diff --git a/.env.release.example b/.env.release.example new file mode 100644 index 0000000..692fb02 --- /dev/null +++ b/.env.release.example @@ -0,0 +1,16 @@ +# Copy to .env.release (gitignored) in the app's repo root and fill in. +# Used by Scripts/release.sh and the asc-*.swift scripts. +# +# These are account-level — the same Apple developer account and API key +# serve every indie project, so copy this file (as .env.release) from an +# existing sibling project rather than filling it out from scratch. +# +# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM). +APPLE_TEAM_ID=C32Z8JNLG6 + +# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API). +# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe. +ASC_KEY_ID=XXXXXXXXXX +ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file. +ASC_KEY_PATH=/Users/rzen/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8 diff --git a/.gitignore b/.gitignore index 6022897..32d1506 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ IceGlass.xcodeproj/ # Claude Code local settings .claude/ + +# Release credentials (App Store Connect API key env) +.env.release diff --git a/IceGlassTests/GameStateTests.swift b/IceGlassTests/GameStateTests.swift new file mode 100644 index 0000000..5840a71 --- /dev/null +++ b/IceGlassTests/GameStateTests.swift @@ -0,0 +1,65 @@ +// +// GameStateTests.swift +// IceGlassTests +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Testing +@testable import IceGlass + +struct GameStateTests { + @Test func decodesFromNHLAPIRawValues() { + #expect(GameState(rawValue: "FUT") == .future) + #expect(GameState(rawValue: "PRE") == .pre) + #expect(GameState(rawValue: "LIVE") == .live) + #expect(GameState(rawValue: "CRIT") == .crit) + #expect(GameState(rawValue: "OVER") == .over) + #expect(GameState(rawValue: "FINAL") == .final_) + #expect(GameState(rawValue: "OFF") == .official) + #expect(GameState(rawValue: "BOGUS") == nil) + } + + @Test func isLiveIsOverIsFutureAreMutuallyConsistent() { + let allCases: [GameState] = [.future, .pre, .live, .crit, .over, .final_, .official] + for state in allCases { + // Each state falls into exactly one of the three buckets used to + // drive menu/notification behavior. + let bucketCount = [state.isFuture, state.isLive, state.isOver].filter { $0 }.count + #expect(bucketCount == 1, "\(state) should be in exactly one of future/live/over") + } + #expect(GameState.future.isFuture) + #expect(GameState.pre.isFuture) + #expect(GameState.live.isLive) + #expect(GameState.crit.isLive) + #expect(GameState.over.isOver) + #expect(GameState.final_.isOver) + #expect(GameState.official.isOver) + } + + @Test func progressionRankIsMonotonicAlongTheAPILifecycle() { + // FUT → PRE → LIVE → CRIT → OVER → FINAL → OFF must never regress; + // MainService relies on this ordering to reject stale API responses + // that would otherwise flip a finished game back to "-:-". + let ordered: [GameState] = [.future, .pre, .live, .crit, .over, .final_, .official] + let ranks = ordered.map(\.progressionRank) + #expect(ranks == ranks.sorted()) + #expect(Set(ranks).count == ranks.count, "ranks must be distinct") + } + + @Test func progressionRankOfRawStringMatchesEnumRankAndHandlesUnknown() { + #expect(GameState.progressionRank(of: "LIVE") == GameState.live.progressionRank) + #expect(GameState.progressionRank(of: "OFF") == GameState.official.progressionRank) + #expect(GameState.progressionRank(of: "NOT_A_STATE") == -1) + } + + @Test func pollingIntervalMapsEachStateToTheExpectedCadence() { + #expect(GameState.future.pollingInterval == .gameDay) + #expect(GameState.pre.pollingInterval == .preGame) + #expect(GameState.live.pollingInterval == .liveGame) + #expect(GameState.crit.pollingInterval == .liveGame) + #expect(GameState.over.pollingInterval == .everyMinute) + #expect(GameState.final_.pollingInterval == .everyMinute) + #expect(GameState.official.pollingInterval == .idle) + } +} diff --git a/LICENSE.md b/LICENSE.md index 6cf97c8..0853f6b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1 +1,7 @@ -Copyright 2026 Rouslan Zenetl. All Rights Reserved. +**ISC License** + +Copyright (c) 2026 Rouslan Zenetl + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Scripts/ExportOptions-macOS.plist b/Scripts/ExportOptions-macOS.plist new file mode 100644 index 0000000..f6b53ee --- /dev/null +++ b/Scripts/ExportOptions-macOS.plist @@ -0,0 +1,21 @@ + + + + + + method + app-store-connect + teamID + C32Z8JNLG6 + + destination + upload + signingStyle + automatic + uploadSymbols + + manageAppVersionAndBuildNumber + + + diff --git a/Scripts/metadata/README.md b/Scripts/metadata/README.md new file mode 100644 index 0000000..c296397 --- /dev/null +++ b/Scripts/metadata/README.md @@ -0,0 +1,58 @@ +# App Store metadata + +Local source-of-truth for the **IceGlass (macOS)** App Store listing. The +appstore-publish skill's `asc-metadata.swift` (invoked in place from +`../indie-skills`) reads this folder and pushes it to App Store Connect via +the API; only files that are present get pushed. Then `asc-submit.swift` +attaches the build and submits. + +``` +set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z --dry-run # preview +set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z # push +set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z --submit # submit +``` + +**Note:** this metadata set is for the **macOS** app record only. IceGlass +also ships an iOS companion (`IceGlass-iOS`, bundle ID +`dev.rzen.indie.IceGlass.iOS`) that isn't wired into the release pipeline +yet — when it's ready to publish, give it its own App Store Connect app +record and its own sibling metadata directory (e.g. `Scripts/metadata-ios/`), +since `asc-metadata.swift` currently queries the `IOS` platform filter when +looking up versions, not `MAC_OS`. + +## Layout & character limits + +| File | Field | Limit | API resource | +|---|---|---|---| +| `app//name.txt` | App name | 30 | `appInfoLocalizations` | +| `app//subtitle.txt` | Subtitle | 30 | `appInfoLocalizations` | +| `app//privacy_url.txt` | Privacy policy URL | — | `appInfoLocalizations` | +| `app/categories.json` | Categories | — | `appInfos` | +| `version//description.txt` | Description | 4000 | `appStoreVersionLocalizations` | +| `version//keywords.txt` | Keywords (comma-sep) | 100 | `appStoreVersionLocalizations` | +| `version//promotional_text.txt` | Promotional text | 170 | `appStoreVersionLocalizations` | +| `version//whats_new.txt` | What's New | 4000 | `appStoreVersionLocalizations` | +| `version//support_url.txt` | Support URL | — | `appStoreVersionLocalizations` | +| `version/review.json` | Review contact / demo / notes | — | `appStoreReviewDetails` | +| `screenshots///*.png` | Screenshots | — | `appScreenshotSets` | + +`name` + `subtitle` are **app-level**; `description` + `keywords` are +**version-level**. `whats_new.txt` only applies after the first version — +leave it out for 1.0. No `whats_new.txt` or `screenshots/` are checked in +yet — screenshots still need to be captured (see the appstore-publish +skill's "Step 3 — Screenshots" for the simulator-capture recipe; macOS +screenshots are plain `screencapture`/window captures at the App Store's +required pixel sizes, not simulator grabs). + +## Screenshot display types (macOS) + +Common types: `APP_DESKTOP` (1280×800 or larger, 16:10). One folder per +device class; filenames sort the order. The local set is authoritative — +re-running replaces the remote set for any display type you provide. + +## Not handled here (separate resource families) + +Age rating (`ageRatingDeclarations`), pricing/availability +(`appPriceSchedules` / `appAvailabilities`), and App Privacy labels +(`appDataUsages`). `asc-submit.swift --submit` (see the appstore-publish +skill) reports any of these that block the submission. diff --git a/Scripts/metadata/app/categories.json b/Scripts/metadata/app/categories.json new file mode 100644 index 0000000..5324531 --- /dev/null +++ b/Scripts/metadata/app/categories.json @@ -0,0 +1,4 @@ +{ + "primary": "SPORTS", + "secondary": null +} diff --git a/Scripts/metadata/app/en-US/name.txt b/Scripts/metadata/app/en-US/name.txt new file mode 100644 index 0000000..7b9157b --- /dev/null +++ b/Scripts/metadata/app/en-US/name.txt @@ -0,0 +1 @@ +IceGlass – NHL Game Tracker \ No newline at end of file diff --git a/Scripts/metadata/app/en-US/privacy_url.txt b/Scripts/metadata/app/en-US/privacy_url.txt new file mode 100644 index 0000000..cb6c7d1 --- /dev/null +++ b/Scripts/metadata/app/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://indie.rzen.dev/apps/iceglass/privacy \ No newline at end of file diff --git a/Scripts/metadata/app/en-US/subtitle.txt b/Scripts/metadata/app/en-US/subtitle.txt new file mode 100644 index 0000000..25f8637 --- /dev/null +++ b/Scripts/metadata/app/en-US/subtitle.txt @@ -0,0 +1 @@ +Menu bar scores & schedule \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/description.txt b/Scripts/metadata/version/en-US/description.txt new file mode 100644 index 0000000..1c0b195 --- /dev/null +++ b/Scripts/metadata/version/en-US/description.txt @@ -0,0 +1,25 @@ +IceGlass puts NHL game situational awareness right in your Mac's menu bar. No window to open, no tab to switch to — click the shield icon and see exactly where every game stands. + +ALWAYS-CURRENT SCOREBOARD +• Shows games from yesterday, today, and tomorrow, grouped by date +• Regular-season rows show the league-wide game number (e.g. "#547 NYR @ WAS") +• Live scores update automatically with a compact "0:2 (FINAL)" style format +• Choose which days you want to see in Display Options + +PLAYOFF MODE +• During the playoffs, every round played so far (Round 1 through the current round) gets its own section +• Each series shows its score, the next game number, and the upcoming tip-off time +• Completed series are clearly marked once a team is eliminated + +STAY ON TOP OF EVERY GAME +• Goal notifications include the scoring team's logo +• Game-start and game-ended notifications +• Dynamic polling — as often as every 7 seconds while a game is live, backing off automatically when nothing's happening +• Click a game to open it in NHL GameCenter; option-click for NHL Videocast + +BUILT FOR THE MENU BAR +• No dock icon, no clutter — IceGlass lives quietly in your menu bar +• Launch at Login so it's always ready +• Refresh Now (⌘R) whenever you want the latest instantly + +IceGlass is for anyone who wants to glance at the score without opening a browser tab — season-long, through every round of the playoffs. diff --git a/Scripts/metadata/version/en-US/keywords.txt b/Scripts/metadata/version/en-US/keywords.txt new file mode 100644 index 0000000..6b12fb6 --- /dev/null +++ b/Scripts/metadata/version/en-US/keywords.txt @@ -0,0 +1 @@ +nhl,hockey,scores,schedule,menu bar,live scores,playoffs,scoreboard,standings,game tracker \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/promotional_text.txt b/Scripts/metadata/version/en-US/promotional_text.txt new file mode 100644 index 0000000..71b192a --- /dev/null +++ b/Scripts/metadata/version/en-US/promotional_text.txt @@ -0,0 +1 @@ +Live NHL scores in your Mac's menu bar — yesterday, today, and tomorrow's games, full playoff brackets, and goal notifications, at a glance. \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/support_url.txt b/Scripts/metadata/version/en-US/support_url.txt new file mode 100644 index 0000000..37fe6b5 --- /dev/null +++ b/Scripts/metadata/version/en-US/support_url.txt @@ -0,0 +1 @@ +https://indie.rzen.dev/apps/iceglass/support \ No newline at end of file diff --git a/Scripts/metadata/version/review.json b/Scripts/metadata/version/review.json new file mode 100644 index 0000000..e64815e --- /dev/null +++ b/Scripts/metadata/version/review.json @@ -0,0 +1,8 @@ +{ + "contactFirstName": "Rouslan", + "contactLastName": "Zenetl", + "contactPhone": "+16465840598", + "contactEmail": "rzenetl@gmail.com", + "demoAccountRequired": false, + "notes": "IceGlass is a macOS menu bar app — no window, no dock icon (LSUIElement). After launching, click the NHL shield icon in the menu bar to open the dropdown showing yesterday/today/tomorrow's games. No account or login is required; it reads NHL's public scoreboard API. During the regular season the dropdown groups games by date; during the playoffs it also lists a ROUND section per round played so far. Display Options (which days to show) and Launch at Login are in the app's settings, reachable from the dropdown." +} diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..f97b337 --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# release.sh — archive IceGlass (macOS menu bar app) and upload to +# App Store Connect. +# +# Usage: Scripts/release.sh +# +# No third-party tooling: pure xcodebuild + the App Store Connect API key. +# Credentials live in .env.release (gitignored); see .env.release.example. +# +# What it does: +# 1. Regenerates the Xcode project with XcodeGen. +# 2. Stamps CFBundleVersion with the git commit count (monotonic build no.) +# via Scripts/update_build_number.sh during the build. +# 3. xcodebuild archive -> .xcarchive (generic/platform=macOS) +# 4. xcodebuild -exportArchive with destination=upload -> App Store Connect. +# +# NOTE: this project also ships an iOS companion (IceGlass-iOS, its own +# bundle ID and — eventually — its own App Store Connect app record). This +# script only releases the macOS app. When the iOS companion is ready to +# publish, copy this file to release-ios.sh, swap PROJECT/SCHEME/BUNDLE_ID +# for the iOS target, destination to "generic/platform=iOS", and point +# -exportOptionsPlist at an ExportOptions-iOS.plist (see the appstore-publish +# skill's template) — plus a sibling Scripts/metadata/ set for its own listing. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +PROJECT="IceGlass.xcodeproj" +SCHEME="IceGlass" +BUILD_DIR="$ROOT/build" + +# ---- credentials ------------------------------------------------------------ +ENV_FILE="$ROOT/.env.release" +if [ -f "$ENV_FILE" ]; then + set -a; . "$ENV_FILE"; set +a +fi +: "${APPLE_TEAM_ID:?Set APPLE_TEAM_ID (copy .env.release.example -> .env.release)}" +: "${ASC_KEY_ID:?Set ASC_KEY_ID in .env.release}" +: "${ASC_ISSUER_ID:?Set ASC_ISSUER_ID in .env.release}" +: "${ASC_KEY_PATH:?Set ASC_KEY_PATH in .env.release}" +[ -f "$ASC_KEY_PATH" ] || { echo "❌ API key not found at: $ASC_KEY_PATH"; exit 1; } + +AUTH=( + -authenticationKeyPath "$ASC_KEY_PATH" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + -allowProvisioningUpdates +) + +# ---- regenerate project ----------------------------------------------------- +if command -v xcodegen >/dev/null 2>&1; then + echo "🧩 Generating $PROJECT ..." + xcodegen generate +elif [ ! -d "$PROJECT" ]; then + echo "❌ $PROJECT missing and xcodegen not installed."; exit 1 +fi + +# ---- versioning ------------------------------------------------------------- +# CFBundleVersion is stamped during the build by Scripts/update_build_number.sh +# (raw commit count). The marketing version lives in project.yml's +# MARKETING_VERSION setting for the IceGlass target (Info.plist references it +# via $(MARKETING_VERSION), so there's no literal to PlistBuddy-read). +BUILD_NUMBER="$(git rev-list HEAD --count)" +MARKETING_VERSION="$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/')" +echo "📦 Version $MARKETING_VERSION — build $BUILD_NUMBER" + +mkdir -p "$BUILD_DIR" + +ARCHIVE_PATH="$BUILD_DIR/$SCHEME.xcarchive" +EXPORT_PATH="$BUILD_DIR/$SCHEME-export" +rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH" + +echo "" +echo "🛠 Archiving $SCHEME (macOS) ..." +xcodebuild archive \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -archivePath "$ARCHIVE_PATH" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + "${AUTH[@]}" + +echo "🚀 Exporting + uploading to App Store Connect ..." +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$SCRIPT_DIR/ExportOptions-macOS.plist" \ + "${AUTH[@]}" + +echo "✅ IceGlass uploaded (build $BUILD_NUMBER)." +echo "" +echo "🎉 Done. The build appears in App Store Connect > TestFlight after processing (~5–15 min)." +echo "" +echo "➡️ Push metadata, then stage/submit for review with the canonical scripts" +echo " (apps sit as siblings of indie-skills under ~/Documents/indie; --bundle" +echo " and --version are required flags — there are no per-app defaults):" +echo "" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION --dry-run" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION # stage only" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION --submit # stage + submit" diff --git a/project.yml b/project.yml index 53174f9..99f4e72 100644 --- a/project.yml +++ b/project.yml @@ -59,6 +59,22 @@ targets: com.apple.security.app-sandbox: true com.apple.security.network.client: true + IceGlassTests: + type: bundle.unit-test + platform: macOS + sources: + - IceGlassTests + dependencies: + - target: IceGlass + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlassTests + DEVELOPMENT_TEAM: C32Z8JNLG6 + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "13.0" + SWIFT_STRICT_CONCURRENCY: complete + GENERATE_INFOPLIST_FILE: true + IceGlass-iOS: type: application platform: iOS @@ -107,6 +123,10 @@ schemes: config: Debug archive: config: Release + test: + config: Debug + targets: + - IceGlassTests IceGlass-iOS: build: