diff --git a/Cargo.lock b/Cargo.lock index 92bbf85..33d0949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1472,6 +1472,9 @@ dependencies = [ "deadpool-postgres", "keyring", "mysql_async", + "objc2", + "objc2-foundation", + "objc2-store-kit", "serde", "serde_json", "tauri", @@ -3430,6 +3433,20 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-store-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a1cef520f802b637e2cca72d7dfd2f17cfff947fb5f802dd2d8c68e6c63072" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" diff --git a/package.json b/package.json index b9168c1..ca3bb4e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "preview": "vite preview", "type-check": "tsc --noEmit", "lint": "eslint .", - "tauri": "tauri" + "tauri": "tauri", + "tauri:build:mas": "bash scripts/build-mas.sh" }, "dependencies": { "@codemirror/autocomplete": "6.20.1", diff --git a/progress.txt b/progress.txt index 9b82911..6ef0f8e 100644 --- a/progress.txt +++ b/progress.txt @@ -1,3 +1,610 @@ +## MAS PRD P3 — Store Listing Assets: app icon + metadata [PARTIAL: icon + text DONE, screenshots PENDING] +- Goal: produce the App Store Connect-ready assets and text content from + PRD §P3. The PRD said "no code changes" — implementation here is a new + store-listing/ directory plus a small Swift generator so the icon build + is reproducible (re-runnable if src-tauri/icons/icon.png ever changes). + Screenshots are intentionally not produced — they need a running app + with real DB content, which can't be reasonably scripted from a CLI. +- store-listing/app-icon-1024.png (NEW, 288417 bytes): 1024×1024 8-bit RGB + PNG, no alpha channel, no rounded corners. App Store Connect rejects + PNGs with alpha or with the source's own rounded mask (Apple applies + its own squircle mask). Verified with `file`: "PNG image data, 1024 x + 1024, 8-bit/color RGB, non-interlaced". +- store-listing/scripts/build-app-store-icon.swift (NEW, ~150 lines): the + generator. Loads src-tauri/icons/icon.png (rounded-corner gradient with + transparent corners), samples the source's own corner-most fully-opaque + pixel along each of the four diagonals (walks inward from each corner + until alpha == 255 — that hit is the gradient colour at the rounded-rect + apex), averages the four samples for the background fill, then composites + the source onto a 1024×1024 noneSkipLast (RGB-only, alpha-discarding) + CGContext. Final PNG carries no alpha channel because the underlying + bitmap doesn't have one — not "alpha set to 255" but the channel is + genuinely absent. Reproducible: `swift store-listing/scripts/build- + app-store-icon.swift src-tauri/icons/icon.png store-listing/app-icon- + 1024.png`. Sampled background for the current source: #203753. + - Why edge-extension by ray-cast to centre was tried and reverted: ray- + casting each transparent pixel toward the centre and copying the + nearest opaque pixel along that ray produced visible radial streaks + in the corners (the source's gradient varies tangentially around the + rounded rect, so neighbouring corner pixels picked colours from + different gradient positions). The "composite onto matched solid + fill" approach has one harmless artefact in trade — a faint outline + where the rounded mask used to be — but that area is fully outside + Apple's eventual icon mask, so it's invisible in the App Store. +- store-listing/metadata.md (NEW, ~120 lines): full App Store Connect + text-content block in one file so the submission form can be filled in + from this alone (no PRD context-switching). Mirrors PRD §P3 Text + Content + IAP Products + adds Version Release Notes, Age Rating, and + an App Review note explaining the network entitlement scope and how to + exercise the commercial-license code path during review. The PRD's + description block is copied byte-for-byte; the IAP product table + mirrors PRD §IAP Products. +- store-listing/screenshots/README.md (NEW): capture guidance + file-name + convention (01-connections.png … 05-settings.png so gallery order is + preserved during upload). Lists the five suggested shots from PRD §P3 + with concrete instructions (which table to load, which theme to use, + what state to put the UI in). Calls out that the Direct build and MAS + build look identical in App Review's eye except for the sidebar + vibrancy, so captures from the Direct build are valid for MAS + submission. +- store-listing/README.md (NEW): file-by-file index of the directory so a + future reviewer can see at a glance what's ready vs pending. +- PRD.md: P3 section reorganised. Sub-headers are now `#### App icon + [DONE]`, `#### Screenshots [PENDING — manual capture]`, `#### Text + content [DONE]`. Each sub-section references the corresponding file + under store-listing/. The icon sub-section embeds the regenerate + command so it's discoverable from the PRD alone. +- Verification: + - npm run type-check — clean (asset-only change, no TS impact). + - npm run lint — clean. + - cargo test (Direct) — 22 passed (unchanged). + - file store-listing/app-icon-1024.png — confirmed + "PNG image data, 1024 x 1024, 8-bit/color RGB, non-interlaced", i.e. + correct dimensions and no alpha channel. +- Out of scope (intentionally): + - Screenshot capture. Requires a running app with realistic DB content, + correct window sizing, theme toggling, and at least 3 distinct UI + states. Doing it from a CLI agent without a graphical session would + produce sub-spec screenshots and waste re-capture effort later. + Documented as the next manual step in store-listing/screenshots/ + README.md so whoever submits the app can pick it up cleanly. + - App Store Connect configuration (create app record, register IAP + product IDs, set privacy/support URLs, enrol in Small Business + Program). Per PRD §Submission Flow Step 1 these depend on Apple + Developer account access that's outside the repo. + - TestFlight upload via Transporter. Needs the App-Specific Password + + Apple Distribution cert + provisioning profile not in this repo. + +## MAS PRD P4 — Build pipeline (scripts/build-mas.sh) [DONE] +- Goal: turn the Cargo.toml patch + cargo tauri build + restore sequence from + the PRD's P1 note into a single repeatable command. Previously the procedure + lived only in prose; running it by hand risked leaving Cargo.toml in a + half-patched state if anything went wrong mid-build. Now it's one npm + script. +- scripts/build-mas.sh (NEW, ~100 lines, +x): + - Computes ROOT_DIR from BASH_SOURCE so it works regardless of where it's + invoked from (CI, repo root, src-tauri/, …). + - Preflight: asserts src-tauri/Cargo.toml and src-tauri/tauri.mas.conf.json + exist and that the literal line + `tauri = { version = "2", features = ["macos-private-api"] }` + is present (anchored full-line equality via `grep -Fxq`). Any drift in + the dependency declaration fails fast with a remediation hint that names + the EXPECTED_LINE / PATCHED_LINE constants in the script to update. + Better to refuse to build than to silently produce the wrong binary. + - Backup: copies Cargo.toml to `$(mktemp -t esploro-cargo-toml.XXXXXX)` and + installs `trap restore EXIT`. The restore function copies the backup + back, then unlinks it. EXIT fires on success, normal failure, and SIGINT + so Ctrl+C mid-build still leaves the working tree pristine. + - Patch: awk full-line match against EXPECTED_LINE replaces with + PATCHED_LINE (= `tauri = { version = "2", features = [] }`), writes to a + sibling .new file, then `mv` for atomicity. Using awk rather than sed + sidesteps the GNU/BSD `sed -i` flag-arg portability foot-gun. + - Build: invokes `npm run tauri -- build --target $TARGET --config + src-tauri/tauri.mas.conf.json --features mas "$@"`. Going through npm + (rather than `cargo tauri build`) uses the Tauri CLI pinned in + package.json so the developer-local build matches whatever CI ends up + running. Extra "$@" args forward to `tauri build` for flags like + --verbose or --debug. + - Environment: TAURI_TARGET overrides the target triple. Defaults to + aarch64-apple-darwin per the PRD's "ARM only initially" scope. The var + exists for the eventual fat-binary follow-up. +- package.json: new "tauri:build:mas" script = "bash scripts/build-mas.sh". + `npm run tauri:build:mas` is now the one-liner entry point. No new + dependencies (awk, sed, mktemp all ship with macOS). +- PRD.md: + - P1 note's trailing paragraph now references P4: "The patch+build+restore + dance is automated as `scripts/build-mas.sh` (see P4)." + - New `### P4 — MAS build pipeline [DONE]` workstream captures the script + behaviour, the environment variables, and the verification matrix. + - Submission Flow → Step 2 (Build MAS binary) collapses the three-step + manual procedure to `npm run tauri:build:mas`. +- Verification: + - `bash -n scripts/build-mas.sh` clean (no shell syntax errors). + - Awk patch tested on a copy of Cargo.toml in /tmp: produces exactly the + expected single-line diff (line 39 `features = ["macos-private-api"]` → + `features = []`), no other changes. + - Direct build untouched: `npm run type-check` clean, `npm run lint` + clean, `cargo clippy --all-targets -- -D warnings` clean, `cargo test` + 22 passed (unchanged from previous step). + - MAS build untouched: `cargo clippy --all-targets --features mas -- + -D warnings` clean, `cargo test --features mas` 27 passed (unchanged + from previous step). Note: the Cursor terminal's sandboxed view of + ~/.cargo/registry/cache/ has no write permission (macOS TCC restriction + on this app, not a project issue), so MAS checks ran with + CARGO_HOME=/tmp/esploro-cargo-home. This is a local-environment quirk + only; CI on macos-latest writes to the default $HOME/.cargo and is + unaffected. +- Out of scope (intentionally): + - Wiring the script into .github/workflows/ci.yml. The current CI workflow + only exercises the Direct build (clippy + test + tauri build). Adding a + MAS job needs Apple Distribution cert + provisioning profile + + App-Specific Password for Transporter, all of which require repository + secrets that aren't configured yet. The script is the unit; CI is the + next deliverable once the App Store Connect side is set up (PRD + Submission Flow Step 1). + - Live TestFlight build with this script. That validates end-to-end IAP + flow (PurchaseSheet → SKPayment → Keychain entitlement) and is a Step 3 + Submission Flow concern, not a P4 deliverable. + +## MAS PRD P2 — Step 4: Frontend (Purchase Sheet, build-flavour branching) [DONE] +- Goal: ship the MAS-flavoured licensing UI (PurchaseSheet, Restore Purchases, + Manage Subscription) without forking the Vite bundle. Single index.html / + React tree picks the right UI at runtime by querying a new build-flavour + Tauri command. Direct build's UI is unchanged — every Direct branch in the + modified components matches the previous markup byte-for-byte. +- src-tauri/src/commands/license.rs: + - New always-on Tauri command get_build_flavor() -> &'static str. Returns + "mas" when cfg!(feature = "mas"), else "direct". cfg! is a compile-time + macro so each binary returns its own constant — no runtime feature + detection. Doc comment notes the frontend caches this with + staleTime: Infinity since the value is fixed for the binary's lifetime. + - Two new unit tests pin the cfg → string mapping per build: + build_flavor_reports_direct_when_mas_feature_off (#[cfg(not(feature = "mas"))]) + build_flavor_reports_mas_when_mas_feature_on (#[cfg(feature = "mas")]) + They live in the existing tests module so cargo test on Direct asserts + "direct" and cargo test --features mas asserts "mas". One of the two is + always cfg'd out for any given build — there's no runtime branch to test + that could lie about the wrong flavour. +- src-tauri/src/lib.rs: registered get_build_flavor in the always-on section + of invoke_handler! (alongside open_url and the prefs commands), so the + frontend's first call resolves regardless of feature flags. +- src/features/license/types.ts: added BuildFlavor = 'direct' | 'mas' plus + the wire shapes for IAP — IapProduct, IapPurchaseStatus, IapPurchaseResult, + IapRestoreResult, IapEntitlement (camelCase fields matching the Rust + serde rename_all = "camelCase"). productId / expiresAt typed as + string | null since the Rust side serialises Option as null. +- src/features/license/api.ts: cleaned up and extended. + - New query keys: BUILD_FLAVOR_KEY, IAP_PRODUCTS_KEY, IAP_ENTITLEMENT_KEY. + - licenseApi.getBuildFlavor() + - licenseApi.getProducts() / .purchase(productId) / .restore() / + .checkEntitlement() — wrappers for the four MAS-only commands. + - licenseApi.openManageSubscription() — invokes open_url with + macappstore://apps.apple.com/account/subscriptions, the canonical + deep link for managing a Mac App Store subscription. macappstore:// + opens the Mac App Store app directly; the https:// fallback would + detour through a browser → handoff and feels broken to users. + - The Direct-only methods (activate, deactivate, openCustomerPortal) + stayed in place; they're still callable but only reachable from the + Direct branch of the new components. +- src/features/license/index.ts: re-exports PurchaseSheet, all new query + keys, and the new types so callers can import everything from + '../features/license'. +- src/features/license/PurchaseSheet.tsx (NEW, ~210 lines): + - Right-side Radix Dialog (w-[360px], matches LicenseActivationSheet + so the two sheets feel like one component family). + - Fetches products via React Query keyed on IAP_PRODUCTS_KEY, enabled + only while the sheet is open, staleTime 5 min. Loading state shows + a centred spinner; failure state shows the error + a Try again + button that triggers refetch(). + - Renders one card per IapProduct with title, price (StoreKit-formatted + on the Rust side, so no Intl needed), description, and a Buy button. + busyProductId tracks which card is awaiting iap_purchase; while one is + in flight all other Buy buttons + the Restore link disable. + - Purchase outcomes: status="purchased" → refresh both LICENSE_STATUS_KEY + and IAP_ENTITLEMENT_KEY caches, toast success, close sheet. + status="cancelled" → silent (user hit Cancel themselves). + status="failed" → toast error. + - Restore link at the bottom of the sheet: calls iap_restore, refreshes + both caches, toasts success/info based on result.restored, closes if + something was restored. +- src/features/license/LicenseBanner.tsx: branches on buildFlavor. + - Reads buildFlavor via React Query (BUILD_FLAVOR_KEY, staleTime: Infinity). + - The revalidation-required banner is now Direct-only — it's gated behind + !isMas because revalidation_required is always false on MAS (StoreKit + is the source of truth, no offline-grace concept). + - The standard banner: Direct keeps the existing two-button layout + (Purchase license + I have a license key); MAS shows a single + Get a license button that opens PurchaseSheet. Dismiss [×] still + works in both flavours. + - Sheets are rendered conditionally: Direct mounts LicenseActivationSheet, + MAS mounts PurchaseSheet. Mounting both at once would either be a no-op + (only one ever opens) or a dead-call risk (the Direct sheet calls + activate_license, which doesn't exist on MAS). +- src/features/license/LicenseSettings.tsx: branches on buildFlavor with + three render paths. + - MAS commercial: pulls IapEntitlement via React Query, maps productId + through PRODUCT_LABELS to a friendly plan name (Personal — Lifetime, + Personal — Annual, Business — Annual) with a fallback to the raw ID. + Subscription rows show "Renews on " + Manage subscription + + Restore purchases. Lifetime row (expires_at == null) shows + "One-time purchase" and skips Manage subscription. + - MAS unlicensed: Personal (free) / No license header, Get a license + button (opens PurchaseSheet), Restore purchases button. + - Direct: existing layout untouched (Commercial / Personal / Unlicensed + branches all match the previous file). + - The IAP_ENTITLEMENT_KEY query is enabled only when isMas is true so + Direct doesn't try to invoke iap_check_entitlement (would 404 — the + command isn't registered there). + - Restore purchases is mounted in two places (PurchaseSheet + Settings) + per the PRD's mitigation for "first submission rejected for missing + Restore Purchases": both placements together cover the App Review + precedent that Restore must be discoverable without buying anything. +- Verification: + - Direct: cargo clippy --all-targets -- -D warnings clean, cargo test + 22 passed (was 21 — the new build_flavor_reports_direct test). + - MAS: cargo clippy --all-targets --features mas -- -D warnings clean, + cargo test --features mas 27 passed (was 26 — the new + build_flavor_reports_mas test). + - Frontend: npm run type-check clean, npm run lint clean. +- Live StoreKit testing of PurchaseSheet (sandbox accounts, three IAP + products appearing with correct prices, sandbox purchase end-to-end, + Restore Purchases) is a TestFlight-time validation per the PRD's + Submission Flow → TestFlight checklist; not part of this step. + +## MAS PRD P2 — Step 3: objc2-store-kit wiring [DONE] +- Replaces the four iap_* command stubs from Step 1 with a real StoreKit 1 + backend, wired through objc2-store-kit (pure-Rust bindings, no Swift + plugin). The MAS binary can now fetch product metadata, kick off purchases + through the App Store payment sheet, restore prior transactions, and + surface the current entitlement to the frontend. The Direct (GitHub/Homebrew) + build is bit-for-bit unchanged — the new dependencies are gated to + --features mas + target_os = "macos". +- src-tauri/Cargo.toml: + - [features].mas now lists "dep:objc2", "dep:objc2-foundation", + "dep:objc2-store-kit" so enabling the feature pulls them in. + - New [target.'cfg(target_os = "macos")'.dependencies] block adds the three + crates as optional. They're target-gated so a hypothetical Linux/Windows + cargo check doesn't try to compile them. objc2 = "0.6", objc2-foundation + = "0.3", objc2-store-kit = "0.3" — default features for all three (they + each enable their own NS* / SK* feature flags by default, which is what + we need for NSString/NSDate/NSDecimalNumber/NSNumberFormatter and the + SK* types used here). +- src-tauri/src/commands/iap_storekit.rs (NEW, ~430 lines, #![allow(deprecated)] + because every StoreKit 1 symbol upstream is #[deprecated] in favour of + StoreKit 2 / Swift Product — migration is the future ADR boundary): + - DelegateState ivar struct holds three Mutex> oneshot slots + (pending_products, pending_purchase, pending_restore), a Vec> + cache (so we can re-use SKProduct objects across fetch_products / + purchase calls and read SKProductSubscriptionPeriod for expiry math), + and a Mutex>> that keeps the in-flight + request alive until its delegate callback fires. + - EsploroStoreKitDelegate is defined via objc2::define_class! with + #[unsafe(super(NSObject))], #[name = "EsploroStoreKitDelegate"] (explicit + Obj-C runtime name so Apple's review tools see a stable symbol), and + #[ivars = DelegateState]. It conforms to three protocols: + * SKRequestDelegate — request:didFailWithError: routes SKProductsRequest + failures back through the pending_products oneshot. + * SKProductsRequestDelegate — productsRequest:didReceiveResponse: + walks SKProductsResponse.products(), turns each SKProduct into an + IapProduct wire value (via NSNumberFormatter with CurrencyStyle + + product.priceLocale for the localised price string), stores the + Retaineds in the cache, and resolves the pending oneshot. + * SKPaymentTransactionObserver — paymentQueue:updatedTransactions: + iterates transactions; Purchased and Restored both write a fresh + StoredEntitlement to the Keychain (via iap::write_stored_entitlement) + and call finishTransaction:. Purchased additionally resolves the + pending_purchase oneshot with status="purchased" iff the + productIdentifier matches the in-flight purchase. Failed inspects + txn.error().code() against SKErrorCode::PaymentCancelled (NSInteger + = 2) to distinguish status="cancelled" from status="failed". + paymentQueueRestoreCompletedTransactionsFinished: resolves the + pending_restore oneshot with the running restored_any bool; + paymentQueue:restoreCompletedTransactionsFailedWithError: resolves it + with the NSError.localizedDescription wrapped as Err. + - Manager singleton (held in static OnceLock): allocates the + delegate via EsploroStoreKitDelegate::new() and calls + SKPaymentQueue::defaultQueue().addTransactionObserver(...) exactly once. + Apple's docs require the observer be installed at app launch so + transactions delivered while the app is starting up aren't dropped — + install_observer_on_startup(MainThreadMarker) is the public entry point + called from lib.rs's setup() hook for that. The MainThreadMarker + parameter is unused at runtime but documents that the caller must be on + the AppKit main thread (which Tauri's setup is). + - Public async API (consumed by iap.rs): + * fetch_products(ids: Vec) -> Result, String> + * purchase(product_id: String) -> Result + (fetches and caches the SKProduct first if it isn't already cached, + guards on SKPaymentQueue::canMakePayments()) + * restore() -> Result + Each grabs a oneshot::Sender slot, kicks off the StoreKit operation + (start(), addPayment:, restoreCompletedTransactions), and awaits the + delegate callback. Re-entrant calls (two purchases / two restores in + flight) return Err("Another … is already in progress") rather than + trampling the existing sender. + - Helpers: + * compute_expires_at(product_id, txn_date: NSDate, cached_products): + looks up the SKProduct, reads subscriptionPeriod() (None for + non-consumables like the lifetime product), and adds + numberOfUnits * unit (Day/Week/Month/Year) to txn_date using + chrono::Months (for Month/Year — calendar-correct, handles + end-of-month rollover) and chrono::Duration (for Day/Week). Lifetime + purchases get expires_at = None. + * format_price(NSDecimalNumber, NSLocale): NSNumberFormatter with + CurrencyStyle and the SKProduct's priceLocale yields strings like + "$129.00", "€49,00", "¥7,800" — exactly what the frontend wants + without an Intl round-trip. +- src-tauri/src/commands/iap.rs: + - The four #[tauri::command] stubs (iap_get_products, iap_purchase, + iap_restore, iap_check_entitlement) now call into iap_storekit. + - New const PRODUCT_IDS: &[&str; 3] hard-codes the three App Store Connect + product identifiers (app.esploro.personal.lifetime, + app.esploro.personal.annual, app.esploro.business.annual). iap_get_products + passes them straight to iap_storekit::fetch_products — the App Store + rejects any IDs not registered on the listing, so there's no value in + making this client-driven. + - iap_check_entitlement reads the cached StoredEntitlement from the + Keychain (via the existing read_stored_entitlement helper, now no + longer #[allow(dead_code)] since it's actually called), then decides + entitled by comparing expires_at to chrono::Utc::now(). Non-consumable + lifetime products (expires_at = None) are always entitled; subscriptions + with a future expiry are entitled; past/unparseable timestamps are not. + This matches the mas_cached_license logic in license.rs (Step 2) but at + a different layer — license.rs makes the LicenseStatus decision, + iap_check_entitlement just reports the raw cached entitlement so the + frontend can show the "Manage Subscription" / "Get a License" UI in + Settings. + - write_stored_entitlement is no longer #[allow(dead_code)] because the + transaction observer in iap_storekit calls it on every Purchased / + Restored transaction. clear_stored_entitlement keeps its + #[allow(dead_code)] pending Step 4's "manage subscription" UI hook. +- src-tauri/src/commands/mod.rs: added `pub mod iap_storekit;` behind + #[cfg(feature = "mas")]. +- src-tauri/src/lib.rs: setup() hook now has a #[cfg(feature = "mas")] block + that grabs objc2::MainThreadMarker::new() (asserts main thread, which + Tauri's setup is) and calls iap_storekit::install_observer_on_startup, + which forces the OnceLock singleton + transaction observer to be + ready before the webview loads. This mirrors the existing + #[cfg(not(feature = "mas"))] revalidate_license_background loop. +- Tests: + - 5 new unit tests in iap_storekit::tests cover add_period across all + SKProductPeriodUnit variants: + * add_period_year_adds_twelve_months + * add_period_month_adds_calendar_month (3 months from 2026-05-18 → + 2026-08-18, exercising chrono's calendar-aware month arithmetic) + * add_period_week_adds_seven_days_per_unit (2 weeks → +14 days) + * add_period_day_adds_n_days + * add_period_unknown_unit_returns_none (fail-closed guard for the + unreachable-in-practice catch-all arm) + These are the only iap_storekit logic that can be exercised without a + live StoreKit session; everything else (delegate callbacks, SKPayment + flow) needs a sandbox App Store Connect account + signed MAS build, which + is a Step 4 / TestFlight concern. + - cargo test: 21 passed on Direct (unchanged), 26 passed on MAS + (+5 add_period tests). + - cargo clippy --all-targets -- -D warnings: clean (Direct). + - cargo clippy --all-targets --features mas -- -D warnings: clean (MAS). + - cargo check --features mas: clean. + - npm run type-check, npm run lint: clean. +- Known limitations (deferred to Step 4 / submission time): + - Live StoreKit testing requires sandbox accounts in App Store Connect and + a packaged .pkg uploaded to TestFlight. The Rust code is wired and + type-checks; verifying that the payment sheet actually appears and that + Purchased / Restored transactions resolve correctly is a manual test + against a sandbox account. + - Receipt validation: StoreKit 1 stores a local receipt at + Bundle.main.appStoreReceiptURL. We currently trust SKPaymentTransactions + delivered by the observer rather than parsing the receipt — sufficient + for IAP at this scale (no server-side receipt validation is needed per + the PRD's Known Risks section). If we later want offline-revocable + entitlement, SKReceiptRefreshRequest is the entry point (the binding is + pulled in but unused at this step). + - The objc2-store-kit bindings are all #[deprecated] because Apple moved + everything to Swift Product / Transaction APIs. The migration boundary + is the four iap_* commands — the frontend won't notice. ADR 09 + (plans/09-storekit-objc2.md) covers the reasoning for staying on + StoreKit 1 for the initial MAS submission. +- PRD.md P2 section: Step 3 marked [DONE] with the full implementation + description; Step 4 (frontend PurchaseSheet) remains pending. + +## MAS PRD P2 — Step 2: license-layer cleanup [DONE] +- Finishes the source-level split started in Step 1: every Dodo-specific item in license.rs + is now behind #[cfg(not(feature = "mas"))], and compute_status_pure works off a small + cross-build CachedLicense abstraction instead of a Dodo-shaped StoredLicense. The MAS + build no longer drags Dodo Payments / curl / DODO_BASE code into the binary, and the + `cargo check --features mas` 12-warning dead-code spew from Step 1 is gone. +- src-tauri/src/commands/license.rs: + - Gated behind #[cfg(not(feature = "mas"))]: + - Constants CUSTOMER_PORTAL_URL, DODO_BASE, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT (only + Dodo uses this keychain account; MAS uses the separate "mas-entitlement" account in + iap.rs). + - struct StoredLicense (Dodo validation cache). + - read_stored_license / write_stored_license / clear_stored_license keychain helpers. + - enum DodoError + async fn call_dodo_validate (the curl-wrapped POST to Dodo's + /licenses/validate endpoint). + - dodo_error_message / dodo_invalid_key_message UI string helpers. + - pub async fn revalidate_license_background (the 24h re-validation loop spawned by + lib.rs — already cfg-gated at the call site in Step 1, now also at the definition). + - The three Tauri commands activate_license, deactivate_license, open_customer_portal. + - Added enum CachedLicense as the cross-build abstraction for "is there a valid cached + commercial license?": Active is reachable from both builds, Stale is + #[cfg(not(feature = "mas"))]-only since App Store subscriptions have no offline-grace + concept (StoreKit is the source of truth and a lapsed subscription is simply not + entitled — there's no "we used to be entitled, please reconnect"). + - Two new build-specific translator helpers feed the same compute_status_pure: + - direct_cached_license(&StoredLicense, now): Active when validated_at is within the + 14-day window, Stale beyond that. Unparseable validated_at also returns Stale (fail + closed so a corrupt cache forces a fresh validation). + - mas_cached_license(&StoredEntitlement, now): Some(Active) for the non-consumable + lifetime product (expires_at = None) or any subscription whose expires_at is still + in the future. None for expired or unparseable subscription timestamps. + - compute_status_pure rewritten to take Option instead of + Option<&StoredLicense>. The match arm for CachedLicense::Stale is itself + #[cfg(not(feature = "mas"))]-gated, so on the MAS build the match becomes exhaustive + with just the Active arm (no unreachable_patterns warnings, no clippy noise). + - New cfg-gated current_cached_license(now) helper consumes the right keychain entry per + build: read_stored_license() + direct_cached_license() on Direct, + crate::commands::iap::read_stored_entitlement() + mas_cached_license() on MAS. + compute_status (the I/O wrapper called by the Tauri commands) just chains it into + compute_status_pure. + - Wire shape of LicenseStatus is unchanged. revalidationRequired and gracePeriodEnds + remain on the camelCase JSON for both builds, but on MAS they're always false / null — + matching the precedent set in Step 0 (P0) for grace_period_ends. The frontend + LicenseBanner.tsx hits the revalidationRequired branch only when set; on MAS that + branch is just unreachable, no UI change required. +- src-tauri/src/commands/iap.rs: + - Dropped the #[allow(dead_code)] attribute on read_stored_entitlement — it's now wired + into license.rs::current_cached_license under #[cfg(feature = "mas")]. + write_stored_entitlement and clear_stored_entitlement keep their #[allow(dead_code)] + pending Step 3 (objc2-store-kit transaction handler will write/clear when the user + purchases or cancels). +- Tests reorganised inside license.rs `mod tests`: + - 5 build-agnostic status-state tests now take CachedLicense::Active or None directly: + cached_active_is_commercial_no_banner, commercial_detected_shows_banner_immediately, + commercial_detected_banner_hidden_when_dismissed_for_session, + commercial_detected_long_ago_still_shows_banner, + no_cached_license_no_commercial_detection_is_unlicensed_no_banner. + - 7 Direct-only tests under `#[cfg(not(feature = "mas"))] mod direct`: + freshly_validated_key_maps_to_active, key_validated_13_days_ago_still_active, + key_validated_over_14_days_ago_is_stale, unparseable_validated_at_is_stale (new — was + missing coverage for the fail-closed branch), cached_stale_maps_to_revalidation_required, + plus the three Dodo error-message tests. + - 4 new MAS-only tests under `#[cfg(feature = "mas")] mod mas`: + lifetime_entitlement_with_no_expiry_is_active, subscription_future_expiry_is_active, + subscription_past_expiry_is_none, subscription_unparseable_expiry_is_none. +- Verified on both build flavours: + - cargo clippy --all-targets -- -D warnings: clean (Direct). + - cargo clippy --all-targets --features mas -- -D warnings: clean (MAS). + - cargo test: 21 passed on Direct (was 22 in Step 1; 4 IAP serde tests now only compile + on MAS so the Direct total drops by 4, while 3 new Direct-only direct_cached_license + tests bring it back up by 3 — net 21). + - cargo test --features mas: 21 passed on MAS (4 IAP serde tests + 4 new mas:: tests + + 5 build-agnostic status tests + 8 data filter tests). + - cargo check --features mas: 0 warnings (down from 12 in Step 1). + - npm run type-check: clean. + - npm run lint: clean. +- PRD.md P2 section: Step 2 marked [DONE] with the full description of which items moved + behind cfg gates, the CachedLicense abstraction, and the per-build translator helpers. + +## MAS PRD P2 — Step 1: IAP backend scaffold + Dodo/updater feature gating [DONE] +- Sets up the source-level boundary between the Direct (Dodo Payments + tauri-plugin-updater) + build and the MAS (StoreKit IAP, no in-app updater) build. The Direct build is bit-for-bit + identical; the MAS build now compiles, though the four IAP commands are stubs returning + Err("IAP backend not yet implemented") until Step 3 wires `objc2-store-kit`. No frontend + changes in this step. +- src-tauri/src/commands/iap.rs (NEW, behind #[cfg(feature = "mas")]): + - Wire types matching the PRD's command return shapes (camelCase via serde): + - IapProduct { id, title, description, price } — price is a pre-formatted locale string + because SKProduct hands us the localised value via priceLocale, saving the frontend an + Intl pass. + - IapPurchaseResult { status } where status is IapPurchaseStatus::Purchased | Cancelled | + Failed (serde rename_all = "lowercase" so the JSON is `{ "status": "purchased" }` etc.). + - IapRestoreResult { restored: bool }. + - IapEntitlement { entitled, productId?, expiresAt? } — productId/expiresAt are None for + unentitled users; expiresAt is None for the non-consumable lifetime product. + - StoredEntitlement { product_id, expires_at: Option } cached in the macOS Keychain + (service "app.esploro", account "mas-entitlement" — distinct from the existing Dodo + "commercial-license" account so the two builds never collide). read/write/clear helpers + are pub(crate) + #[allow(dead_code)] until Step 2 hooks them into compute_status_pure. + - Four #[tauri::command] stubs (iap_get_products, iap_purchase, iap_restore, + iap_check_entitlement) all returning Err("IAP backend not yet implemented"). They exist + so lib.rs registration, command signatures, and future frontend invokes can be wired + without coupling to the StoreKit work. + - 4 unit tests covering serde shape: lowercase purchase status, camelCase keys on + IapEntitlement, IapProduct field presence, and StoredEntitlement JSON round-trip. +- src-tauri/src/commands/mod.rs: added `pub mod iap;` behind #[cfg(feature = "mas")] and + gated `pub mod updater;` behind #[cfg(not(feature = "mas"))] — the updater module is + unreachable from a MAS binary anyway, this just prevents it from being type-checked. +- src-tauri/src/lib.rs: + - Restructured the Builder chain so tauri_plugin_updater is only plugged in for + #[cfg(not(feature = "mas"))]. App Store rules disallow self-updating outside the store + mechanism, so the plugin would be dead weight (and a review risk) on MAS. + - Wrapped the `revalidate_license_background` background spawn in + #[cfg(not(feature = "mas"))]. The MAS build sources entitlement from StoreKit and has + no license key to re-validate. + - generate_handler! invocation: kept the always-on commands (connections, schema, data, + saved_queries, license::{get_license_status, answer_usage_dialog, dismiss_license_banner, + notify_connection_count, open_url, get_ui_preferences, set_ui_preferences}) at the top; + gated activate_license, deactivate_license, open_customer_portal, updater::check_for_update, + updater::install_update behind #[cfg(not(feature = "mas"))] and the four iap_* commands + behind #[cfg(feature = "mas")]. Inline #[cfg] attributes inside tauri::generate_handler! + work because the macro expands the cfg gates on each item list entry. +- Verified: cargo clippy --all-targets -- -D warnings (clean, default features), + cargo test (22 passed — 18 existing license/data + 4 new iap), npm run type-check (clean), + npm run lint (clean). +- Also verified: cargo check --features mas exits 0. There are 12 dead-code warnings for + Dodo-only items in license.rs (call_dodo_validate, DodoError, StoredLicense + keychain + helpers, revalidate_license_background, activate_license, deactivate_license, + open_customer_portal, dodo_error_message, dodo_invalid_key_message). These are expected + and intentional for this step — Step 2 (license-layer cleanup) moves all of those behind + #[cfg(not(feature = "mas"))] and rewires compute_status_pure to read StoredEntitlement + on MAS. CI doesn't exercise --features mas, so -D warnings is unaffected. +- PRD.md P2 section: added Step 1 [DONE] sub-status with full description, plus Step 2/3/4 + pending markers describing the remaining scope. + +## MAS PRD P1 — Config split (prerequisite for MAS build) [DONE] +- Split tauri.conf.json so the same source tree can produce both the Direct (GitHub/Homebrew) + build and a future MAS build via overlay configs. No runtime/source code changes — purely + build configuration plumbing. +- src-tauri/tauri.conf.json: stripped macOS-only fields (windows[0].titleBarStyle, + hiddenTitle, windowEffects, and app.macOSPrivateApi). Base config is now platform-neutral. +- src-tauri/tauri.macos.conf.json (NEW): Direct build overlay. Auto-merged by Tauri for + macOS targets. Sets macOSPrivateApi: true and the NSVisualEffectView sidebar vibrancy. +- src-tauri/tauri.mas.conf.json (NEW): MAS build overlay, passed explicitly via + `--config`. Overrides macos overlay with macOSPrivateApi: false and windowEffects: null + (the null is required to undo the macos overlay's value during config merge — without it + the sidebar effect would still be requested). Sets bundle.targets: ["app"], + createUpdaterArtifacts: false, bundle.macOS signing/provisioning/entitlements references, + and plugins.updater.active: false. The signingIdentity and provisioningProfile values are + placeholders ("Apple Distribution: ", "EsploroMAS.provisionprofile") that the + release pipeline must replace with real values. +- src-tauri/Esploro-MAS.entitlements (NEW): App Sandbox + network.client only. No other + entitlements needed since Esploro only opens outbound TCP to Postgres/MySQL. +- src-tauri/Cargo.toml: added [features] section with `mas = []`. This flag is consumed + by source code (planned for P2) via `#[cfg(feature = "mas")]` to gate Dodo/updater code + out of the MAS binary. +- Documented a known tauri-build constraint in Cargo.toml + PRD: tauri-build (the build + script) reads `tauri`'s features array in Cargo.toml LITERALLY and compares it against + the merged config's `macOSPrivateApi` allowlist on every `cargo build`. Cargo feature + aliases like `direct = ["tauri/macos-private-api"]` are NOT resolved during that check + (tauri-apps/tauri#11142, #7940). Tried that approach first; cargo check failed with + "The `tauri` dependency features on the `Cargo.toml` file does not match the allowlist + defined under `tauri.conf.json`. Please … add the `macos-private-api` feature." +- Resolution: keep `tauri = { features = ["macos-private-api"] }` literally listed so the + default Direct build on macOS passes tauri-build, and accept that producing a + MAS-compliant binary (no private API symbols linked in) requires the release pipeline to + patch Cargo.toml at build time: + 1. Strip "macos-private-api" from the tauri dep's features array. + 2. cargo tauri build --target aarch64-apple-darwin + --config src-tauri/tauri.mas.conf.json --features mas + 3. Restore Cargo.toml. + Updated PRD's Submission Flow Step 2 to spell this out. The day-to-day developer + experience (`cargo tauri dev`) is unchanged. +- Verified: cargo check (1.7s), cargo clippy --all-targets -- -D warnings (clean), + cargo test (18 passed), npm run type-check + npm run lint (clean). All three config + JSON files parse. The macos overlay is now being picked up — confirmed by tauri-build + emitting `rerun-if-changed=tauri.macos.conf.json` during the build. + +## MAS PRD P0 — Grace period bug fix [DONE] +- Bug: compute_status_pure in src-tauri/src/commands/license.rs silently suppressed the + Commercial Banner for 14 days after commercial_detected_at was set. The delay served no + purpose — banner should appear as soon as commercial usage is detected, subject only to + the per-session banner_dismissed flag. +- src-tauri/src/commands/license.rs: + - Removed the grace_end (detected + 14d) computation in the commercial-detected branch. + - Removed the `now > grace_end` gate on banner_visible; banner_visible is now simply + `!banner_dismissed` whenever commercial_detected_at is set. + - grace_period_ends field on LicenseStatus is kept but always set to None. + Rationale: AppShell LicenseBadge popover already handles `gracePeriodEnds: null` by + rendering "License active" instead of "Valid through [date]" — no frontend change needed. + - Tests rewritten: + - commercial_detected_shows_banner_immediately (new) — banner visible the instant detection lands + - commercial_detected_banner_hidden_when_dismissed_for_session (new) — dismissed=true suppresses + - commercial_detected_long_ago_still_shows_banner (new) — regression guard for the old 14d bug + - no_stored_license_after_valid_false_shows_banner_when_grace_expired (replaced) — grace concept gone + - All 10 license tests pass. +- PRD.md: marked P0 [DONE] with a summary of what changed. +- All CI checks pass (cargo clippy --workspace --all-targets -- -D warnings, tsc --noEmit, eslint). + ## PRD #4 — Default theme: Tokyo Night [DONE] - Changed defaultUiPreferences.ui.theme from "system" to "tokyo-night" in src/features/settings/preferences.ts:52. - Affects first-run users only; existing users with a stored theme see no change. diff --git a/scripts/build-mas.sh b/scripts/build-mas.sh new file mode 100755 index 0000000..2bbcbae --- /dev/null +++ b/scripts/build-mas.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# build-mas.sh — Build the Mac App Store target. +# +# Strips the literal "macos-private-api" feature from src-tauri/Cargo.toml's +# `tauri` dependency, runs `cargo tauri build` with the MAS overlay config and +# the `mas` Cargo feature, then unconditionally restores Cargo.toml on exit +# (success or failure, including Ctrl+C). The Direct (GitHub Releases / +# Homebrew) build remains the day-to-day default — `cargo tauri dev` keeps +# working unchanged. +# +# Why the patch is needed: `tauri-build` strictly compares the literal `tauri` +# features array in Cargo.toml against the merged config allowlist. Feature +# aliases (`mas = ["tauri/..."]`) are NOT resolved during that check (see +# tauri-apps/tauri#11142, #7940), so physically removing the feature from the +# array is the only way to produce a binary without `macos-private-api` +# linkage. App Store review rejects any binary that links private APIs. +# +# Usage: +# scripts/build-mas.sh # build aarch64-apple-darwin (default) +# TAURI_TARGET=x86_64-apple-darwin \ +# scripts/build-mas.sh # override target +# scripts/build-mas.sh --verbose # extra args forwarded to `tauri build` +# +# Environment: +# TAURI_TARGET Rust target triple. Default: aarch64-apple-darwin. +# PRD scope is ARM-only for the first MAS submission; the +# variable exists for future fat-binary work. +# +# Exit codes: +# 0 success +# 1 precondition failure (Cargo.toml missing, expected line absent, …) +# * propagated from `cargo tauri build` +# +# The patched Cargo.toml is always restored via `trap … EXIT` — including on +# SIGINT — so a Ctrl+C during the build does not leave the working tree in a +# half-patched state. See `restore` below. + +set -euo pipefail + +TARGET="${TAURI_TARGET:-aarch64-apple-darwin}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +CARGO_TOML="$ROOT_DIR/src-tauri/Cargo.toml" +MAS_CONFIG="$ROOT_DIR/src-tauri/tauri.mas.conf.json" + +# The exact line we expect to patch. Strict equality (anchored ^ … $) so this +# script fails loudly the moment the `tauri` dependency line changes shape — +# better to force a conscious script update than to silently produce a +# half-patched Cargo.toml that builds the wrong binary. +EXPECTED_LINE='tauri = { version = "2", features = ["macos-private-api"] }' +PATCHED_LINE='tauri = { version = "2", features = [] }' + +if [[ ! -f "$CARGO_TOML" ]]; then + echo "error: $CARGO_TOML not found" >&2 + exit 1 +fi + +if [[ ! -f "$MAS_CONFIG" ]]; then + echo "error: $MAS_CONFIG not found" >&2 + exit 1 +fi + +if ! grep -Fxq "$EXPECTED_LINE" "$CARGO_TOML"; then + echo "error: expected line not found in $CARGO_TOML" >&2 + echo " expected: $EXPECTED_LINE" >&2 + echo " The MAS build script needs to match this exact line so it can" >&2 + echo " strip 'macos-private-api' from the tauri features array." >&2 + echo " If the tauri dependency declaration has changed, update both" >&2 + echo " EXPECTED_LINE and PATCHED_LINE in this script accordingly." >&2 + exit 1 +fi + +BACKUP="$(mktemp -t esploro-cargo-toml.XXXXXX)" +cp "$CARGO_TOML" "$BACKUP" + +restore() { + if [[ -f "$BACKUP" ]]; then + cp "$BACKUP" "$CARGO_TOML" + rm -f "$BACKUP" + fi +} +trap restore EXIT + +# In-place patch via a temp file (portable across BSD/GNU sed without -i''). +# We pin the replacement to the full literal line — any drift in formatting +# (extra spaces, reordered features) would have already tripped the grep +# preflight check above. +awk -v expected="$EXPECTED_LINE" -v patched="$PATCHED_LINE" ' + $0 == expected { print patched; next } + { print } +' "$CARGO_TOML" > "$CARGO_TOML.new" +mv "$CARGO_TOML.new" "$CARGO_TOML" + +echo "==> Patched Cargo.toml (stripped 'macos-private-api' from tauri features)" +echo "==> Building MAS binary" +echo " target: $TARGET" +echo " config: $MAS_CONFIG" +echo " feature: mas" + +# We invoke `npm run tauri -- build` rather than `cargo tauri build` so this +# script uses the same Tauri CLI version pinned in package.json — matching the +# Direct build path and avoiding "which cargo-tauri is on PATH?" drift in CI. +( + cd "$ROOT_DIR" + npm run tauri -- build \ + --target "$TARGET" \ + --config "$MAS_CONFIG" \ + --features mas \ + "$@" +) + +echo "==> MAS build complete" +echo "==> Bundle: src-tauri/target/$TARGET/release/bundle/" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebb1769..01fbe2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,28 @@ license = "MIT" name = "esploro_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +# Mac App Store build flag. Used in source code via `#[cfg(feature = "mas")]` +# to gate out Dodo / updater code paths that App Store review rejects. +# +# NOTE: This feature alone is NOT sufficient to produce a MAS-compliant binary. +# `tauri-build` strictly compares the literal `tauri` dependency features in +# `Cargo.toml` against the merged `tauri.conf.json` allowlist — feature +# aliases like `direct = ["tauri/macos-private-api"]` are not resolved during +# that check (see tauri-apps/tauri#11142, #7940). So the +# `tauri/macos-private-api` feature must stay literally listed in the +# `[dependencies]` section below to satisfy the build script on macOS for the +# default Direct build. +# +# To actually produce a MAS binary (no `macos-private-api` linked in), the +# release pipeline must patch `Cargo.toml` at build time: +# 1. Remove `"macos-private-api"` from `tauri`'s `features` array. +# 2. Run `cargo tauri build --config tauri.mas.conf.json --features mas`. +# 3. Restore `Cargo.toml`. +# This is intentionally a CI-side concern, not a developer ergonomic, so the +# day-to-day `cargo tauri dev` keeps working unchanged. +mas = ["dep:objc2", "dep:objc2-foundation", "dep:objc2-store-kit"] + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -26,3 +48,12 @@ keyring = "2" chrono = { version = "0.4", features = ["serde"] } tauri-plugin-updater = "2" tauri-plugin-process = "2" + +# StoreKit 1 bindings for the MAS build. Pulled in only when --features mas is +# active (see [features].mas above). The crates are macOS-only by their own +# target cfg, so leaving them as default-disabled keeps Linux/Windows CI +# unaffected even though we don't target those for the desktop app. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", optional = true } +objc2-foundation = { version = "0.3", optional = true } +objc2-store-kit = { version = "0.3", optional = true } diff --git a/src-tauri/Esploro-MAS.entitlements b/src-tauri/Esploro-MAS.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/src-tauri/Esploro-MAS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/src-tauri/src/commands/iap.rs b/src-tauri/src/commands/iap.rs new file mode 100644 index 0000000..4de26c3 --- /dev/null +++ b/src-tauri/src/commands/iap.rs @@ -0,0 +1,227 @@ +//! In-App Purchase commands for the Mac App Store build. +//! +//! This module is only compiled when the `mas` cargo feature is enabled. +//! It exposes a small Tauri command surface mirroring the IAP product matrix +//! described in `PRD.md` (P2 — StoreKit IAP Integration). The four commands +//! are the migration boundary between the Rust backend and the frontend; the +//! actual StoreKit 1 (`objc2-store-kit`) integration lands in a follow-up +//! task. For now each command returns `Err("not yet implemented")` so the +//! shape of the API can be wired into `lib.rs` and the frontend without +//! coupling to the StoreKit work. +//! +//! Companion design docs: +//! - `plans/08-mas-adr.md` — Mac App Store distribution decision +//! - `plans/09-storekit-objc2.md` — `objc2-store-kit` over a Swift plugin +//! - `PRD.md` § P2 — command surface and license-layer changes + +use serde::{Deserialize, Serialize}; + +use super::iap_storekit; + +const KEYCHAIN_SERVICE: &str = "app.esploro"; +const KEYCHAIN_ACCOUNT_ENTITLEMENT: &str = "mas-entitlement"; + +/// IAP product identifiers registered in App Store Connect. The MAS build +/// queries these three on every `iap_get_products` call; the App Store will +/// reject any IDs not configured on the listing so the client side just +/// hard-codes the canonical list. +pub const PRODUCT_IDS: &[&str] = &[ + "app.esploro.personal.lifetime", + "app.esploro.personal.annual", + "app.esploro.business.annual", +]; + +// --------------------------------------------------------------------------- +// Wire types — kept in sync with the PRD's `iap_*` command return shapes. +// --------------------------------------------------------------------------- + +/// One purchasable product fetched from the App Store at runtime. +/// +/// `price` is the localised, currency-formatted string (e.g. `"$129.00"`). +/// StoreKit hands us this directly via `SKProduct.priceLocale`, so we keep it +/// pre-formatted on the Rust side rather than shipping numeric prices the +/// frontend would have to format with `Intl`. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IapProduct { + pub id: String, + pub title: String, + pub description: String, + pub price: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum IapPurchaseStatus { + Purchased, + Cancelled, + Failed, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IapPurchaseResult { + pub status: IapPurchaseStatus, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IapRestoreResult { + pub restored: bool, +} + +/// Result of `iap_check_entitlement`. `expires_at` is `None` for the +/// non-consumable lifetime product and `Some(rfc3339)` for subscriptions. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct IapEntitlement { + pub entitled: bool, + pub product_id: Option, + pub expires_at: Option, +} + +// --------------------------------------------------------------------------- +// Cached entitlement — replaces `StoredLicense` on the MAS build. +// --------------------------------------------------------------------------- + +/// Last-known IAP entitlement, cached in the macOS Keychain so the app can +/// render the correct license tier offline / on launch before re-querying +/// `SKPaymentQueue` for fresh transactions. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct StoredEntitlement { + pub product_id: String, + pub expires_at: Option, +} + +pub(crate) fn read_stored_entitlement() -> Option { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_ENTITLEMENT).ok()?; + match entry.get_password() { + Ok(json) => serde_json::from_str(&json).ok(), + Err(keyring::Error::NoEntry) => None, + Err(e) => { + eprintln!("Failed to read MAS entitlement from keychain: {e}"); + None + } + } +} + +pub(crate) fn write_stored_entitlement(stored: &StoredEntitlement) -> Result<(), String> { + let json = serde_json::to_string(stored).map_err(|e| e.to_string())?; + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_ENTITLEMENT) + .map_err(|e| e.to_string())?; + entry.set_password(&json).map_err(|e| e.to_string()) +} + +#[allow(dead_code)] +pub(crate) fn clear_stored_entitlement() { + if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_ENTITLEMENT) { + entry.delete_password().ok(); + } +} + +// --------------------------------------------------------------------------- +// Tauri commands — delegate to `iap_storekit` for the live StoreKit work and +// translate cached entitlement state for the entitlement check. +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn iap_get_products() -> Result, String> { + iap_storekit::fetch_products(PRODUCT_IDS.iter().map(|s| s.to_string()).collect()).await +} + +#[tauri::command] +pub async fn iap_purchase(product_id: String) -> Result { + iap_storekit::purchase(product_id).await +} + +#[tauri::command] +pub async fn iap_restore() -> Result { + iap_storekit::restore().await +} + +/// Reads the cached entitlement from the Keychain and translates it into the +/// wire shape the frontend consumes. Subscription expiry is checked against +/// `Utc::now()` so a lapsed subscription is reported as `entitled: false` +/// without having to walk the StoreKit transaction queue. Fresh transactions +/// (renewals, restores) flow through the `SKPaymentTransactionObserver` in +/// `iap_storekit` and update this cache on the fly. +#[tauri::command] +pub async fn iap_check_entitlement() -> Result { + let Some(stored) = read_stored_entitlement() else { + return Ok(IapEntitlement::default()); + }; + let now = chrono::Utc::now(); + let entitled = match stored.expires_at.as_deref() { + None => true, // Non-consumable (lifetime) — always entitled. + Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) { + Ok(dt) => dt.with_timezone(&chrono::Utc) > now, + Err(_) => false, + }, + }; + Ok(IapEntitlement { + entitled, + product_id: Some(stored.product_id), + expires_at: stored.expires_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iap_purchase_status_serialises_as_lowercase_strings() { + let purchased = serde_json::to_string(&IapPurchaseResult { + status: IapPurchaseStatus::Purchased, + }) + .unwrap(); + assert_eq!(purchased, r#"{"status":"purchased"}"#); + + let cancelled = serde_json::to_string(&IapPurchaseResult { + status: IapPurchaseStatus::Cancelled, + }) + .unwrap(); + assert_eq!(cancelled, r#"{"status":"cancelled"}"#); + + let failed = serde_json::to_string(&IapPurchaseResult { + status: IapPurchaseStatus::Failed, + }) + .unwrap(); + assert_eq!(failed, r#"{"status":"failed"}"#); + } + + #[test] + fn iap_entitlement_omits_camel_case_fields_when_none() { + let unentitled = IapEntitlement::default(); + let json = serde_json::to_string(&unentitled).unwrap(); + // `expiresAt` (camelCase) must appear when serialised — None becomes null. + assert_eq!(json, r#"{"entitled":false,"productId":null,"expiresAt":null}"#); + } + + #[test] + fn iap_product_uses_camel_case_keys() { + let product = IapProduct { + id: "app.esploro.personal.lifetime".to_string(), + title: "Personal — Lifetime".to_string(), + description: "One-time individual commercial license".to_string(), + price: "$129.00".to_string(), + }; + let json = serde_json::to_value(&product).unwrap(); + let obj = json.as_object().expect("object"); + assert!(obj.contains_key("id")); + assert!(obj.contains_key("title")); + assert!(obj.contains_key("description")); + assert!(obj.contains_key("price")); + } + + #[test] + fn stored_entitlement_roundtrips_through_json() { + let stored = StoredEntitlement { + product_id: "app.esploro.business.annual".to_string(), + expires_at: Some("2027-05-18T00:00:00+00:00".to_string()), + }; + let json = serde_json::to_string(&stored).unwrap(); + let parsed: StoredEntitlement = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, stored); + } +} diff --git a/src-tauri/src/commands/iap_storekit.rs b/src-tauri/src/commands/iap_storekit.rs new file mode 100644 index 0000000..89d321b --- /dev/null +++ b/src-tauri/src/commands/iap_storekit.rs @@ -0,0 +1,558 @@ +//! StoreKit 1 (SKProductsRequest / SKPaymentQueue) wiring for the MAS build. +//! +//! This module is compiled only when `--features mas` is active and on +//! `target_os = "macos"` (see Cargo.toml). +//! +//! It owns a single Obj-C delegate instance (`EsploroStoreKitDelegate`) that +//! conforms to: +//! - `SKProductsRequestDelegate` — fetches localised product metadata +//! - `SKRequestDelegate` (supertrait) — handles request-level errors +//! - `SKPaymentTransactionObserver` — receives purchase/restore updates +//! +//! The delegate is registered with `SKPaymentQueue.defaultQueue` exactly once +//! per process via a `OnceLock`. From that point on, every transaction the +//! payment queue delivers — including pending transactions re-queued from a +//! previous app launch, and renewal transactions for active subscriptions — +//! flows through `payment_queue_updated_transactions`, which updates the +//! cached `StoredEntitlement` in the Keychain and resolves any in-flight +//! purchase/restore request waiting on a Tokio oneshot channel. +//! +//! Tauri commands (declared in `iap.rs`) run on the Tokio runtime and call +//! into this module's async functions. The async functions install a oneshot +//! sender in the shared `DelegateState`, kick off the StoreKit operation, and +//! await the response that the Obj-C callbacks push back through that sender. +//! StoreKit 1 delivers all its callbacks on the main thread; the channels +//! cross that thread boundary safely. +//! +//! Every StoreKit 1 symbol used here is `#[deprecated]` upstream (Apple +//! marked the whole API in favour of StoreKit 2's Swift `Product` API). +//! The `#![allow(deprecated)]` at the top of the module silences those +//! warnings — the migration to StoreKit 2 / a Swift plugin is captured as +//! a future ADR (see `plans/09-storekit-objc2.md`). +#![allow(deprecated)] + +use std::sync::{Mutex, OnceLock}; + +use chrono::{DateTime, Months, Utc}; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{define_class, msg_send, AnyThread, DefinedClass, MainThreadMarker}; +use objc2_foundation::{ + NSArray, NSDate, NSDecimalNumber, NSError, NSLocale, NSNumberFormatter, NSNumberFormatterStyle, + NSObject, NSObjectProtocol, NSSet, NSString, +}; +use objc2_store_kit::{ + SKErrorCode, SKPayment, SKPaymentQueue, SKPaymentTransaction, SKPaymentTransactionObserver, + SKPaymentTransactionState, SKProduct, SKProductPeriodUnit, SKProductsRequest, + SKProductsRequestDelegate, SKProductsResponse, SKRequest, SKRequestDelegate, +}; +use tokio::sync::oneshot; + +use super::iap::{ + write_stored_entitlement, IapProduct, IapPurchaseResult, IapPurchaseStatus, IapRestoreResult, + StoredEntitlement, +}; + +// --------------------------------------------------------------------------- +// Pending-operation state owned by the delegate's ivars. +// --------------------------------------------------------------------------- + +struct PendingPurchase { + product_id: String, + sender: oneshot::Sender>, +} + +struct PendingRestore { + sender: oneshot::Sender>, + restored_any: bool, +} + +type ProductsSender = oneshot::Sender, String>>; + +pub struct DelegateState { + /// Active products request — one at a time. `start()` is non-blocking and + /// fires `productsRequest:didReceiveResponse:` (or the SKRequestDelegate + /// failure callback) which resolves this oneshot. + pending_products: Mutex>, + /// Active purchase — at most one. Resolved when a transaction with the + /// matching `productIdentifier` reaches `Purchased`/`Failed`. Other + /// (background) transactions for the same product are still observed and + /// finished, they just don't satisfy this future. + pending_purchase: Mutex>, + /// Active restore — at most one. Resolved by either + /// `paymentQueueRestoreCompletedTransactionsFinished:` (success, possibly + /// with zero restored transactions) or the corresponding failure + /// callback. + pending_restore: Mutex>, + /// `SKProduct` objects cached from the most recent products request. We + /// hold these to (a) initiate purchases (need the `SKProduct`, not just + /// the identifier, for `SKPayment.paymentWithProduct:`) and (b) read + /// `subscriptionPeriod` when computing expiry timestamps for cached + /// entitlements. + cached_products: Mutex>>, + /// The in-flight `SKProductsRequest`, retained so it isn't released + /// before its delegate callback fires. + active_request: Mutex>>, +} + +// --------------------------------------------------------------------------- +// Custom delegate class — implements three Obj-C protocols. +// --------------------------------------------------------------------------- + +define_class!( + /// Single instance, owned by `Manager`, registered as the + /// `SKPaymentQueue.defaultQueue` transaction observer for the life of the + /// process. + /// + /// SAFETY: + /// - Superclass is plain `NSObject` (no subclassing requirements). + /// - We do not implement `Drop`. The Obj-C runtime is allowed to call + /// `dealloc` from any thread; our ivars are `Send + Sync` (mutex-wrapped + /// tokio oneshots and `Retained` / `Retained`, + /// the latter two of which the objc2-store-kit bindings explicitly mark + /// `Send + Sync`). + #[unsafe(super(NSObject))] + #[name = "EsploroStoreKitDelegate"] + #[ivars = DelegateState] + pub struct EsploroStoreKitDelegate; + + unsafe impl NSObjectProtocol for EsploroStoreKitDelegate {} + + unsafe impl SKRequestDelegate for EsploroStoreKitDelegate { + #[unsafe(method(request:didFailWithError:))] + fn request_did_fail_with_error(&self, _request: &SKRequest, error: &NSError) { + let msg = format_ns_error(error); + if let Some(tx) = self.ivars().pending_products.lock().unwrap().take() { + let _ = tx.send(Err(msg)); + } + *self.ivars().active_request.lock().unwrap() = None; + } + } + + unsafe impl SKProductsRequestDelegate for EsploroStoreKitDelegate { + #[unsafe(method(productsRequest:didReceiveResponse:))] + fn products_request_did_receive_response( + &self, + _request: &SKProductsRequest, + response: &SKProductsResponse, + ) { + let products = unsafe { response.products() }; + let mut wire = Vec::with_capacity(products.len()); + let mut retained: Vec> = Vec::with_capacity(products.len()); + for product in products.iter() { + if let Some(iap) = product_to_wire(&product) { + wire.push(iap); + } + retained.push(product.clone()); + } + *self.ivars().cached_products.lock().unwrap() = retained; + if let Some(tx) = self.ivars().pending_products.lock().unwrap().take() { + let _ = tx.send(Ok(wire)); + } + *self.ivars().active_request.lock().unwrap() = None; + } + } + + unsafe impl SKPaymentTransactionObserver for EsploroStoreKitDelegate { + #[unsafe(method(paymentQueue:updatedTransactions:))] + fn payment_queue_updated_transactions( + &self, + queue: &SKPaymentQueue, + transactions: &NSArray, + ) { + self.handle_updated_transactions(queue, transactions); + } + + #[unsafe(method(paymentQueueRestoreCompletedTransactionsFinished:))] + fn payment_queue_restore_completed_transactions_finished(&self, _queue: &SKPaymentQueue) { + if let Some(state) = self.ivars().pending_restore.lock().unwrap().take() { + let _ = state.sender.send(Ok(state.restored_any)); + } + } + + #[unsafe(method(paymentQueue:restoreCompletedTransactionsFailedWithError:))] + fn payment_queue_restore_completed_transactions_failed_with_error( + &self, + _queue: &SKPaymentQueue, + error: &NSError, + ) { + let msg = format_ns_error(error); + if let Some(state) = self.ivars().pending_restore.lock().unwrap().take() { + let _ = state.sender.send(Err(msg)); + } + } + } +); + +impl EsploroStoreKitDelegate { + fn new() -> Retained { + let state = DelegateState { + pending_products: Mutex::new(None), + pending_purchase: Mutex::new(None), + pending_restore: Mutex::new(None), + cached_products: Mutex::new(Vec::new()), + active_request: Mutex::new(None), + }; + let this = Self::alloc().set_ivars(state); + unsafe { msg_send![super(this), init] } + } + + fn handle_updated_transactions( + &self, + queue: &SKPaymentQueue, + transactions: &NSArray, + ) { + for txn in transactions.iter() { + let state = unsafe { txn.transactionState() }; + match state { + SKPaymentTransactionState::Purchasing | SKPaymentTransactionState::Deferred => { + continue + } + SKPaymentTransactionState::Purchased + | SKPaymentTransactionState::Restored => { + let payment = unsafe { txn.payment() }; + let product_id_ns = unsafe { payment.productIdentifier() }; + let product_id = product_id_ns.to_string(); + let txn_date = unsafe { txn.transactionDate() }; + let entitlement = self + .entitlement_from_transaction(&product_id, txn_date.as_deref()); + let _ = write_stored_entitlement(&entitlement); + + if state == SKPaymentTransactionState::Purchased { + self.resolve_purchase( + &product_id, + Ok(IapPurchaseResult { + status: IapPurchaseStatus::Purchased, + }), + ); + } else { + let mut pending = self.ivars().pending_restore.lock().unwrap(); + if let Some(s) = pending.as_mut() { + s.restored_any = true; + } + } + unsafe { queue.finishTransaction(&txn); } + } + SKPaymentTransactionState::Failed => { + let payment = unsafe { txn.payment() }; + let product_id_ns = unsafe { payment.productIdentifier() }; + let product_id = product_id_ns.to_string(); + let cancelled = unsafe { txn.error() } + .map(|e| e.code() == SKErrorCode::PaymentCancelled.0) + .unwrap_or(false); + let status = if cancelled { + IapPurchaseStatus::Cancelled + } else { + IapPurchaseStatus::Failed + }; + self.resolve_purchase(&product_id, Ok(IapPurchaseResult { status })); + unsafe { queue.finishTransaction(&txn); } + } + _ => continue, + } + } + } + + fn resolve_purchase( + &self, + product_id: &str, + result: Result, + ) { + let mut pending = self.ivars().pending_purchase.lock().unwrap(); + let matches = pending + .as_ref() + .map(|p| p.product_id == product_id) + .unwrap_or(false); + if matches { + if let Some(p) = pending.take() { + let _ = p.sender.send(result); + } + } + } + + fn entitlement_from_transaction( + &self, + product_id: &str, + txn_date: Option<&NSDate>, + ) -> StoredEntitlement { + let cached = self.ivars().cached_products.lock().unwrap(); + StoredEntitlement { + product_id: product_id.to_string(), + expires_at: compute_expires_at(product_id, txn_date, &cached), + } + } +} + +// --------------------------------------------------------------------------- +// Singleton manager + lazy-install of the transaction observer. +// --------------------------------------------------------------------------- + +pub struct Manager { + delegate: Retained, +} + +// SAFETY: `EsploroStoreKitDelegate`'s ivars are all `Send + Sync` (mutexes +// containing oneshot senders + Retained/Retained, +// both of which the bindings mark `Send + Sync`). The `Retained<>` wrapper is +// `Send + Sync` iff its target is. Holding the manager in a static `OnceLock` +// across threads is therefore sound. +unsafe impl Send for Manager {} +unsafe impl Sync for Manager {} + +static MANAGER: OnceLock = OnceLock::new(); + +fn manager() -> &'static Manager { + MANAGER.get_or_init(|| { + let delegate = EsploroStoreKitDelegate::new(); + unsafe { + // `SKPaymentQueue.addTransactionObserver:` strongly retains the + // observer for the queue's lifetime; we additionally hold one + // reference on the manager to keep our Rust-side handle valid. + let queue = SKPaymentQueue::defaultQueue(); + queue.addTransactionObserver(ProtocolObject::from_ref(&*delegate)); + } + Manager { delegate } + }) +} + +// --------------------------------------------------------------------------- +// Public API consumed by `iap.rs`. +// --------------------------------------------------------------------------- + +pub async fn fetch_products(ids: Vec) -> Result, String> { + let mgr = manager(); + let (tx, rx) = oneshot::channel(); + { + let mut pending = mgr.delegate.ivars().pending_products.lock().unwrap(); + if pending.is_some() { + return Err("Another products fetch is already in progress".to_string()); + } + *pending = Some(tx); + } + + unsafe { + let ns_ids: Vec> = ids.iter().map(|s| NSString::from_str(s)).collect(); + let ns_set: Retained> = NSSet::from_retained_slice(&ns_ids); + let request = SKProductsRequest::initWithProductIdentifiers( + SKProductsRequest::alloc(), + &ns_set, + ); + request.setDelegate(Some(ProtocolObject::from_ref(&*mgr.delegate))); + *mgr.delegate.ivars().active_request.lock().unwrap() = Some(request.clone()); + request.start(); + } + + match rx.await { + Ok(result) => result, + Err(_) => Err("Products request was cancelled".to_string()), + } +} + +pub async fn purchase(product_id: String) -> Result { + let mgr = manager(); + + if !unsafe { SKPaymentQueue::canMakePayments() } { + return Err("In-app purchases are not allowed on this device".to_string()); + } + + if !product_cached(mgr, &product_id) { + let _ = fetch_products(vec![product_id.clone()]).await?; + } + let sk_product = lookup_cached(mgr, &product_id) + .ok_or_else(|| format!("Product {product_id} not available from the App Store"))?; + + let (tx, rx) = oneshot::channel(); + { + let mut pending = mgr.delegate.ivars().pending_purchase.lock().unwrap(); + if pending.is_some() { + return Err("Another purchase is already in progress".to_string()); + } + *pending = Some(PendingPurchase { + product_id: product_id.clone(), + sender: tx, + }); + } + + unsafe { + let payment = SKPayment::paymentWithProduct(&sk_product); + SKPaymentQueue::defaultQueue().addPayment(&payment); + } + + match rx.await { + Ok(result) => result, + Err(_) => Err("Purchase was cancelled before completion".to_string()), + } +} + +pub async fn restore() -> Result { + let mgr = manager(); + let (tx, rx) = oneshot::channel(); + { + let mut pending = mgr.delegate.ivars().pending_restore.lock().unwrap(); + if pending.is_some() { + return Err("A restore is already in progress".to_string()); + } + *pending = Some(PendingRestore { + sender: tx, + restored_any: false, + }); + } + + unsafe { + SKPaymentQueue::defaultQueue().restoreCompletedTransactions(); + } + + let restored = match rx.await { + Ok(Ok(restored)) => restored, + Ok(Err(e)) => return Err(e), + Err(_) => return Err("Restore was cancelled".to_string()), + }; + + Ok(IapRestoreResult { restored }) +} + +/// Eagerly install the transaction observer (called from `lib.rs` at startup +/// so we don't miss transactions delivered while the app is launching). +/// The `MainThreadMarker` parameter is unused at runtime but documents the +/// expectation that the caller is on the AppKit main thread. +pub fn install_observer_on_startup(_mtm: MainThreadMarker) { + let _ = manager(); +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +fn product_cached(mgr: &Manager, product_id: &str) -> bool { + mgr.delegate + .ivars() + .cached_products + .lock() + .unwrap() + .iter() + .any(|p| { + let id = unsafe { p.productIdentifier() }; + id.to_string() == product_id + }) +} + +fn lookup_cached(mgr: &Manager, product_id: &str) -> Option> { + mgr.delegate + .ivars() + .cached_products + .lock() + .unwrap() + .iter() + .find(|p| { + let id = unsafe { p.productIdentifier() }; + id.to_string() == product_id + }) + .cloned() +} + +fn format_ns_error(error: &NSError) -> String { + let desc = error.localizedDescription(); + desc.to_string() +} + +fn product_to_wire(product: &SKProduct) -> Option { + unsafe { + let id = product.productIdentifier().to_string(); + let title = product.localizedTitle().to_string(); + let description = product.localizedDescription().to_string(); + let price = format_price(&product.price(), &product.priceLocale())?; + Some(IapProduct { + id, + title, + description, + price, + }) + } +} + +fn format_price(amount: &NSDecimalNumber, locale: &NSLocale) -> Option { + let formatter = NSNumberFormatter::new(); + formatter.setNumberStyle(NSNumberFormatterStyle::CurrencyStyle); + formatter.setLocale(Some(locale)); + // `NSDecimalNumber` is an `NSNumber` subclass; pass through the AsRef impl. + let as_number: &objc2_foundation::NSNumber = amount; + formatter.stringFromNumber(as_number).map(|s| s.to_string()) +} + +fn compute_expires_at( + product_id: &str, + txn_date: Option<&NSDate>, + cached_products: &[Retained], +) -> Option { + let product = cached_products.iter().find(|p| { + let id = unsafe { p.productIdentifier() }; + id.to_string() == product_id + })?; + let period = unsafe { product.subscriptionPeriod() }?; + let txn_date = txn_date?; + let secs = txn_date.timeIntervalSince1970() as i64; + let start = DateTime::::from_timestamp(secs, 0)?; + let units = unsafe { period.numberOfUnits() } as u32; + let unit = unsafe { period.unit() }; + add_period(start, units, unit).map(|d| d.to_rfc3339()) +} + +fn add_period(start: DateTime, units: u32, unit: SKProductPeriodUnit) -> Option> { + match unit { + SKProductPeriodUnit::Day => { + start.checked_add_signed(chrono::Duration::days(units as i64)) + } + SKProductPeriodUnit::Week => { + start.checked_add_signed(chrono::Duration::weeks(units as i64)) + } + SKProductPeriodUnit::Month => start.checked_add_months(Months::new(units)), + SKProductPeriodUnit::Year => { + start.checked_add_months(Months::new(units.saturating_mul(12))) + } + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Tests — limited to logic we can exercise without a live StoreKit session. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn add_period_year_adds_twelve_months() { + let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(); + let end = add_period(start, 1, SKProductPeriodUnit::Year).unwrap(); + assert_eq!(end, Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()); + } + + #[test] + fn add_period_month_adds_calendar_month() { + let start = Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(); + let end = add_period(start, 3, SKProductPeriodUnit::Month).unwrap(); + assert_eq!(end, Utc.with_ymd_and_hms(2026, 8, 18, 12, 0, 0).unwrap()); + } + + #[test] + fn add_period_week_adds_seven_days_per_unit() { + let start = Utc.with_ymd_and_hms(2026, 5, 18, 0, 0, 0).unwrap(); + let end = add_period(start, 2, SKProductPeriodUnit::Week).unwrap(); + assert_eq!(end, Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap()); + } + + #[test] + fn add_period_day_adds_n_days() { + let start = Utc.with_ymd_and_hms(2026, 5, 18, 0, 0, 0).unwrap(); + let end = add_period(start, 5, SKProductPeriodUnit::Day).unwrap(); + assert_eq!(end, Utc.with_ymd_and_hms(2026, 5, 23, 0, 0, 0).unwrap()); + } + + #[test] + fn add_period_unknown_unit_returns_none() { + let start = Utc.with_ymd_and_hms(2026, 5, 18, 0, 0, 0).unwrap(); + assert!(add_period(start, 1, SKProductPeriodUnit(999)).is_none()); + } +} diff --git a/src-tauri/src/commands/license.rs b/src-tauri/src/commands/license.rs index b958875..3aa4145 100644 --- a/src-tauri/src/commands/license.rs +++ b/src-tauri/src/commands/license.rs @@ -7,15 +7,19 @@ use tauri::{AppHandle, Manager, State}; use crate::AppState; +#[cfg(not(feature = "mas"))] const CUSTOMER_PORTAL_URL: &str = "https://app.dodopayments.com/customer-portal"; +#[cfg(not(feature = "mas"))] const DODO_BASE: &str = if cfg!(debug_assertions) { "https://test.dodopayments.com" } else { "https://live.dodopayments.com" }; +#[cfg(not(feature = "mas"))] const KEYCHAIN_SERVICE: &str = "app.esploro"; +#[cfg(not(feature = "mas"))] const KEYCHAIN_ACCOUNT: &str = "commercial-license"; const DEFAULT_UI_THEME: &str = "tairiki-light"; @@ -61,12 +65,30 @@ pub struct LicenseStatus { pub revalidation_required: bool, } +#[cfg(not(feature = "mas"))] #[derive(Serialize, Deserialize, Clone, Debug)] struct StoredLicense { license_key: String, validated_at: String, } +/// Cached commercial-license signal, sourced per build flavour. +/// +/// - **Direct** build: populated from the Dodo Payments validation cache +/// (`StoredLicense`). `Active` means the key was validated within the +/// 14-day offline grace window; `Stale` means it has been longer and the +/// user must reconnect to revalidate. +/// - **MAS** build: populated from the StoreKit entitlement cache +/// (`StoredEntitlement`). Only `Active` is reachable — App Store +/// subscriptions are the source of truth, so there is no offline-grace +/// concept and the `Stale` variant is gated out. +#[derive(Debug, Clone, PartialEq, Eq)] +enum CachedLicense { + Active, + #[cfg(not(feature = "mas"))] + Stale, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct UiPreferences { @@ -137,9 +159,10 @@ pub struct UserPrefs { } // --------------------------------------------------------------------------- -// Keychain helpers +// Keychain helpers (Direct build only — Dodo Payments cache) // --------------------------------------------------------------------------- +#[cfg(not(feature = "mas"))] fn read_stored_license() -> Option { let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).ok()?; match entry.get_password() { @@ -152,6 +175,7 @@ fn read_stored_license() -> Option { } } +#[cfg(not(feature = "mas"))] fn write_stored_license(stored: &StoredLicense) -> Result<(), String> { let json = serde_json::to_string(stored).map_err(|e| e.to_string())?; let entry = @@ -159,6 +183,7 @@ fn write_stored_license(stored: &StoredLicense) -> Result<(), String> { entry.set_password(&json).map_err(|e| e.to_string()) } +#[cfg(not(feature = "mas"))] fn clear_stored_license() { if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) { entry.delete_password().ok(); @@ -411,9 +436,10 @@ fn save_prefs(app: &AppHandle, prefs: &UserPrefs) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Dodo Payments validation +// Dodo Payments validation (Direct build only) // --------------------------------------------------------------------------- +#[cfg(not(feature = "mas"))] enum DodoError { InvalidFormat, NetworkOrServer, @@ -421,6 +447,7 @@ enum DodoError { /// Calls `POST /licenses/validate` via the system `curl` binary. /// Returns `Ok(true/false)` based on the `valid` field in Dodo's response. +#[cfg(not(feature = "mas"))] async fn call_dodo_validate(license_key: &str) -> Result { let url = format!("{DODO_BASE}/licenses/validate"); let body = format!("{{\"license_key\":\"{}\"}}", license_key.replace('"', "\\\"")); @@ -472,21 +499,76 @@ async fn call_dodo_validate(license_key: &str) -> Result { Ok(parsed.get("valid").and_then(Value::as_bool).unwrap_or(false)) } +// --------------------------------------------------------------------------- +// Cached-license resolution (per build) +// --------------------------------------------------------------------------- + +/// Direct build: map a Dodo `StoredLicense` to the cross-build +/// `CachedLicense` abstraction. Returns `Active` while inside the 14-day +/// offline grace window, `Stale` afterwards (or if the timestamp is +/// unparseable — fail closed so the user revalidates). +#[cfg(not(feature = "mas"))] +fn direct_cached_license(stored: &StoredLicense, now: chrono::DateTime) -> CachedLicense { + match chrono::DateTime::parse_from_rfc3339(&stored.validated_at) { + Ok(validated_at) => { + let age = now - validated_at.with_timezone(&Utc); + if age < chrono::Duration::days(14) { + CachedLicense::Active + } else { + CachedLicense::Stale + } + } + Err(_) => CachedLicense::Stale, + } +} + +/// MAS build: map a StoreKit `StoredEntitlement` to the cross-build +/// `CachedLicense` abstraction. Returns `Some(Active)` for the +/// non-consumable lifetime product (no `expires_at`) and for subscriptions +/// whose `expires_at` is still in the future. Returns `None` for expired or +/// unparseable subscriptions — App Store rules treat lapsed subscriptions as +/// unentitled, and StoreKit (not our cache) is the source of truth. +#[cfg(feature = "mas")] +fn mas_cached_license( + stored: &crate::commands::iap::StoredEntitlement, + now: chrono::DateTime, +) -> Option { + match stored.expires_at.as_deref() { + None => Some(CachedLicense::Active), + Some(expiry) => match chrono::DateTime::parse_from_rfc3339(expiry) { + Ok(exp) if exp.with_timezone(&Utc) > now => Some(CachedLicense::Active), + _ => None, + }, + } +} + +/// Read the cached license signal from the appropriate keychain entry for +/// the current build flavour. +#[cfg(not(feature = "mas"))] +fn current_cached_license(now: chrono::DateTime) -> Option { + read_stored_license().map(|stored| direct_cached_license(&stored, now)) +} + +#[cfg(feature = "mas")] +fn current_cached_license(now: chrono::DateTime) -> Option { + crate::commands::iap::read_stored_entitlement() + .and_then(|stored| mas_cached_license(&stored, now)) +} + // --------------------------------------------------------------------------- // Status computation // --------------------------------------------------------------------------- /// Pure status computation — no I/O; accepts all inputs as parameters. fn compute_status_pure( - stored: Option<&StoredLicense>, + cached: Option, prefs: &UserPrefs, banner_dismissed: bool, now: chrono::DateTime, ) -> LicenseStatus { - if let Some(stored) = stored { - if let Ok(validated_at) = chrono::DateTime::parse_from_rfc3339(&stored.validated_at) { - let age = now - validated_at.with_timezone(&Utc); - if age < chrono::Duration::days(14) { + if let Some(cached) = cached { + match cached { + CachedLicense::Active => { return LicenseStatus { tier: LicenseTier::Commercial, banner_visible: false, @@ -495,31 +577,28 @@ fn compute_status_pure( revalidation_required: false, }; } - // Key exists but offline too long — revert to Unlicensed - return LicenseStatus { - tier: LicenseTier::Unlicensed, - banner_visible: false, - grace_period_ends: None, - show_usage_dialog: false, - revalidation_required: true, - }; + #[cfg(not(feature = "mas"))] + CachedLicense::Stale => { + return LicenseStatus { + tier: LicenseTier::Unlicensed, + banner_visible: false, + grace_period_ends: None, + show_usage_dialog: false, + revalidation_required: true, + }; + } } } - // Commercial usage detected? - if let Some(detected_str) = &prefs.commercial_detected_at { - if let Ok(detected) = chrono::DateTime::parse_from_rfc3339(detected_str) { - let grace_end = detected.with_timezone(&Utc) + chrono::Duration::days(14); - let grace_period_ends = Some(grace_end.to_rfc3339()); - let banner_visible = now > grace_end && !banner_dismissed; - return LicenseStatus { - tier: LicenseTier::Unlicensed, - banner_visible, - grace_period_ends, - show_usage_dialog: false, - revalidation_required: false, - }; - } + // Commercial usage detected — show banner immediately (subject only to session dismissal). + if prefs.commercial_detected_at.is_some() { + return LicenseStatus { + tier: LicenseTier::Unlicensed, + banner_visible: !banner_dismissed, + grace_period_ends: None, + show_usage_dialog: false, + revalidation_required: false, + }; } // Personal or unknown — check if we should show the usage dialog @@ -547,14 +626,16 @@ fn compute_status_pure( /// Returns status from cached state only — no network calls. fn compute_status(app: &AppHandle, banner_dismissed: bool) -> LicenseStatus { let prefs = load_prefs(app); - let stored = read_stored_license(); - compute_status_pure(stored.as_ref(), &prefs, banner_dismissed, Utc::now()) + let now = Utc::now(); + let cached = current_cached_license(now); + compute_status_pure(cached, &prefs, banner_dismissed, now) } // --------------------------------------------------------------------------- -// Error message helpers +// Error message helpers (Direct build only) // --------------------------------------------------------------------------- +#[cfg(not(feature = "mas"))] fn dodo_error_message(error: &DodoError) -> String { match error { DodoError::InvalidFormat => { @@ -566,16 +647,18 @@ fn dodo_error_message(error: &DodoError) -> String { } } +#[cfg(not(feature = "mas"))] fn dodo_invalid_key_message() -> &'static str { "License key is not valid or has expired — check your subscription in the customer portal" } // --------------------------------------------------------------------------- -// Background re-validation +// Background re-validation (Direct build only) // --------------------------------------------------------------------------- /// Re-validates the stored license key against Dodo if it is older than 24 hours. /// Called on launch and then every 24 hours by the background task in lib.rs. +#[cfg(not(feature = "mas"))] pub async fn revalidate_license_background(app: AppHandle) { let Some(stored) = read_stored_license() else { return; @@ -630,6 +713,7 @@ pub async fn get_license_status( Ok(compute_status(&app, dismissed)) } +#[cfg(not(feature = "mas"))] #[tauri::command] pub async fn activate_license( app: AppHandle, @@ -651,6 +735,7 @@ pub async fn activate_license( } } +#[cfg(not(feature = "mas"))] #[tauri::command] pub async fn deactivate_license( app: AppHandle, @@ -699,6 +784,7 @@ pub async fn notify_connection_count( Ok(compute_status(&app, dismissed)) } +#[cfg(not(feature = "mas"))] #[tauri::command] pub fn open_customer_portal() -> Result<(), String> { std::process::Command::new("open") @@ -717,6 +803,22 @@ pub fn open_url(url: String) -> Result<(), String> { .map_err(|e| e.to_string()) } +/// Build flavour identifier exposed to the frontend so a single bundled +/// `index.html` can render the right licensing UI for each binary. Returns: +/// - `"mas"` — Mac App Store build (StoreKit IAP, no Dodo, no updater) +/// - `"direct"` — GitHub Releases / Homebrew build (Dodo Payments, updater) +/// +/// The frontend caches this with `staleTime: Infinity` since the value is +/// fixed for the lifetime of the running binary. +#[tauri::command] +pub fn get_build_flavor() -> &'static str { + if cfg!(feature = "mas") { + "mas" + } else { + "direct" + } +} + #[tauri::command] pub async fn get_ui_preferences(app: AppHandle) -> Result { match read_prefs_json(&app) { @@ -778,59 +880,57 @@ mod tests { } } - fn stored(validated_at: chrono::DateTime) -> StoredLicense { - StoredLicense { - license_key: "test-key".to_string(), - validated_at: validated_at.to_rfc3339(), - } - } - - // -- State-machine transitions -- + // -- Status-state transitions (build-agnostic) -- #[test] - fn freshly_validated_key_is_commercial() { + fn cached_active_is_commercial_no_banner() { let now = Utc::now(); - let s = stored(now - Duration::hours(1)); - let status = compute_status_pure(Some(&s), &base_prefs(), false, now); + let status = + compute_status_pure(Some(CachedLicense::Active), &base_prefs(), false, now); assert_eq!(status.tier, LicenseTier::Commercial); assert!(!status.banner_visible); assert!(!status.revalidation_required); + assert!(status.grace_period_ends.is_none()); } #[test] - fn key_validated_13_days_ago_is_still_commercial() { + fn commercial_detected_shows_banner_immediately() { + // No 14-day grace period: as soon as commercial usage is detected the banner is shown + // (subject only to session dismissal). let now = Utc::now(); - let s = stored(now - Duration::days(13)); - let status = compute_status_pure(Some(&s), &base_prefs(), false, now); - assert_eq!(status.tier, LicenseTier::Commercial); + let mut prefs = base_prefs(); + prefs.commercial_detected_at = Some(now.to_rfc3339()); + let status = compute_status_pure(None, &prefs, false, now); + assert_eq!(status.tier, LicenseTier::Unlicensed); + assert!(status.banner_visible); + assert!(status.grace_period_ends.is_none()); assert!(!status.revalidation_required); } #[test] - fn key_validated_over_14_days_ago_requires_revalidation() { + fn commercial_detected_banner_hidden_when_dismissed_for_session() { let now = Utc::now(); - let s = stored(now - Duration::days(15)); - let status = compute_status_pure(Some(&s), &base_prefs(), false, now); + let mut prefs = base_prefs(); + prefs.commercial_detected_at = Some(now.to_rfc3339()); + let status = compute_status_pure(None, &prefs, true, now); assert_eq!(status.tier, LicenseTier::Unlicensed); - assert!(status.revalidation_required); assert!(!status.banner_visible); } #[test] - fn no_stored_license_after_valid_false_shows_banner_when_grace_expired() { - // Simulates: Dodo returned valid:false → clear_stored_license() called → compute_status - // runs next poll with no stored key. Banner shows because commercial was detected earlier. + fn commercial_detected_long_ago_still_shows_banner() { + // Sanity: the previous 14-day grace bug suppressed the banner for 14 days. Even a + // long-ago detection must still surface the banner now. let now = Utc::now(); let mut prefs = base_prefs(); prefs.commercial_detected_at = Some((now - Duration::days(20)).to_rfc3339()); let status = compute_status_pure(None, &prefs, false, now); - assert_eq!(status.tier, LicenseTier::Unlicensed); assert!(status.banner_visible); - assert!(!status.revalidation_required); + assert!(status.grace_period_ends.is_none()); } #[test] - fn no_stored_license_no_commercial_detection_is_unlicensed_no_banner() { + fn no_cached_license_no_commercial_detection_is_unlicensed_no_banner() { let now = Utc::now(); let status = compute_status_pure(None, &base_prefs(), false, now); assert_eq!(status.tier, LicenseTier::Unlicensed); @@ -838,30 +938,144 @@ mod tests { assert!(!status.revalidation_required); } - // -- Error-code → message mapping -- - + #[cfg(not(feature = "mas"))] #[test] - fn invalid_format_error_message() { - assert_eq!( - dodo_error_message(&DodoError::InvalidFormat), - "Invalid license key format — check for typos" - ); + fn build_flavor_reports_direct_when_mas_feature_off() { + assert_eq!(get_build_flavor(), "direct"); } + #[cfg(feature = "mas")] #[test] - fn network_error_message() { - assert_eq!( - dodo_error_message(&DodoError::NetworkOrServer), - "Could not reach the license server — check your connection and try again" - ); + fn build_flavor_reports_mas_when_mas_feature_on() { + assert_eq!(get_build_flavor(), "mas"); } - #[test] - fn valid_false_message() { - assert_eq!( - dodo_invalid_key_message(), - "License key is not valid or has expired — check your subscription in the customer portal" - ); + // -- Direct-only: Dodo cache mapping + error messages -- + + #[cfg(not(feature = "mas"))] + mod direct { + use super::*; + + fn stored(validated_at: chrono::DateTime) -> StoredLicense { + StoredLicense { + license_key: "test-key".to_string(), + validated_at: validated_at.to_rfc3339(), + } + } + + #[test] + fn freshly_validated_key_maps_to_active() { + let now = Utc::now(); + let cached = direct_cached_license(&stored(now - Duration::hours(1)), now); + assert_eq!(cached, CachedLicense::Active); + } + + #[test] + fn key_validated_13_days_ago_still_active() { + let now = Utc::now(); + let cached = direct_cached_license(&stored(now - Duration::days(13)), now); + assert_eq!(cached, CachedLicense::Active); + } + + #[test] + fn key_validated_over_14_days_ago_is_stale() { + let now = Utc::now(); + let cached = direct_cached_license(&stored(now - Duration::days(15)), now); + assert_eq!(cached, CachedLicense::Stale); + } + + #[test] + fn unparseable_validated_at_is_stale() { + let stored = StoredLicense { + license_key: "test-key".to_string(), + validated_at: "garbage".to_string(), + }; + assert_eq!( + direct_cached_license(&stored, Utc::now()), + CachedLicense::Stale + ); + } + + #[test] + fn cached_stale_maps_to_revalidation_required() { + let now = Utc::now(); + let status = + compute_status_pure(Some(CachedLicense::Stale), &base_prefs(), false, now); + assert_eq!(status.tier, LicenseTier::Unlicensed); + assert!(status.revalidation_required); + assert!(!status.banner_visible); + } + + #[test] + fn invalid_format_error_message() { + assert_eq!( + dodo_error_message(&DodoError::InvalidFormat), + "Invalid license key format — check for typos" + ); + } + + #[test] + fn network_error_message() { + assert_eq!( + dodo_error_message(&DodoError::NetworkOrServer), + "Could not reach the license server — check your connection and try again" + ); + } + + #[test] + fn valid_false_message() { + assert_eq!( + dodo_invalid_key_message(), + "License key is not valid or has expired — check your subscription in the customer portal" + ); + } } + // -- MAS-only: StoreKit entitlement mapping -- + + #[cfg(feature = "mas")] + mod mas { + use super::*; + use crate::commands::iap::StoredEntitlement; + + #[test] + fn lifetime_entitlement_with_no_expiry_is_active() { + let now = Utc::now(); + let stored = StoredEntitlement { + product_id: "app.esploro.personal.lifetime".to_string(), + expires_at: None, + }; + assert_eq!(mas_cached_license(&stored, now), Some(CachedLicense::Active)); + } + + #[test] + fn subscription_future_expiry_is_active() { + let now = Utc::now(); + let stored = StoredEntitlement { + product_id: "app.esploro.business.annual".to_string(), + expires_at: Some((now + Duration::days(30)).to_rfc3339()), + }; + assert_eq!(mas_cached_license(&stored, now), Some(CachedLicense::Active)); + } + + #[test] + fn subscription_past_expiry_is_none() { + let now = Utc::now(); + let stored = StoredEntitlement { + product_id: "app.esploro.personal.annual".to_string(), + expires_at: Some((now - Duration::days(1)).to_rfc3339()), + }; + assert_eq!(mas_cached_license(&stored, now), None); + } + + #[test] + fn subscription_unparseable_expiry_is_none() { + let now = Utc::now(); + let stored = StoredEntitlement { + product_id: "app.esploro.personal.annual".to_string(), + expires_at: Some("not-a-date".to_string()), + }; + assert_eq!(mas_cached_license(&stored, now), None); + } + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7018895..053b926 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,11 @@ pub mod connections; pub mod data; +#[cfg(feature = "mas")] +pub mod iap; +#[cfg(feature = "mas")] +pub mod iap_storekit; pub mod license; pub mod saved_queries; pub mod schema; +#[cfg(not(feature = "mas"))] pub mod updater; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45f0c1c..7bde509 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,9 +36,15 @@ impl Default for AppState { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) + let builder = tauri::Builder::default().plugin(tauri_plugin_process::init()); + + // The Tauri updater plugin polls a GitHub Releases endpoint and replaces + // the .app on disk. App Store rules disallow self-updating outside the + // store mechanism, so the MAS build omits it entirely. + #[cfg(not(feature = "mas"))] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + + builder .manage(AppState::default()) .setup(|app| { // Native macOS menu bar @@ -95,13 +101,30 @@ pub fn run() { } }); - let handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - loop { - commands::license::revalidate_license_background(handle.clone()).await; - tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await; - } - }); + // Dodo Payments background re-validation is Direct-build only. + // The MAS build sources entitlement from StoreKit and has no + // license key to re-validate. + #[cfg(not(feature = "mas"))] + { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + commands::license::revalidate_license_background(handle.clone()).await; + tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await; + } + }); + } + + // App Store rules require the SKPaymentQueue transaction observer + // to be installed at launch so we don't miss transactions + // delivered while the app is starting up (e.g. a renewal that + // fired between two launches). + #[cfg(feature = "mas")] + { + let mtm = objc2::MainThreadMarker::new() + .expect("Tauri setup runs on the main thread"); + commands::iap_storekit::install_observer_on_startup(mtm); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -124,17 +147,33 @@ pub fn run() { commands::saved_queries::get_saved_query, commands::saved_queries::delete_saved_query, commands::license::get_license_status, - commands::license::activate_license, - commands::license::deactivate_license, commands::license::answer_usage_dialog, commands::license::dismiss_license_banner, commands::license::notify_connection_count, - commands::license::open_customer_portal, commands::license::open_url, + commands::license::get_build_flavor, commands::license::get_ui_preferences, commands::license::set_ui_preferences, + // Dodo Payments + in-app updater: Direct build only. + #[cfg(not(feature = "mas"))] + commands::license::activate_license, + #[cfg(not(feature = "mas"))] + commands::license::deactivate_license, + #[cfg(not(feature = "mas"))] + commands::license::open_customer_portal, + #[cfg(not(feature = "mas"))] commands::updater::check_for_update, + #[cfg(not(feature = "mas"))] commands::updater::install_update, + // StoreKit IAP: MAS build only. + #[cfg(feature = "mas")] + commands::iap::iap_get_products, + #[cfg(feature = "mas")] + commands::iap::iap_purchase, + #[cfg(feature = "mas")] + commands::iap::iap_restore, + #[cfg(feature = "mas")] + commands::iap::iap_check_entitlement, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c4f58f..db54ed6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,19 +16,12 @@ "width": 1200, "height": 780, "minWidth": 900, - "minHeight": 600, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "windowEffects": { - "effects": ["sidebar"], - "state": "followsWindowActiveState" - } + "minHeight": 600 } ], "security": { "csp": null - }, - "macOSPrivateApi": true + } }, "bundle": { "active": true, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..7378905 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "macOSPrivateApi": true, + "windows": [ + { + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "windowEffects": { + "effects": ["sidebar"], + "state": "followsWindowActiveState" + } + } + ] + } +} diff --git a/src-tauri/tauri.mas.conf.json b/src-tauri/tauri.mas.conf.json new file mode 100644 index 0000000..58acd76 --- /dev/null +++ b/src-tauri/tauri.mas.conf.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "macOSPrivateApi": false, + "windows": [ + { + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "windowEffects": null + } + ] + }, + "bundle": { + "targets": ["app"], + "createUpdaterArtifacts": false, + "macOS": { + "signingIdentity": "Apple Distribution: ", + "provisioningProfile": "EsploroMAS.provisionprofile", + "entitlements": "Esploro-MAS.entitlements" + } + }, + "plugins": { + "updater": { + "active": false + } + } +} diff --git a/src/features/license/LicenseBanner.tsx b/src/features/license/LicenseBanner.tsx index 2745dde..4533a2d 100644 --- a/src/features/license/LicenseBanner.tsx +++ b/src/features/license/LicenseBanner.tsx @@ -1,8 +1,13 @@ import { useState } from 'react'; import { X } from 'lucide-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { licenseApi, LICENSE_STATUS_KEY } from './api'; +import { + BUILD_FLAVOR_KEY, + LICENSE_STATUS_KEY, + licenseApi, +} from './api'; import { LicenseActivationSheet } from './LicenseActivationSheet'; +import { PurchaseSheet } from './PurchaseSheet'; export function LicenseBanner() { const queryClient = useQueryClient(); @@ -11,8 +16,20 @@ export function LicenseBanner() { queryFn: licenseApi.getStatus, staleTime: 60_000, }); + const { data: buildFlavor } = useQuery({ + queryKey: BUILD_FLAVOR_KEY, + queryFn: licenseApi.getBuildFlavor, + staleTime: Infinity, + }); const [activationOpen, setActivationOpen] = useState(false); + const [purchaseOpen, setPurchaseOpen] = useState(false); + // `revalidationRequired` is always false on MAS (StoreKit is the source of + // truth, no offline-grace concept), so the dedicated revalidation banner + // never appears there. Fall back gracefully if the build-flavour query + // hasn't resolved yet by treating the build as direct (matches behaviour + // before P2 Step 4). + const isMas = buildFlavor === 'mas'; const showBanner = status?.bannerVisible || status?.revalidationRequired; if (!showBanner) return null; @@ -21,7 +38,7 @@ export function LicenseBanner() { queryClient.invalidateQueries({ queryKey: LICENSE_STATUS_KEY }); } - if (status?.revalidationRequired) { + if (status?.revalidationRequired && !isMas) { return (
Esploro is free for personal use. Commercial use requires a license. - - + {isMas ? ( + + ) : ( + <> + + + + )} + )} + +
+ + + ); + } + + // ---- MAS — Personal / unlicensed user + if (isMas) { + return ( +
+

+ License +

+
+
+ + + {status?.tier === 'Personal' ? 'Personal (free)' : 'No license'} + +
+

+ Commercial use requires a license. +

+
+ + +
+
+ setPurchaseOpen(false)} + /> +
+ ); + } + + // ---- Direct build (existing UI) return (

diff --git a/src/features/license/PurchaseSheet.tsx b/src/features/license/PurchaseSheet.tsx new file mode 100644 index 0000000..7ee6f3e --- /dev/null +++ b/src/features/license/PurchaseSheet.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Loader2, X } from 'lucide-react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + IAP_ENTITLEMENT_KEY, + IAP_PRODUCTS_KEY, + LICENSE_STATUS_KEY, + licenseApi, +} from './api'; +import { useToast } from '../../components/Toast'; + +interface Props { + open: boolean; + onClose: () => void; +} + +/// Right-side sheet that lists the three IAP products fetched live from +/// `iap_get_products` and routes per-card purchases through `iap_purchase`. +/// Only rendered in the MAS build (`buildFlavor === 'mas'`); the Direct build +/// uses `LicenseActivationSheet` instead. +/// +/// Restore Purchases is mounted both here (App Review precedent) and in +/// `LicenseSettings`, so a user who already paid on another machine can recover +/// their entitlement without ever opening this sheet. +export function PurchaseSheet({ open, onClose }: Props) { + const queryClient = useQueryClient(); + const { toast } = useToast(); + const [busyProductId, setBusyProductId] = useState(null); + const [restoring, setRestoring] = useState(false); + + const { + data: products, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: IAP_PRODUCTS_KEY, + queryFn: licenseApi.getProducts, + enabled: open, + staleTime: 5 * 60_000, + }); + + async function refreshLicenseState() { + const [status, entitlement] = await Promise.all([ + licenseApi.getStatus(), + licenseApi.checkEntitlement(), + ]); + queryClient.setQueryData(LICENSE_STATUS_KEY, status); + queryClient.setQueryData(IAP_ENTITLEMENT_KEY, entitlement); + } + + async function handlePurchase(productId: string) { + setBusyProductId(productId); + try { + const result = await licenseApi.purchase(productId); + if (result.status === 'purchased') { + await refreshLicenseState(); + toast('License activated. Welcome aboard!', 'success'); + onClose(); + } else if (result.status === 'cancelled') { + // No toast — user dismissed the sheet themselves. + } else { + toast('Purchase failed. Please try again.', 'error'); + } + } catch (e) { + toast(`Purchase failed: ${String(e)}`, 'error'); + } finally { + setBusyProductId(null); + } + } + + async function handleRestore() { + setRestoring(true); + try { + const result = await licenseApi.restore(); + await refreshLicenseState(); + if (result.restored) { + toast('Purchases restored.', 'success'); + onClose(); + } else { + toast('No previous purchases found on this Apple ID.', 'info'); + } + } catch (e) { + toast(`Restore failed: ${String(e)}`, 'error'); + } finally { + setRestoring(false); + } + } + + return ( + !v && onClose()}> + + + e.preventDefault()} + > +
+ + Get a license + + + + +
+ +
+

+ Esploro is free for personal use. Choose a plan below for + commercial use. +

+ + {isLoading && ( +
+ + Loading plans… +
+ )} + + {error && ( +
+

+ Couldn't load plans from the App Store. +

+ +
+ )} + + {products?.map((product) => { + const busy = busyProductId === product.id; + const anyBusy = busyProductId !== null || restoring; + return ( +
+
+

+ {product.title} +

+ + {product.price} + +
+

+ {product.description} +

+ +
+ ); + })} +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/features/license/api.ts b/src/features/license/api.ts index 1b1ef9c..9580f80 100644 --- a/src/features/license/api.ts +++ b/src/features/license/api.ts @@ -1,17 +1,47 @@ import { invoke } from '@tauri-apps/api/core'; -import type { LicenseStatus } from './types'; +import type { + BuildFlavor, + IapEntitlement, + IapProduct, + IapPurchaseResult, + IapRestoreResult, + LicenseStatus, +} from './types'; export const LICENSE_STATUS_KEY = ['license-status'] as const; +export const BUILD_FLAVOR_KEY = ['build-flavor'] as const; +export const IAP_PRODUCTS_KEY = ['iap-products'] as const; +export const IAP_ENTITLEMENT_KEY = ['iap-entitlement'] as const; + +/// Apple's deep link to the user's subscription management screen on macOS. +/// `macappstore://` opens the Mac App Store app directly; falling back to the +/// `https://` URL would route through a browser → App Store handoff. +const MANAGE_SUBSCRIPTIONS_URL = + 'macappstore://apps.apple.com/account/subscriptions'; export const licenseApi = { + // Both builds. getStatus: () => invoke('get_license_status'), - activate: (key: string) => invoke('activate_license', { key }), - deactivate: () => invoke('deactivate_license'), + getBuildFlavor: () => invoke('get_build_flavor'), answerUsageDialog: (answer: 'personal' | 'commercial') => invoke('answer_usage_dialog', { answer }), dismissBanner: () => invoke('dismiss_license_banner'), notifyConnectionCount: (count: number) => invoke('notify_connection_count', { count }), openPricingPage: () => invoke('open_url', { url: 'https://esploro.app/pricing' }), + + // Direct build only — calls fail at runtime on the MAS binary because the + // commands aren't registered there. + activate: (key: string) => invoke('activate_license', { key }), + deactivate: () => invoke('deactivate_license'), openCustomerPortal: () => invoke('open_customer_portal'), + + // MAS build only — same caveat in the opposite direction. + getProducts: () => invoke('iap_get_products'), + purchase: (productId: string) => + invoke('iap_purchase', { productId }), + restore: () => invoke('iap_restore'), + checkEntitlement: () => invoke('iap_check_entitlement'), + openManageSubscription: () => + invoke('open_url', { url: MANAGE_SUBSCRIPTIONS_URL }), }; diff --git a/src/features/license/index.ts b/src/features/license/index.ts index e6b7f88..ee4be99 100644 --- a/src/features/license/index.ts +++ b/src/features/license/index.ts @@ -1,6 +1,22 @@ export { LicenseBanner } from './LicenseBanner'; export { LicenseActivationSheet } from './LicenseActivationSheet'; +export { PurchaseSheet } from './PurchaseSheet'; export { UsageTypeDialog } from './UsageTypeDialog'; export { LicenseSettings } from './LicenseSettings'; -export { licenseApi, LICENSE_STATUS_KEY } from './api'; -export type { LicenseStatus, LicenseTier } from './types'; +export { + licenseApi, + LICENSE_STATUS_KEY, + BUILD_FLAVOR_KEY, + IAP_PRODUCTS_KEY, + IAP_ENTITLEMENT_KEY, +} from './api'; +export type { + LicenseStatus, + LicenseTier, + BuildFlavor, + IapProduct, + IapPurchaseResult, + IapPurchaseStatus, + IapRestoreResult, + IapEntitlement, +} from './types'; diff --git a/src/features/license/types.ts b/src/features/license/types.ts index 67bb5f7..b854b00 100644 --- a/src/features/license/types.ts +++ b/src/features/license/types.ts @@ -7,3 +7,34 @@ export interface LicenseStatus { showUsageDialog: boolean; revalidationRequired: boolean; } + +/// Identifies which binary flavour is running. The Direct build is shipped via +/// GitHub Releases / Homebrew with Dodo Payments; the MAS build is shipped via +/// the Mac App Store with StoreKit IAP. The frontend ships a single bundle and +/// picks the right UI by querying `get_build_flavor` once at startup. +export type BuildFlavor = 'direct' | 'mas'; + +export interface IapProduct { + id: string; + title: string; + description: string; + /** Already localised + currency-formatted by StoreKit (e.g. `"$129.00"`). */ + price: string; +} + +export type IapPurchaseStatus = 'purchased' | 'cancelled' | 'failed'; + +export interface IapPurchaseResult { + status: IapPurchaseStatus; +} + +export interface IapRestoreResult { + restored: boolean; +} + +export interface IapEntitlement { + entitled: boolean; + productId: string | null; + /** RFC 3339 timestamp; `null` for non-consumable lifetime products. */ + expiresAt: string | null; +} diff --git a/store-listing/README.md b/store-listing/README.md new file mode 100644 index 0000000..0ce9b7b --- /dev/null +++ b/store-listing/README.md @@ -0,0 +1,28 @@ +# App Store Listing Assets + +Assets and metadata for the Mac App Store submission of Esploro. +Mirrors PRD §P3 — Store Listing Assets so the App Store Connect form can be +filled in straight from this directory without re-reading the PRD. + +## Contents + +| File | Status | Notes | +|---|---|---| +| `app-icon-1024.png` | Ready | 1024×1024 RGB PNG, no alpha, square fill — generated by `scripts/build-app-store-icon.swift` from `src-tauri/icons/icon.png`. | +| `metadata.md` | Ready | Text content (Name, Subtitle, Keywords, URLs, Description). | +| `screenshots/` | Pending | Manual capture against a running app — see `screenshots/README.md`. | +| `scripts/build-app-store-icon.swift` | Ready | Reproducible icon build. Run again whenever `src-tauri/icons/icon.png` changes. | + +## Regenerating the icon + +```bash +swift store-listing/scripts/build-app-store-icon.swift \ + src-tauri/icons/icon.png \ + store-listing/app-icon-1024.png +``` + +The script composites the rounded source icon onto a solid background sampled +from the source's own corner-most gradient pixel, then writes RGB-only PNG so +the output carries no alpha channel — both App Store requirements. Apple +applies its own rounded-rectangle mask in the App Store, so the slight square +fringe visible in the bare preview is clipped away in production. diff --git a/store-listing/app-icon-1024.png b/store-listing/app-icon-1024.png new file mode 100644 index 0000000..93e3986 Binary files /dev/null and b/store-listing/app-icon-1024.png differ diff --git a/store-listing/metadata.md b/store-listing/metadata.md new file mode 100644 index 0000000..a0b74c5 --- /dev/null +++ b/store-listing/metadata.md @@ -0,0 +1,92 @@ +# App Store Connect Metadata + +Direct copy of the text content from PRD §P3 — Store Listing Assets so the +App Store Connect form can be filled in from this file alone. Update both +this file and the PRD if either drifts. + +## Identification + +| Field | Limit | Value | +|---|---|---| +| Name | 30 chars | `Esploro` | +| Subtitle | 30 chars | `Postgres & MySQL Client` | +| Bundle ID | — | `app.esploro` | +| Primary Category | — | Developer Tools | +| Pricing | — | Free download (in-app purchase for commercial use) | + +## Keywords + +100-character limit, comma-separated, no spaces (App Store counts spaces). + +``` +postgres,mysql,sql,database,query,client,developer,db,schema,postgresql +``` + +## URLs + +| Field | Value | +|---|---| +| Privacy policy | https://esploro.app/privacy | +| Terms of use | https://esploro.app/terms | +| Support URL | https://esploro.app | +| Support email | support@tandoku.hr | +| Marketing URL (optional) | https://esploro.app | + +## Description + +Use as-is. Paragraphs are separated by blank lines; bold markers in the App +Store are not rendered as markdown, so the `**` prefixes act as label cues +that read naturally even as plain text. + +``` +Esploro is a native Mac database client for PostgreSQL and MySQL. Built for developers who want to move fast without a cluttered UI. + +Browse and filter data — Open any table, sort by any column, and apply filters across multiple column types including UUIDs, timestamps, booleans, and more. Pagination keeps large tables fast. + +Write and run queries — A Monaco-powered SQL editor with syntax highlighting, keyboard-driven execution, and per-tab result sets. Export results as CSV. + +Explore your schema — Browse tables, views, columns, types, indexes, and foreign keys in the schema panel without writing a single query. + +Multiple connections — Manage all your databases from one window. Colour-coded connection tabs. + +Esploro is free for personal use. Commercial use requires a license — available as a one-time purchase or annual subscription. +``` + +## Version release notes (1.0) + +First Mac App Store release. Features parity with the existing GitHub +Releases / Homebrew build, with these MAS-specific differences: + +- Licensing via Mac App Store In-App Purchase (Personal Lifetime, Personal + Annual, Business Annual). The Direct build's license-key flow is not + used on MAS. +- Auto-updater disabled (App Store handles updates). +- Sandboxed; only outbound TCP entitlement (`com.apple.security.network.client`) + is requested — required to reach PostgreSQL / MySQL servers. +- Flat sidebar instead of the Direct build's frosted-glass vibrancy + (private API not allowed in App Store submissions). + +## In-App Purchase products + +Mirror of the table in PRD §IAP Products. All three resolve to +`LicenseTier::Commercial`; the app does not gate features between them. + +| Product ID | Type | Price | Intended for | +|---|---|---|---| +| `app.esploro.personal.lifetime` | Non-consumable | $129 | Individual commercial use, one-time | +| `app.esploro.personal.annual` | Auto-renewable subscription | $49/yr | Individual commercial use, subscription | +| `app.esploro.business.annual` | Auto-renewable subscription | $79/yr | Business / company | + +## Age rating + +4+ (no objectionable content; no user-generated content; no third-party ads). + +## App Review notes + +> Esploro connects to PostgreSQL and MySQL databases over outbound TCP only +> (`com.apple.security.network.client` entitlement). It does not host a +> server, does not collect telemetry, and does not require an account. To +> exercise the commercial-license code path during review, use one of the +> sandbox IAP products listed above. Personal use is free and does not +> require any purchase to test the core features (connection management, +> schema browsing, table viewer, SQL editor). diff --git a/store-listing/screenshots/README.md b/store-listing/screenshots/README.md new file mode 100644 index 0000000..d27b61c --- /dev/null +++ b/store-listing/screenshots/README.md @@ -0,0 +1,47 @@ +# Screenshots — Pending Manual Capture + +App Store Connect requires a minimum of three screenshots per size set, +maximum of ten. These cannot be reasonably scripted because they require +real database content, a configured connection, and a running app +window — capture them by hand against the Direct build (the visible UI is +identical to the MAS build except for the sidebar vibrancy, which is +irrelevant to App Review). + +## Required size sets + +| Display | Resolution | Required | +|---|---|---| +| MacBook Pro 14" / 16" | 2880×1800 or 1440×900 | Yes | +| MacBook Air / Pro 13" | 2560×1600 or 1280×800 | Recommended | + +## Suggested shots (in App Store gallery order) + +1. **Connection manager** — sidebar list with at least two saved connections, + one connection active (green dot), the connection form open with a + realistic-looking host/port/database/user filled in. +2. **Table browser** — a populated table loaded into the table viewer, + sorted by one column, with one column filter applied (e.g. `status = ...`). + Pick a table that includes a UUID column and a `created_at` timestamp so + the typed-cell rendering is visible. +3. **Query editor** — a non-trivial SQL query (a join with `ORDER BY` and + `LIMIT`) with the result panel showing rows below. Use the Tokyo Night + theme — it's the default for new installs. +4. **Schema browser** — a deeply expanded schema tree (database → schema → + tables → one expanded table showing column types). +5. **Settings / dark theme** — Appearance section visible, theme picker open, + demonstrating the available themes. + +## Capture tips + +- Resize the window to one of the required resolutions before capturing + (use `osascript` or Rectangle to set window size programmatically). +- Use macOS Screenshot (`⌘⇧4`, then space → click window) and crop tightly. +- Disable any system overlays (notifications, Stage Manager bezel) before + capturing. +- Save as PNG; App Store Connect accepts PNG and JPEG, PNG preserves text + edges better. +- File-name convention: `01-connections.png`, `02-table-browser.png`, + `03-query-editor.png`, `04-schema-browser.png`, `05-settings.png` — + numeric prefix preserves gallery order during upload. + +Place captured files directly in this directory. diff --git a/store-listing/scripts/build-app-store-icon.swift b/store-listing/scripts/build-app-store-icon.swift new file mode 100755 index 0000000..ad96595 --- /dev/null +++ b/store-listing/scripts/build-app-store-icon.swift @@ -0,0 +1,146 @@ +#!/usr/bin/env swift +// +// build-app-store-icon.swift +// +// Produces the App Store-compliant 1024x1024 icon from the existing +// rounded-corner source icon (src-tauri/icons/icon.png). +// +// App Store Connect requires: +// - exactly 1024x1024 px +// - PNG with no alpha channel +// - no rounded corners (Apple applies its own rounded-rectangle mask) +// +// Strategy: composite the source onto a solid square background coloured +// to match the source's own corner-most gradient pixel. We find the most- +// corner-ward fully-opaque pixel along each of the four corner diagonals +// (these sit exactly on the source's rounded-rect edge, where the gradient +// is darkest) and average them for the background fill. CGContext's alpha +// blending then handles the rounded-rect anti-aliased edge cleanly, and +// because the fill colour matches the gradient where the rounded rect +// meets the corner, Apple's icon mask cannot expose a visible fringe +// regardless of how its radius compares to the source's. +// +// Usage: +// swift build-app-store-icon.swift + +import AppKit +import CoreGraphics +import Foundation + +guard CommandLine.arguments.count == 3 else { + FileHandle.standardError.write(Data("usage: build-app-store-icon.swift \n".utf8)) + exit(2) +} + +let inPath = CommandLine.arguments[1] +let outPath = CommandLine.arguments[2] + +guard let inputData = try? Data(contentsOf: URL(fileURLWithPath: inPath)), + let inputImage = NSImage(data: inputData), + let inputCG = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) +else { + FileHandle.standardError.write(Data("error: failed to load input image at \(inPath)\n".utf8)) + exit(1) +} + +let size = 1024 +guard inputCG.width == size, inputCG.height == size else { + FileHandle.standardError.write(Data("error: input image must be \(size)x\(size), got \(inputCG.width)x\(inputCG.height)\n".utf8)) + exit(1) +} + +let colorSpace = CGColorSpaceCreateDeviceRGB() +let bytesPerRow = size * 4 + +// Read the source into a pixel buffer once so we can sample arbitrary +// coordinates without going through CGContext.draw per sample (which is +// silently slow on this Apple Silicon machine). +var pixels = [UInt8](repeating: 0, count: size * bytesPerRow) +guard let readCtx = pixels.withUnsafeMutableBytes({ buf -> CGContext? in + CGContext( + data: buf.baseAddress, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) +}) else { + FileHandle.standardError.write(Data("error: failed to create read CGContext\n".utf8)) + exit(1) +} +readCtx.draw(inputCG, in: CGRect(x: 0, y: 0, width: size, height: size)) + +@inline(__always) func offset(_ x: Int, _ y: Int) -> Int { (y * size + x) * 4 } + +// Walk inward along the diagonal from a corner until we hit a fully-opaque +// pixel. That's the gradient colour right at the rounded-rect edge — +// exactly the colour the corner would have if the rect weren't rounded. +struct Sample { let r: Double; let g: Double; let b: Double } +func sampleCorner(cornerX: Int, cornerY: Int) -> Sample { + let stepX = cornerX == 0 ? 1 : -1 + let stepY = cornerY == 0 ? 1 : -1 + var x = cornerX + var y = cornerY + while x >= 0 && x < size && y >= 0 && y < size { + let idx = offset(x, y) + if pixels[idx + 3] == 255 { + return Sample( + r: Double(pixels[idx]) / 255.0, + g: Double(pixels[idx + 1]) / 255.0, + b: Double(pixels[idx + 2]) / 255.0 + ) + } + x += stepX + y += stepY + } + return Sample(r: 0, g: 0, b: 0) +} + +let cornerSamples = [ + sampleCorner(cornerX: 0, cornerY: 0), + sampleCorner(cornerX: size - 1, cornerY: 0), + sampleCorner(cornerX: 0, cornerY: size - 1), + sampleCorner(cornerX: size - 1, cornerY: size - 1), +] +let count = Double(cornerSamples.count) +let bgR = cornerSamples.map(\.r).reduce(0, +) / count +let bgG = cornerSamples.map(\.g).reduce(0, +) / count +let bgB = cornerSamples.map(\.b).reduce(0, +) / count +print(String(format: "sampled rounded-rect edge colour: r=%.3f g=%.3f b=%.3f (#%02x%02x%02x)", + bgR, bgG, bgB, + Int(bgR * 255), Int(bgG * 255), Int(bgB * 255))) + +// Render: solid background + source on top, into a no-alpha bitmap so the +// output PNG carries no alpha channel. +guard let outCtx = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue +) else { + FileHandle.standardError.write(Data("error: failed to create output CGContext\n".utf8)) + exit(1) +} + +outCtx.setFillColor(red: bgR, green: bgG, blue: bgB, alpha: 1.0) +outCtx.fill(CGRect(x: 0, y: 0, width: size, height: size)) +outCtx.draw(inputCG, in: CGRect(x: 0, y: 0, width: size, height: size)) + +guard let outCG = outCtx.makeImage() else { + FileHandle.standardError.write(Data("error: failed to make output image\n".utf8)) + exit(1) +} + +let rep = NSBitmapImageRep(cgImage: outCG) +guard let pngData = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write(Data("error: failed to encode PNG\n".utf8)) + exit(1) +} + +try pngData.write(to: URL(fileURLWithPath: outPath)) +print("wrote \(outPath) (\(pngData.count) bytes, no alpha channel)")