From ead3856763c1038b5cc22147d3cbfbad7df7594d Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Tue, 12 May 2026 14:48:03 -0500 Subject: [PATCH 1/2] proof(pointer): gate updates and persistence --- NORTH_STAR.md | 33 +++- README.md | 3 +- apps/macos/MANUAL_RELEASE_CHECKS.md | 2 +- apps/macos/Makefile | 6 +- apps/macos/POINTER_PERSISTENCE_RESEARCH.md | 74 +++++++++ apps/macos/README.md | 12 +- apps/macos/RELEASE_RUNBOOK.md | 5 + apps/macos/REQUIREMENTS.md | 21 ++- .../Scripts/manual-release-evidence-check.sh | 4 +- .../manual-release-evidence-template.sh | 2 +- apps/macos/Scripts/north-star-audit.sh | 2 +- apps/macos/Scripts/release-readiness.sh | 7 + .../Scripts/release-source-state-check.sh | 90 +++++++++++ .../PreferencesWindowController.swift | 97 +++++++++++ .../PointerDesignerCore/CursorEngine.swift | 59 +++++++ .../PointerDesignerCore/CursorSettings.swift | 20 ++- .../CursorStateController.swift | 15 ++ .../PointerDesignerCore/UpdateChecker.swift | 152 ++++++++++++++++++ .../CursorSettingsTests.swift | 23 ++- .../CursorStateControllerTests.swift | 23 +++ .../PointerDesignerTests/IdentityTests.swift | 64 +++++++- .../UpdateCheckerTests.swift | 26 +++ scripts/check-app-ui-contract.sh | 4 + scripts/check-local-first.sh | 9 ++ 24 files changed, 736 insertions(+), 17 deletions(-) create mode 100644 apps/macos/POINTER_PERSISTENCE_RESEARCH.md create mode 100755 apps/macos/Scripts/release-source-state-check.sh create mode 100644 apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift create mode 100644 apps/macos/Tests/PointerDesignerTests/UpdateCheckerTests.swift diff --git a/NORTH_STAR.md b/NORTH_STAR.md index d5ee76d..ea438ed 100644 --- a/NORTH_STAR.md +++ b/NORTH_STAR.md @@ -19,7 +19,8 @@ it, local-first, reversible, and respectful of macOS permission boundaries. - Preserve last-known permission posture for continuity and diagnostics while keeping live macOS permission checks authoritative. - Keep processing local. Do not add telemetry, trackers, cloud processing, or - hidden network dependency to the cursor loop. + hidden network dependency to the cursor loop. Update checks are allowed only + after the user explicitly enables internet access for update checks. ## Product Shape Today @@ -50,6 +51,8 @@ Designer. - If screen recording permission, helper installation, display state, or stored settings are unavailable or invalid, the app fails safely and explains itself through UI state or diagnostics instead of doing surprising work. +- If update checking is used, it is user-initiated from Settings and remains + off until the user explicitly allows internet access for that purpose. ## Pointer Capability Contract @@ -72,6 +75,11 @@ Current supported contract: must stay hidden or explicitly marked unavailable unless a public, distribution-safe, tested implementation enables it through the app capability model. +- AppKit cursor replacement is not a durable cross-application pointer + replacement guarantee. Other apps' cursor-update handling can reset it. The + current least-permission mitigation is a reapply supervisor; any stronger + persistence mechanism must come from a proven supervisor or helper capability + documented in `apps/macos/POINTER_PERSISTENCE_RESEARCH.md`. Unsupported claims: @@ -88,6 +96,8 @@ Prefer work that increases trust in the core utility: - clearer identity and packaging consistency - safer helper and XPC behavior only when a real pointer capability requires it +- a least-permission pointer supervisor that improves cross-app persistence + without private APIs, admin access, or broad Accessibility grants - better crash recovery and orphan cleanup - stronger display, color, and permission edge-case handling - focused local verification for app, helper, settings, and packaging paths @@ -116,11 +126,13 @@ true from live evidence: 6. Packaging scripts produce a validated app bundle and DMG from the repo-local macOS package. 7. Signing, notarization, release metadata, and install instructions are - verified. Homebrew or cask distribution is optional and must remain absent - or explicitly blocked until its URL, checksum, notarization, and install - behavior are verified. + verified from a clean committed tree. Homebrew or cask distribution is optional + and must remain absent or explicitly blocked until its URL, checksum, + notarization, and install behavior are verified. 8. The repo contains no wrong-product surfaces, stale WindowDrop language, telemetry, trackers, surprise network calls, or placeholder release claims. +9. Update checks are unavailable until the user enables internet access for + update checks, and any update action is user-initiated from Settings. The phrase "mass production ready" means every item above has direct proof. A green test suite alone is not enough if packaging, signing, notarization, @@ -141,8 +153,12 @@ These are intentional blockers, not polish notes: unavailable. - Helper installation remains scaffolded and must not be sold as a user-facing capability or required path until a supported pointer capability exists. +- Cursor persistence across other applications is not considered solved by + `NSCursor.set()` alone. If the app claims stronger cross-app persistence, it + must ship a verified least-permission supervisor or supported helper path. - Release packaging, signing, notarization, stable release metadata, and DMG - install flow require live verification before any public launch claim. + install flow require live verification from a clean committed tree before any + public launch claim. - Current release-authority blockers are tracked in GitHub issue #71: https://github.com/rogu3bear/macOS-pointer-designer/issues/71. Keep that issue open until the final North Star audit passes against the same @@ -203,6 +219,11 @@ proof for another. under assessment by identity, version, build, and executable digest; from `apps/macos`, `make dmg-artifact-match-check` - Signing identity: from `apps/macos`, `make signing-identity-check` +- Release source state: from `apps/macos`, `make release-source-state-check`; + public release gates must run from a clean committed tree so the signed app, + DMG, release metadata, and manual evidence all refer to the same source + state. `make release-readiness` also requires the app executable and DMG to + be newer than the commit being certified. - Notarization credential setup and check: from `apps/macos`, `make setup-notary-profile` with private operator `NOTARY_*` inputs, then `make notary-profile-check` @@ -232,5 +253,7 @@ explicit blocker. - Making a helper mandatory for ordinary preference preview behavior. - Adding telemetry, trackers, surprise network calls, or hidden background services outside the documented app/helper model. +- Checking the internet for updates before the user enables update-check + internet access in Settings. - Rebranding compatibility identifiers without a migration plan and full identity verification. diff --git a/README.md b/README.md index 872d719..8bb39b4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,8 @@ metadata, and completed manual release evidence before any mass-production claim. From `apps/macos`, the release authority lane starts with -`make setup-notary-profile` and `make notary-profile-check`, then proceeds +`make setup-notary-profile` and `make notary-profile-check`, verifies the +committed source state with `make release-source-state-check`, then proceeds through `make release-candidate`, `make release-artifact-readiness`, and `make release-readiness` against the same artifact. diff --git a/apps/macos/MANUAL_RELEASE_CHECKS.md b/apps/macos/MANUAL_RELEASE_CHECKS.md index 917b8a5..1c109ea 100644 --- a/apps/macos/MANUAL_RELEASE_CHECKS.md +++ b/apps/macos/MANUAL_RELEASE_CHECKS.md @@ -75,7 +75,7 @@ App executable SHA-256: Machine gates: - make release-readiness: Pass/fail -- spctl --assess --type open --verbose=4 CursorDesigner.dmg: Pass/fail +- spctl --assess --type open --context context:primary-signature --verbose=4 CursorDesigner.dmg: Pass/fail - xcrun stapler validate CursorDesigner.dmg: Pass/fail Manual observations: diff --git a/apps/macos/Makefile b/apps/macos/Makefile index 9c7aed6..4b20b83 100644 --- a/apps/macos/Makefile +++ b/apps/macos/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build release clean test install uninstall dmg create-dmg signed-dmg sign-dmg preflight launch-smoke dmg-install-check dmg-artifact-match-check release-metadata-check signing-identity-check setup-notary-profile notary-profile-check manual-release-evidence-template manual-release-evidence-check north-star-audit release-artifact-readiness release-readiness release-candidate +.PHONY: all build release clean test install uninstall dmg create-dmg signed-dmg sign-dmg preflight launch-smoke dmg-install-check dmg-artifact-match-check release-source-state-check release-metadata-check signing-identity-check setup-notary-profile notary-profile-check manual-release-evidence-template manual-release-evidence-check north-star-audit release-artifact-readiness release-readiness release-candidate PRODUCT_NAME = CursorDesigner BUILD_DIR = .build @@ -105,6 +105,10 @@ dmg-install-check: dmg-artifact-match-check: @./Scripts/dmg-install-check.sh --app "$(APP_BUNDLE)" --dmg "$(DMG_NAME)" $(DMG_SIGNATURE_FLAGS) +# Verify public release gates are running from a committed tree. +release-source-state-check: + @./Scripts/release-source-state-check.sh + # Verify public release metadata is truthful for the current distribution state. release-metadata-check: @./Scripts/release-metadata-check.sh --app "$(APP_BUNDLE)" --repo "$(GITHUB_REPO)" --dmg "$(DMG_NAME)" diff --git a/apps/macos/POINTER_PERSISTENCE_RESEARCH.md b/apps/macos/POINTER_PERSISTENCE_RESEARCH.md new file mode 100644 index 0000000..78f846f --- /dev/null +++ b/apps/macos/POINTER_PERSISTENCE_RESEARCH.md @@ -0,0 +1,74 @@ +# Cursor Persistence Research + +Last reviewed: May 12, 2026. + +This note explains why Cursor Designer's current custom cursor can return to the +default shape or orientation in some applications, and what a least-permission +fix should look like. + +## Findings + +1. `NSCursor.set()` is an AppKit cursor mechanism, not a durable system-wide + pointer replacement contract. Apple documents `NSCursor.set()` as making the + receiver the current cursor, and AppKit cursor rectangles/tracking areas are + designed so the view under the pointer can set I-beam, hand, resize, and + other cursors as the pointer crosses cursor regions. +2. The current implementation uses `NSCursor(image:hotSpot:)` and `cursor.set()` + in `CursorEngine.applyCursor()`. That can work while Cursor Designer or one + of its own cursor rects is controlling the pointer, but another application + can legitimately replace the cursor as part of normal AppKit cursor-update + handling. +3. A privileged helper is not automatically the right answer. The current helper + scaffold correctly keeps `supportsSystemWidePointerReplacement == false` + because a distribution-safe public API for replacing every system cursor has + not been proven here. Private WindowServer/CGS cursor replacement should stay + out of the release path unless a future tranche proves entitlement, + notarization, rollback, and user-consent behavior. +4. The least-permission durable path starts with a supervised pointer + presentation layer: a menu-bar agent that keeps the visible customization + alive by observing pointer motion and app/display changes, then reapplying or + drawing the custom pointer without installing a privileged helper. Mouse + event observation can use AppKit global monitors for mouse events; Apple + notes global monitors only observe events and cannot modify or prevent + delivery. +5. Screen Recording is only justified for dynamic contrast/background sampling. + It must not be requested for static color, static outline, settings, launch at + login, or update checks. +6. Accessibility is only justified when Cursor Designer needs to control or read + other applications through Accessibility APIs, monitor key-related events, or + synthesize input. It should not be requested just to check for updates or to + render the Preferences preview. +7. Update checks are network behavior and must be explicit. The app should only + contact release metadata when the user enables internet access for update + checks and presses a settings-menu update action. Future automatic updates + should use a signed appcast/updater system such as Sparkle only after the same + opt-in boundary is preserved. + +## Recommended Architecture + +Keep three separate capability layers: + +- **AppKit preview layer**: no extra permission. Uses `NSCursor`, cursor rects, + and Preferences preview. This is useful but not a system-wide persistence + guarantee. +- **Pointer supervisor layer**: no privileged helper by default. Observes mouse, + active-app, display, sleep/wake, and settings changes; keeps the chosen pointer + presentation alive by reapplying the current cursor; degrades honestly in apps + or modes it cannot control. If it later uses a click-through overlay, it must + be reversible, local-only, and disabled before any password, secure input, + screen saver, or full-screen edge case that proves unsafe. +- **Privileged/helper layer**: disabled until proven. Only enable after there is + a public, notarizable, least-privilege mechanism with rollback, code-signing + verification, and manual permission evidence. Do not ship private CGS or SIP + workarounds as product behavior. + +## Sources + +- Apple Developer Documentation, `NSCursor.set()`: https://developer.apple.com/documentation/appkit/nscursor/set%28%29 +- Apple Developer Documentation, `NSCursor`: https://developer.apple.com/documentation/appkit/nscursor +- Apple Cocoa Event Handling Guide, cursor-update events and cursor rectangles: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/MouseTrackingEvents/MouseTrackingEvents.html +- Apple Cocoa Event Handling Guide, tracking areas and `cursorUpdate:`: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/TrackingAreaObjects/TrackingAreaObjects.html +- Apple Developer Documentation, global event monitors: https://developer.apple.com/documentation/appkit/nsevent/addglobalmonitorforevents%28matching%3Ahandler:%29 +- Apple Cocoa Event Handling Guide, global monitors cannot modify events and key monitoring needs Accessibility trust: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/MonitoringEvents/MonitoringEvents.html +- Apple Developer Documentation, macOS sandbox outgoing network entitlement: https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.network.client +- Sparkle documentation, programmatic updater setup and user-controlled update checks: https://sparkle-project.org/documentation/programmatic-setup/ diff --git a/apps/macos/README.md b/apps/macos/README.md index 1743234..4dd5826 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -14,6 +14,7 @@ For the app-side production checklist, proof gates, and release blockers, see - **Outline Mode**: Add a contrasting outline that adapts to the background - **Menu Bar App**: Quick access to settings from the menu bar - **Launch at Login**: Optionally start with your Mac +- **Explicit Update Checks**: Settings-gated release checks; no internet access until the user allows it - **Pointer Scope Status**: Shows whether this build enables any broader pointer replacement capability - **Multi-Monitor Support**: Handles different DPI scales and refresh rates per display - **Crash Recovery**: Tracks app session state and recovers cleanly after unexpected termination @@ -44,6 +45,7 @@ For release-candidate proof, use the repo gates instead of ad hoc notarization: ```bash make setup-notary-profile NOTARY_PROFILE="" make notary-profile-check NOTARY_PROFILE="" +make release-source-state-check make release-candidate SIGN_IDENTITY="" NOTARY_PROFILE="" make release-artifact-readiness NOTARY_PROFILE="" make release-readiness NOTARY_PROFILE="" @@ -74,6 +76,7 @@ Click the cursor icon in the menu bar to: - **Sampling Rate**: Background detection frequency (15-120 Hz) - **Pointer Scope**: Shows whether broader pointer replacement is enabled in this build - **Launch at Login**: Start automatically with macOS +- **Updates**: Allow internet access for update checks, then manually check verified release metadata ## Quick Start (Personal Use) @@ -230,13 +233,20 @@ the signed, notarized release checklist before any stable download claim. Cursor Designer requires: - **Screen Recording**: To sample background colors (System Settings → Privacy & Security → Screen Recording) +- **Internet Access for Update Checks**: Off by default. The app only checks release metadata after you enable update-check internet access in Settings and press Check for Updates. - **Administrator Access**: Not required for the current app behavior. Do not grant admin access unless a future release clearly enables and explains a supported helper capability. The app may remember the last-known Screen Recording and Accessibility posture for continuity and diagnostics, but live macOS permission checks always decide what the app can do now. -No data is collected or transmitted. All processing happens locally. +No pointer data, settings, telemetry, or analytics are collected or transmitted. +All cursor processing happens locally. Update checks contact verified release +metadata only after explicit Settings consent. + +The current AppKit cursor path is best-effort outside Cursor Designer's own +windows. For the research-backed persistence boundary and least-permission +supervisor direction, see [`POINTER_PERSISTENCE_RESEARCH.md`](POINTER_PERSISTENCE_RESEARCH.md). ## Troubleshooting diff --git a/apps/macos/RELEASE_RUNBOOK.md b/apps/macos/RELEASE_RUNBOOK.md index c2a05ce..3878ba8 100644 --- a/apps/macos/RELEASE_RUNBOOK.md +++ b/apps/macos/RELEASE_RUNBOOK.md @@ -80,12 +80,16 @@ From `apps/macos`, run: ```bash make signing-identity-check SIGN_IDENTITY="" +make release-source-state-check make release-candidate \ SIGN_IDENTITY="" \ NOTARY_PROFILE="" make release-readiness NOTARY_PROFILE="" ``` +`make release-source-state-check` keeps public release proof tied to a clean +committed tree. In `make release-readiness`, that same guard also requires the +app executable and DMG to be newer than the commit being certified. `make release-candidate` builds, signs, notarizes, staples, and runs artifact readiness. `make release-readiness` additionally verifies stable GitHub release metadata, so it must remain red until a stable GitHub release exists and its @@ -148,6 +152,7 @@ passes on the same commit and DMG. Stop and do not publish if any of these are true: - Gatekeeper rejects the app or DMG. +- The release tree has uncommitted or untracked files. - The DMG is not notarized or stapled. - The stable GitHub release digest does not match the local DMG. - Manual Screen Recording, persistence, drag-install, or local-first checks are diff --git a/apps/macos/REQUIREMENTS.md b/apps/macos/REQUIREMENTS.md index 087d0a1..8693c87 100644 --- a/apps/macos/REQUIREMENTS.md +++ b/apps/macos/REQUIREMENTS.md @@ -17,8 +17,10 @@ either verified by live evidence or explicitly marked blocked. | APP-4 | Make dynamic contrast honest with and without Screen Recording permission. | `./scripts/check-app-ui-contract.sh`; `swift test --package-path apps/macos --filter CursorStateControllerTests`; Preferences UI must show active, inactive, or permission-required state. | Controller and Preferences contract verified; real permission flow still needs release-candidate manual proof. | | APP-5 | Hide, disable, or mark unsupported helper and system-wide replacement paths unavailable. | `./scripts/check-app-ui-contract.sh`; `swift test --package-path apps/macos --filter IdentityTests`; `swift test --package-path apps/macos --filter CursorStateControllerTests`; `./scripts/check-monorepo-references.sh` | Locally verified; system-wide replacement remains unsupported. | | APP-6 | Produce a validated app bundle and DMG from the repo-local macOS package. | `make preflight`; `make dmg`; `make dmg-install-check`; `make dmg-artifact-match-check` | Locally verified when the gates pass on the candidate artifact. Public artifact gates additionally verify the mounted DMG app matches the release app under assessment. | -| APP-7 | Verify app signing, DMG signing, hardened runtime, Gatekeeper acceptance, notarization, release metadata, manual release evidence, and install instructions before public distribution. | `make signing-identity-check`; `make setup-notary-profile`; `make notary-profile-check`; `make signed-dmg`; `make release-artifact-readiness`; `make release-readiness`; `make release-metadata-check`; `make manual-release-evidence-check`; `make north-star-audit` | Signing identity, app signing, hardened runtime, mounted app identity/version/executable match, mounted app signature, and DMG signature are locally verified when `make signed-dmg` and `make release-artifact-readiness` reach those checks; public distribution remains blocked until notarization credentials/profile, stapled notarization, Gatekeeper acceptance, manual release evidence, and stable release metadata exist. `make setup-notary-profile` creates the private Keychain credential lane, `make notary-profile-check` verifies it, and `make release-metadata-check` verifies the stable release tag matches app version before comparing the DMG SHA-256 digest. `make north-star-audit` fails until both `make release-readiness` and manual evidence validation pass. | +| APP-7 | Verify app signing, DMG signing, hardened runtime, Gatekeeper acceptance, notarization, release metadata, clean committed release source state, manual release evidence, and install instructions before public distribution. | `make signing-identity-check`; `make release-source-state-check`; `make setup-notary-profile`; `make notary-profile-check`; `make signed-dmg`; `make release-artifact-readiness`; `make release-readiness`; `make release-metadata-check`; `make manual-release-evidence-check`; `make north-star-audit` | Signing identity, app signing, hardened runtime, mounted app identity/version/executable match, mounted app signature, and DMG signature are locally verified when `make signed-dmg` and `make release-artifact-readiness` reach those checks; public distribution remains blocked until the release source tree is clean and committed, the app executable and DMG are rebuilt after that commit, notarization credentials/profile, stapled notarization, Gatekeeper acceptance, manual release evidence, and stable release metadata exist. `make setup-notary-profile` creates the private Keychain credential lane, `make notary-profile-check` verifies it, and `make release-metadata-check` verifies the stable release tag matches app version before comparing the DMG SHA-256 digest. `make north-star-audit` fails until both `make release-readiness` and manual evidence validation pass. | | APP-8 | Keep wrong-product language, premature website surfaces, premature public distribution instructions, telemetry, trackers, surprise network calls, and placeholder release claims out of user-facing surfaces. | `./scripts/check-monorepo-references.sh`; `./scripts/check-website-boundary.sh`; `./scripts/check-distribution-boundary.sh`; `./scripts/check-local-first.sh`; `swift test --package-path apps/macos --filter IdentityTests` | Guarded locally; repeat before release. | +| APP-9 | Keep update checks explicit, user-initiated, and internet-gated from Settings. | `swift test --package-path apps/macos --filter UpdateCheckerTests`; `swift test --package-path apps/macos --filter CursorSettingsTests`; `swift test --package-path apps/macos --filter CursorStateControllerTests`; `./scripts/check-local-first.sh`; `./scripts/check-app-ui-contract.sh` | Update metadata checks are allowed only through the reviewed `UpdateChecker` path after the user enables internet access for update checks. | +| APP-10 | Research and implement pointer persistence as a least-permission capability, not as an unsupported helper claim. | `POINTER_PERSISTENCE_RESEARCH.md`; `CursorEngine.swift`; `HelperToolManager.swift`; future supervisor tests before any cross-app persistence claim | Research documented. `CursorEngine` now has a least-permission reapply supervisor for mouse/app activation resets; true system-wide replacement remains blocked until a stronger verified supervisor or supported helper path ships. | ## Release-Candidate Proof @@ -45,6 +47,7 @@ For public distribution, add the signed/notarized artifact gates: (cd apps/macos && make setup-notary-profile NOTARY_PROFILE="") (cd apps/macos && make notary-profile-check NOTARY_PROFILE="") (cd apps/macos && make signing-identity-check SIGN_IDENTITY="") +(cd apps/macos && make release-source-state-check) (cd apps/macos && make sign SIGN_IDENTITY="") (cd apps/macos && make create-dmg) (cd apps/macos && make sign-dmg SIGN_IDENTITY="") @@ -87,6 +90,16 @@ Accessibility posture for continuity and diagnostics. Live macOS permission checks remain authoritative; persisted permission posture must not be presented as a permanent grant. +Internet access for update checks defaults off. Cursor Designer may contact +verified release metadata only after the user enables update-check internet +access in Settings and initiates a check. + +Cursor persistence across other applications is not proven by `NSCursor.set()` +alone. The research note in `POINTER_PERSISTENCE_RESEARCH.md` is the current +design boundary: prefer a least-permission supervisor before considering a +privileged helper, and keep private/SIP-dependent cursor replacement out of the +release path. + The app must stay explicitly not-ready for broad distribution while any of these are true: @@ -96,10 +109,16 @@ these are true: supported pointer capability exists. - The DMG is unsigned, unstapled, unnotarized, or rejected by Gatekeeper. - notarytool profile credentials are missing or notarization fails. +- The release source tree has uncommitted or untracked files, or the app/DMG + artifact is older than the commit being certified, while a public release + gate is being used to certify an artifact. - There is no verified stable GitHub release metadata with a tag matching the app version and a SHA-256 digest matching the local DMG for public downloads. - Homebrew install instructions or casks are not backed by a verified stable artifact. +- Update checks run without explicit Settings consent. +- Cross-application pointer persistence is claimed without a verified + least-permission supervisor or supported helper path. ## Documentation Rule diff --git a/apps/macos/Scripts/manual-release-evidence-check.sh b/apps/macos/Scripts/manual-release-evidence-check.sh index 15f2d37..e5f3fdd 100755 --- a/apps/macos/Scripts/manual-release-evidence-check.sh +++ b/apps/macos/Scripts/manual-release-evidence-check.sh @@ -77,7 +77,7 @@ required_fields=( "App build:" "App executable SHA-256:" "make release-readiness:" - "spctl --assess --type open --verbose=4 CursorDesigner.dmg:" + "spctl --assess --type open --context context:primary-signature --verbose=4 CursorDesigner.dmg:" "xcrun stapler validate CursorDesigner.dmg:" "APP-1 menu bar launch:" "APP-2 persistence after quit/relaunch:" @@ -94,7 +94,7 @@ required_fields=( observed_fields=( "make release-readiness:" - "spctl --assess --type open --verbose=4 CursorDesigner.dmg:" + "spctl --assess --type open --context context:primary-signature --verbose=4 CursorDesigner.dmg:" "xcrun stapler validate CursorDesigner.dmg:" "APP-1 menu bar launch:" "APP-2 persistence after quit/relaunch:" diff --git a/apps/macos/Scripts/manual-release-evidence-template.sh b/apps/macos/Scripts/manual-release-evidence-template.sh index 00c9025..caea4de 100755 --- a/apps/macos/Scripts/manual-release-evidence-template.sh +++ b/apps/macos/Scripts/manual-release-evidence-template.sh @@ -109,7 +109,7 @@ App executable SHA-256: $APP_EXECUTABLE_SHA256 Machine gates: - make release-readiness: -- spctl --assess --type open --verbose=4 CursorDesigner.dmg: +- spctl --assess --type open --context context:primary-signature --verbose=4 CursorDesigner.dmg: - xcrun stapler validate CursorDesigner.dmg: Manual observations: diff --git a/apps/macos/Scripts/north-star-audit.sh b/apps/macos/Scripts/north-star-audit.sh index 0543e87..67722a2 100755 --- a/apps/macos/Scripts/north-star-audit.sh +++ b/apps/macos/Scripts/north-star-audit.sh @@ -79,7 +79,7 @@ echo "- APP-3 Negative preset and custom color: apps/macos/REQUIREMENTS.md -> ch echo "- APP-4 dynamic contrast permission truth: apps/macos/REQUIREMENTS.md -> check-app-ui-contract.sh, CursorStateControllerTests, MANUAL_RELEASE_CHECKS.md" echo "- APP-5 unsupported helper and system-wide replacement unavailable: apps/macos/REQUIREMENTS.md -> check-app-ui-contract.sh, IdentityTests, CursorStateControllerTests" echo "- APP-6 app bundle, DMG, and mounted app match: apps/macos/REQUIREMENTS.md -> make preflight, make dmg, make dmg-install-check, make dmg-artifact-match-check" -echo "- APP-7 signing, notarization, Gatekeeper, release metadata, install instructions, optional Homebrew/cask truth, and artifact-bound human evidence: apps/macos/REQUIREMENTS.md -> make setup-notary-profile, make notary-profile-check, make release-readiness, make release-metadata-check, manual-release-evidence-check.sh" +echo "- APP-7 signing, notarization, Gatekeeper, release metadata, install instructions, optional Homebrew/cask truth, committed release source state, and artifact-bound human evidence: apps/macos/REQUIREMENTS.md -> make setup-notary-profile, make notary-profile-check, make release-source-state-check, make release-readiness, make release-metadata-check, manual-release-evidence-check.sh" echo " Manual evidence must match the same DMG digest, commit, release tag, mounted app bundle ID, app version, app build, and app executable SHA-256." echo "- APP-8 local-first, website-boundary, and distribution-boundary product truth: apps/macos/REQUIREMENTS.md -> check-monorepo-references.sh, check-website-boundary.sh, check-distribution-boundary.sh, check-local-first.sh, IdentityTests" echo "- Website: NORTH_STAR.md -> No canonical Cursor Designer website exists; future Leptos/Cloudflare work is limited to static-first Leptos UI, Cloudflare edge delivery, verified release metadata reads, digest display, compatibility notes, and privacy-preserving download routing." diff --git a/apps/macos/Scripts/release-readiness.sh b/apps/macos/Scripts/release-readiness.sh index 9367f96..7d644e0 100755 --- a/apps/macos/Scripts/release-readiness.sh +++ b/apps/macos/Scripts/release-readiness.sh @@ -114,6 +114,10 @@ print_next_required_proof() { echo "- Publish a stable GitHub release whose tag matches this app version and its SHA-256 digest matches this local DMG." >&2 fi + if has_failure "Release source tree is clean"; then + echo "- Commit the release tranche, rebuild/sign/notarize the DMG from that commit, then rerun release-readiness." >&2 + fi + echo "- After this gate passes, complete MANUAL_RELEASE_CHECKS.md against the same Gatekeeper-accepted DMG." >&2 } @@ -168,6 +172,9 @@ if [[ ! -f "$DMG_PATH" ]]; then record_failure "DMG exists" fi +run_check "Release source tree is clean" \ + "$SCRIPT_DIR/release-source-state-check.sh" --app "$APP_PATH" --dmg "$DMG_PATH" + if [[ -d "$APP_PATH" ]]; then run_check "Code signature verifies" \ codesign --verify --deep --strict --verbose=2 "$APP_PATH" diff --git a/apps/macos/Scripts/release-source-state-check.sh b/apps/macos/Scripts/release-source-state-check.sh new file mode 100755 index 0000000..6cb1ebd --- /dev/null +++ b/apps/macos/Scripts/release-source-state-check.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +APP_PATH="" +DMG_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --app) + if [[ $# -lt 2 || "$2" == --* ]]; then + echo "ERROR: --app requires a path" >&2 + exit 2 + fi + APP_PATH="$2" + shift 2 + ;; + --dmg) + if [[ $# -lt 2 || "$2" == --* ]]; then + echo "ERROR: --dmg requires a path" >&2 + exit 2 + fi + DMG_PATH="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [--app PATH] [--dmg PATH]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +if ! git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "ERROR: release source state check must run inside a git work tree" >&2 + exit 2 +fi + +head_commit="$(git -C "$ROOT_DIR" rev-parse HEAD)" +head_time="$(git -C "$ROOT_DIR" show -s --format=%ct HEAD)" +dirty_status="$(git -C "$ROOT_DIR" status --porcelain=v1 --untracked-files=all -- .)" + +echo "Release commit: $head_commit" + +if [[ -n "$dirty_status" ]]; then + echo "ERROR: release readiness requires a clean committed tree." >&2 + echo "The signed app and DMG must be built from the same committed state that manual release evidence records." >&2 + echo "" >&2 + echo "Uncommitted or untracked files:" >&2 + printf '%s\n' "$dirty_status" >&2 + echo "" >&2 + echo "Commit the release tranche, rebuild/sign/notarize the DMG from that commit, then rerun release-readiness." >&2 + exit 1 +fi + +if [[ -n "$APP_PATH" ]]; then + app_executable="$APP_PATH/Contents/MacOS/PointerDesigner" + + if [[ ! -x "$app_executable" ]]; then + echo "ERROR: release app executable not found or not executable: $app_executable" >&2 + exit 1 + fi + + app_time="$(stat -f %m "$app_executable")" + if (( app_time <= head_time )); then + echo "ERROR: release app executable is not newer than the release commit." >&2 + echo "Rebuild the app from HEAD before running release readiness." >&2 + exit 1 + fi +fi + +if [[ -n "$DMG_PATH" ]]; then + if [[ ! -f "$DMG_PATH" ]]; then + echo "ERROR: release DMG not found: $DMG_PATH" >&2 + exit 1 + fi + + dmg_time="$(stat -f %m "$DMG_PATH")" + if (( dmg_time <= head_time )); then + echo "ERROR: release DMG is not newer than the release commit." >&2 + echo "Recreate, sign, notarize, and staple the DMG from HEAD before running release readiness." >&2 + exit 1 + fi +fi + +echo "Release source tree is clean." diff --git a/apps/macos/Sources/PointerDesigner/PreferencesWindowController.swift b/apps/macos/Sources/PointerDesigner/PreferencesWindowController.swift index 4bfa973..2f3f1ba 100644 --- a/apps/macos/Sources/PointerDesigner/PreferencesWindowController.swift +++ b/apps/macos/Sources/PointerDesigner/PreferencesWindowController.swift @@ -161,6 +161,9 @@ final class PreferencesView: NSView { private var permissionButton: NSButton? private var helperStatusLabel: NSTextField? private var helperButton: NSButton? + private var internetUpdateCheckbox: NSButton? + private var checkForUpdatesButton: NSButton? + private var updateStatusLabel: NSTextField? // Use CursorStateController for business logic private let stateController: CursorStateController @@ -411,6 +414,34 @@ final class PreferencesView: NSView { launchAtLoginCheckbox = loginCheckbox stackView.addArrangedSubview(loginCheckbox) + let updateSection = createSection(title: "Updates") + let internetCheck = NSButton(checkboxWithTitle: "Allow internet access for update checks", target: self, action: #selector(internetUpdateAccessChanged)) + internetCheck.setAccessibilityLabel("Allow Internet Access for Update Checks") + internetCheck.setAccessibilityHelp("Permit Cursor Designer to contact GitHub only when you check for updates.") + internetUpdateCheckbox = internetCheck + updateSection.addArrangedSubview(internetCheck) + + let updateRow = NSStackView() + updateRow.orientation = .horizontal + updateRow.spacing = 10 + + let updateButton = NSButton(title: "Check for Updates", target: self, action: #selector(checkForUpdates)) + updateButton.bezelStyle = .rounded + updateButton.controlSize = .small + updateButton.setAccessibilityLabel("Check for Updates") + updateButton.setAccessibilityHelp("Check the verified GitHub release metadata after internet access is allowed.") + checkForUpdatesButton = updateButton + + let updateStatus = NSTextField(labelWithString: "Update checks are local-off until internet access is allowed.") + updateStatus.font = NSFont.systemFont(ofSize: 10) + updateStatus.textColor = .secondaryLabelColor + updateStatusLabel = updateStatus + + updateRow.addArrangedSubview(updateButton) + updateRow.addArrangedSubview(updateStatus) + updateSection.addArrangedSubview(updateRow) + stackView.addArrangedSubview(updateSection) + // Reset Button let resetButton = NSButton(title: "Reset to Defaults", target: self, action: #selector(resetToDefaults)) // Edge case #69 & #70: Accessibility and keyboard shortcut @@ -490,6 +521,8 @@ final class PreferencesView: NSView { outlineWidthSlider?.doubleValue = Double(settings.outlineWidth) samplingRateSlider?.doubleValue = Double(settings.samplingRate) launchAtLoginCheckbox?.state = stateController.isLaunchAtLoginEnabled ? .on : .off + internetUpdateCheckbox?.state = settings.allowsInternetUpdateChecks ? .on : .off + updateUpdateCheckStatus() // Update permission status updatePermissionStatus() @@ -641,6 +674,66 @@ final class PreferencesView: NSView { stateController.setLaunchAtLogin(enabled) } + @objc private func internetUpdateAccessChanged() { + let allowed = internetUpdateCheckbox?.state == .on + stateController.setAllowsInternetUpdateChecks(allowed) + updateUpdateCheckStatus() + } + + @objc private func checkForUpdates() { + guard stateController.currentSettings.allowsInternetUpdateChecks else { + updateStatusLabel?.stringValue = "Enable internet access before checking for updates." + updateStatusLabel?.textColor = .systemOrange + return + } + + checkForUpdatesButton?.isEnabled = false + updateStatusLabel?.stringValue = "Checking verified release metadata..." + updateStatusLabel?.textColor = .secondaryLabelColor + + UpdateChecker.shared.checkLatestRelease( + allowsInternetAccess: stateController.currentSettings.allowsInternetUpdateChecks, + currentVersion: Self.currentAppVersion + ) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.checkForUpdatesButton?.isEnabled = true + + switch result { + case .success(.updateAvailable(let info)): + self.stateController.recordUpdateCheck(latestReleaseTag: info.tagName) + self.updateStatusLabel?.stringValue = "Update available: \(info.tagName). Opening release page..." + self.updateStatusLabel?.textColor = .systemGreen + NSWorkspace.shared.open(info.releaseURL) + case .success(.upToDate(let info)): + self.stateController.recordUpdateCheck(latestReleaseTag: info.tagName) + self.updateStatusLabel?.stringValue = "Up to date. Latest release: \(info.tagName)." + self.updateStatusLabel?.textColor = .secondaryLabelColor + case .failure(let error): + self.updateStatusLabel?.stringValue = error.localizedDescription + self.updateStatusLabel?.textColor = .systemOrange + } + } + } + } + + private func updateUpdateCheckStatus() { + let settings = stateController.currentSettings + checkForUpdatesButton?.isEnabled = settings.allowsInternetUpdateChecks + + if settings.allowsInternetUpdateChecks { + if let tag = settings.lastKnownLatestReleaseTag { + updateStatusLabel?.stringValue = "Internet allowed for update checks. Last seen release: \(tag)." + } else { + updateStatusLabel?.stringValue = "Internet allowed for user-initiated update checks." + } + updateStatusLabel?.textColor = .secondaryLabelColor + } else { + updateStatusLabel?.stringValue = "Update checks are local-off until internet access is allowed." + updateStatusLabel?.textColor = .secondaryLabelColor + } + } + @objc private func resetToDefaults() { stateController.resetToDefaults() loadSettings() @@ -678,6 +771,10 @@ final class PreferencesView: NSView { } } + private static var currentAppVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + } + // Edge case #55: Refresh UI when settings change externally @objc private func settingsDidChange(_ notification: Notification) { DispatchQueue.main.async { [weak self] in diff --git a/apps/macos/Sources/PointerDesignerCore/CursorEngine.swift b/apps/macos/Sources/PointerDesignerCore/CursorEngine.swift index 2788acd..d3b3d5f 100644 --- a/apps/macos/Sources/PointerDesignerCore/CursorEngine.swift +++ b/apps/macos/Sources/PointerDesignerCore/CursorEngine.swift @@ -24,15 +24,18 @@ public final class CursorEngine: CursorService { @Atomic private var lastActivityTime: CFAbsoluteTime = 0 @Atomic private var movementThreshold: CGFloat = 2.0 @Atomic private var lastCursorUpdateTime: CFAbsoluteTime = 0 + @Atomic private var lastSupervisorReapplyTime: CFAbsoluteTime = 0 @Atomic private var currentRefreshRate: Double = 60.0 // Main thread only private var lastAppliedCursor: NSCursor? private var idleTimer: Timer? + private var globalMouseMonitor: Any? // Constants private let idleThreshold: TimeInterval = 5.0 // seconds private let minUpdateInterval: TimeInterval = 1.0 / 120.0 // Max 120 updates/sec + private let supervisorMinReapplyInterval: TimeInterval = 1.0 / 15.0 // Thread safety private let updateQueue = DispatchQueue(label: Identity.cursorEngineQueueLabel, qos: .userInteractive) @@ -111,6 +114,7 @@ public final class CursorEngine: CursorService { } startIdleTimer() + startPersistenceSupervisor() NSLog("CursorEngine: Calling initial applyCursor()") applyCursor() } @@ -121,6 +125,7 @@ public final class CursorEngine: CursorService { isRunning = false stopIdleTimer() + stopPersistenceSupervisor() releaseDisplayLink() restoreSystemCursor() } @@ -212,6 +217,13 @@ public final class CursorEngine: CursorService { object: nil ) + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleWorkspaceAppActivation), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + // Edge case #32, #42: Handle sleep/wake NSWorkspace.shared.notificationCenter.addObserver( self, @@ -271,6 +283,7 @@ public final class CursorEngine: CursorService { if isRunning, let displayLink = displayLink { CVDisplayLinkStart(displayLink) } + reapplyCursorFromSupervisor(reason: "app became active") } @objc private func handleAppDidResignActive(_ notification: Notification) { @@ -278,6 +291,10 @@ public final class CursorEngine: CursorService { // Keep running but at reduced rate } + @objc private func handleWorkspaceAppActivation(_ notification: Notification) { + reapplyCursorFromSupervisor(reason: "workspace app activation") + } + @objc private func handleSleepNotification(_ notification: Notification) { // Edge case #32, #42: Stop before sleep releaseDisplayLink() @@ -312,6 +329,48 @@ public final class CursorEngine: CursorService { idleTimer = nil } + private func startPersistenceSupervisor() { + stopPersistenceSupervisor() + + let mask: NSEvent.EventTypeMask = [ + .mouseMoved, + .leftMouseDragged, + .rightMouseDragged, + .otherMouseDragged + ] + + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] _ in + self?.reapplyCursorFromSupervisor(reason: "global mouse event") + } + } + + private func stopPersistenceSupervisor() { + if let monitor = globalMouseMonitor { + NSEvent.removeMonitor(monitor) + globalMouseMonitor = nil + } + } + + private func reapplyCursorFromSupervisor(reason: String) { + guard isRunning else { return } + + let now = CFAbsoluteTimeGetCurrent() + guard now - lastSupervisorReapplyTime >= supervisorMinReapplyInterval else { return } + lastSupervisorReapplyTime = now + + DispatchQueue.main.async { [weak self] in + guard let self = self, self.isRunning else { return } + + if let cursor = self.lastAppliedCursor { + cursor.set() + } else { + self.applyCursor() + } + + NSLog("CursorEngine: persistence supervisor reapplied cursor (%@)", reason) + } + } + private func checkIdle() { let now = CFAbsoluteTimeGetCurrent() let idleTime = now - lastActivityTime diff --git a/apps/macos/Sources/PointerDesignerCore/CursorSettings.swift b/apps/macos/Sources/PointerDesignerCore/CursorSettings.swift index 487a0a2..42bd11e 100644 --- a/apps/macos/Sources/PointerDesignerCore/CursorSettings.swift +++ b/apps/macos/Sources/PointerDesignerCore/CursorSettings.swift @@ -264,7 +264,7 @@ public struct CursorColor: Codable, Equatable, Sendable { /// Fixes edge cases: #45 (corrupted data), #47 (migration), #48 (zero sampling), #49 (zero outline) public struct CursorSettings: Codable, Equatable, Sendable { /// Schema version for migration support (edge case #47) - public static let currentSchemaVersion = 3 + public static let currentSchemaVersion = 4 public var schemaVersion: Int public var isEnabled: Bool @@ -276,6 +276,9 @@ public struct CursorSettings: Codable, Equatable, Sendable { public var launchAtLogin: Bool public var lastKnownScreenRecordingPermission: Bool? public var lastKnownAccessibilityPermission: Bool? + public var allowsInternetUpdateChecks: Bool + public var lastUpdateCheckDate: Date? + public var lastKnownLatestReleaseTag: String? // Advanced settings public var brightnessThreshold: Float // Edge case #14: configurable threshold @@ -301,6 +304,9 @@ public struct CursorSettings: Codable, Equatable, Sendable { launchAtLogin: Bool = false, lastKnownScreenRecordingPermission: Bool? = nil, lastKnownAccessibilityPermission: Bool? = nil, + allowsInternetUpdateChecks: Bool = false, + lastUpdateCheckDate: Date? = nil, + lastKnownLatestReleaseTag: String? = nil, brightnessThreshold: Float = 0.5, hysteresis: Float = 0.1, adaptiveScaling: Bool = true, @@ -323,6 +329,9 @@ public struct CursorSettings: Codable, Equatable, Sendable { self.launchAtLogin = launchAtLogin self.lastKnownScreenRecordingPermission = lastKnownScreenRecordingPermission self.lastKnownAccessibilityPermission = lastKnownAccessibilityPermission + self.allowsInternetUpdateChecks = allowsInternetUpdateChecks + self.lastUpdateCheckDate = lastUpdateCheckDate + self.lastKnownLatestReleaseTag = lastKnownLatestReleaseTag self.brightnessThreshold = max(0.1, min(0.9, brightnessThreshold)) self.hysteresis = max(0.01, min(0.2, hysteresis)) self.adaptiveScaling = adaptiveScaling @@ -384,6 +393,9 @@ public struct CursorSettings: Codable, Equatable, Sendable { self.launchAtLogin = (try? container.decode(Bool.self, forKey: .launchAtLogin)) ?? false self.lastKnownScreenRecordingPermission = try? container.decode(Bool.self, forKey: .lastKnownScreenRecordingPermission) self.lastKnownAccessibilityPermission = try? container.decode(Bool.self, forKey: .lastKnownAccessibilityPermission) + self.allowsInternetUpdateChecks = (try? container.decode(Bool.self, forKey: .allowsInternetUpdateChecks)) ?? false + self.lastUpdateCheckDate = try? container.decode(Date.self, forKey: .lastUpdateCheckDate) + self.lastKnownLatestReleaseTag = try? container.decode(String.self, forKey: .lastKnownLatestReleaseTag) self.brightnessThreshold = (try? container.decode(Float.self, forKey: .brightnessThreshold)) ?? 0.5 self.hysteresis = (try? container.decode(Float.self, forKey: .hysteresis)) ?? 0.1 self.adaptiveScaling = (try? container.decode(Bool.self, forKey: .adaptiveScaling)) ?? true @@ -419,6 +431,11 @@ public struct CursorSettings: Codable, Equatable, Sendable { self.lastKnownScreenRecordingPermission = nil self.lastKnownAccessibilityPermission = nil } + if version < 4 { + self.allowsInternetUpdateChecks = false + self.lastUpdateCheckDate = nil + self.lastKnownLatestReleaseTag = nil + } self.schemaVersion = Self.currentSchemaVersion } @@ -426,6 +443,7 @@ public struct CursorSettings: Codable, Equatable, Sendable { case schemaVersion, isEnabled, cursorColor, contrastMode case outlineWidth, outlineColor, samplingRate, launchAtLogin case lastKnownScreenRecordingPermission, lastKnownAccessibilityPermission + case allowsInternetUpdateChecks, lastUpdateCheckDate, lastKnownLatestReleaseTag case brightnessThreshold, hysteresis, adaptiveScaling case preset, glowEnabled, glowColor, glowRadius, shadowEnabled, cursorScale } diff --git a/apps/macos/Sources/PointerDesignerCore/CursorStateController.swift b/apps/macos/Sources/PointerDesignerCore/CursorStateController.swift index c4f5f00..166e0bb 100644 --- a/apps/macos/Sources/PointerDesignerCore/CursorStateController.swift +++ b/apps/macos/Sources/PointerDesignerCore/CursorStateController.swift @@ -232,6 +232,21 @@ public final class CursorStateController: ObservableObject { return result } + /// Allow or deny network access for user-initiated update checks. + public func setAllowsInternetUpdateChecks(_ allowed: Bool) { + var settings = currentSettings + settings.allowsInternetUpdateChecks = allowed + updateSettings(settings) + } + + /// Record the last release tag observed by the user-initiated update checker. + public func recordUpdateCheck(latestReleaseTag: String, checkedAt date: Date = Date()) { + var settings = currentSettings + settings.lastKnownLatestReleaseTag = latestReleaseTag + settings.lastUpdateCheckDate = date + updateSettings(settings) + } + /// Refresh permission state public func refreshPermissionState() { hasScreenRecordingPermission = permissionService.hasScreenRecordingPermission diff --git a/apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift b/apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift new file mode 100644 index 0000000..ac9925c --- /dev/null +++ b/apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct AppUpdateInfo: Equatable, Sendable { + public let tagName: String + public let releaseURL: URL + public let publishedAt: Date? + + public init(tagName: String, releaseURL: URL, publishedAt: Date?) { + self.tagName = tagName + self.releaseURL = releaseURL + self.publishedAt = publishedAt + } +} + +public enum UpdateCheckResult: Equatable, Sendable { + case upToDate(AppUpdateInfo) + case updateAvailable(AppUpdateInfo) +} + +public enum UpdateCheckError: Error, Equatable, LocalizedError, Sendable { + case internetAccessNotAllowed + case invalidReleaseURL + case invalidResponse + case network(String) + + public var errorDescription: String? { + switch self { + case .internetAccessNotAllowed: + return "Internet access for update checks is disabled in Cursor Designer settings." + case .invalidReleaseURL: + return "Cursor Designer could not build the release metadata URL." + case .invalidResponse: + return "Cursor Designer could not read release metadata." + case .network(let message): + return "Update check failed: \(message)" + } + } +} + +public final class UpdateChecker { + public static let shared = UpdateChecker() + + private let repository = "rogu3bear/macOS-pointer-designer" + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func checkLatestRelease( + allowsInternetAccess: Bool, + currentVersion: String, + completion: @escaping (Result) -> Void + ) { + guard allowsInternetAccess else { + completion(.failure(.internetAccessNotAllowed)) + return + } + + guard let url = URL(string: "https://api.github.com/repos/\(repository)/releases/latest") else { + completion(.failure(.invalidReleaseURL)) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("CursorDesigner/\(currentVersion)", forHTTPHeaderField: "User-Agent") + + session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(.network(error.localizedDescription))) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode), + let data = data else { + completion(.failure(.invalidResponse)) + return + } + + do { + let release = try JSONDecoder.githubReleaseDecoder.decode(GitHubRelease.self, from: data) + guard let releaseURL = URL(string: release.htmlURL) else { + completion(.failure(.invalidResponse)) + return + } + + let info = AppUpdateInfo( + tagName: release.tagName, + releaseURL: releaseURL, + publishedAt: release.publishedAt + ) + + if Self.isRelease(release.tagName, newerThan: currentVersion) { + completion(.success(.updateAvailable(info))) + } else { + completion(.success(.upToDate(info))) + } + } catch { + completion(.failure(.invalidResponse)) + } + }.resume() + } + + public static func isRelease(_ tagName: String, newerThan currentVersion: String) -> Bool { + let releaseParts = versionParts(from: tagName) + let currentParts = versionParts(from: currentVersion) + + for index in 0.. current + } + } + + return false + } + + private static func versionParts(from value: String) -> [Int] { + let normalized = value.trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + return normalized + .split(separator: ".") + .map { part in + let numericPrefix = part.prefix { $0.isNumber } + return Int(numericPrefix) ?? 0 + } + } +} + +private struct GitHubRelease: Decodable { + let tagName: String + let htmlURL: String + let publishedAt: Date? + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlURL = "html_url" + case publishedAt = "published_at" + } +} + +private extension JSONDecoder { + static var githubReleaseDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/apps/macos/Tests/PointerDesignerTests/CursorSettingsTests.swift b/apps/macos/Tests/PointerDesignerTests/CursorSettingsTests.swift index 29e7803..8b5d1e2 100644 --- a/apps/macos/Tests/PointerDesignerTests/CursorSettingsTests.swift +++ b/apps/macos/Tests/PointerDesignerTests/CursorSettingsTests.swift @@ -15,6 +15,9 @@ final class CursorSettingsTests: XCTestCase { XCTAssertFalse(defaults.launchAtLogin) XCTAssertNil(defaults.lastKnownScreenRecordingPermission) XCTAssertNil(defaults.lastKnownAccessibilityPermission) + XCTAssertFalse(defaults.allowsInternetUpdateChecks) + XCTAssertNil(defaults.lastUpdateCheckDate) + XCTAssertNil(defaults.lastKnownLatestReleaseTag) XCTAssertEqual(defaults.schemaVersion, CursorSettings.currentSchemaVersion) } @@ -119,7 +122,10 @@ final class CursorSettingsTests: XCTestCase { samplingRate: 30, launchAtLogin: true, lastKnownScreenRecordingPermission: true, - lastKnownAccessibilityPermission: false + lastKnownAccessibilityPermission: false, + allowsInternetUpdateChecks: true, + lastUpdateCheckDate: Date(timeIntervalSince1970: 1_000), + lastKnownLatestReleaseTag: "v1.0.1" ) let encoded = try JSONEncoder().encode(original) @@ -131,6 +137,8 @@ final class CursorSettingsTests: XCTestCase { XCTAssertEqual(original.samplingRate, decoded.samplingRate) XCTAssertEqual(original.lastKnownScreenRecordingPermission, decoded.lastKnownScreenRecordingPermission) XCTAssertEqual(original.lastKnownAccessibilityPermission, decoded.lastKnownAccessibilityPermission) + XCTAssertEqual(original.allowsInternetUpdateChecks, decoded.allowsInternetUpdateChecks) + XCTAssertEqual(original.lastKnownLatestReleaseTag, decoded.lastKnownLatestReleaseTag) } func testCorruptedDataFallback() throws { @@ -168,6 +176,19 @@ final class CursorSettingsTests: XCTestCase { XCTAssertEqual(decoded.schemaVersion, CursorSettings.currentSchemaVersion) } + func testUpdateCheckPermissionMigratesFromOlderSettingsAsDisabled() throws { + let v3JSON = """ + {"schemaVersion": 3, "isEnabled": true, "contrastMode": "autoInvert"} + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(CursorSettings.self, from: v3JSON) + + XCTAssertFalse(decoded.allowsInternetUpdateChecks) + XCTAssertNil(decoded.lastUpdateCheckDate) + XCTAssertNil(decoded.lastKnownLatestReleaseTag) + XCTAssertEqual(decoded.schemaVersion, CursorSettings.currentSchemaVersion) + } + // MARK: - Contrast Mode Tests func testContrastModeValues() { diff --git a/apps/macos/Tests/PointerDesignerTests/CursorStateControllerTests.swift b/apps/macos/Tests/PointerDesignerTests/CursorStateControllerTests.swift index ac1b8bd..9b6adfb 100644 --- a/apps/macos/Tests/PointerDesignerTests/CursorStateControllerTests.swift +++ b/apps/macos/Tests/PointerDesignerTests/CursorStateControllerTests.swift @@ -251,6 +251,29 @@ final class CursorStateControllerTests: XCTestCase { XCTAssertFalse(controller.currentSettings.launchAtLogin) } + // MARK: - Update Permission Tests + + func testInternetUpdateChecksDefaultToDisabled() { + XCTAssertFalse(controller.currentSettings.allowsInternetUpdateChecks) + } + + func testSetAllowsInternetUpdateChecksPersistsExplicitConsent() { + controller.setAllowsInternetUpdateChecks(true) + + XCTAssertTrue(controller.currentSettings.allowsInternetUpdateChecks) + XCTAssertTrue(mockSettings.lastSavedSettings?.allowsInternetUpdateChecks ?? false) + } + + func testRecordUpdateCheckPersistsObservedReleaseMetadata() { + let checkedAt = Date(timeIntervalSince1970: 2_000) + + controller.recordUpdateCheck(latestReleaseTag: "v1.0.1", checkedAt: checkedAt) + + XCTAssertEqual(controller.currentSettings.lastKnownLatestReleaseTag, "v1.0.1") + XCTAssertEqual(controller.currentSettings.lastUpdateCheckDate, checkedAt) + XCTAssertEqual(mockSettings.lastSavedSettings?.lastKnownLatestReleaseTag, "v1.0.1") + } + // MARK: - Permission Tests func testRefreshPermissionState() { diff --git a/apps/macos/Tests/PointerDesignerTests/IdentityTests.swift b/apps/macos/Tests/PointerDesignerTests/IdentityTests.swift index 8347028..f02e28d 100644 --- a/apps/macos/Tests/PointerDesignerTests/IdentityTests.swift +++ b/apps/macos/Tests/PointerDesignerTests/IdentityTests.swift @@ -510,6 +510,7 @@ final class IdentityTests: XCTestCase { "make dmg-artifact-match-check", "mounted DMG app must match the release app", "make signing-identity-check", + "make release-source-state-check", "make setup-notary-profile", "make notary-profile-check", "make signed-dmg", @@ -539,6 +540,7 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(rootReadme.contains("Cursor Designer is not advertised as a stable public download yet")) XCTAssertTrue(rootReadme.contains("make setup-notary-profile")) XCTAssertTrue(rootReadme.contains("make notary-profile-check")) + XCTAssertTrue(rootReadme.contains("make release-source-state-check")) XCTAssertTrue(rootReadme.contains("make release-candidate")) XCTAssertTrue(rootReadme.contains("make release-artifact-readiness")) XCTAssertTrue(rootReadme.contains("make release-readiness")) @@ -573,6 +575,7 @@ final class IdentityTests: XCTestCase { "last-known permission posture", "persisted permission posture must not be presented", "make signing-identity-check", + "make release-source-state-check", "make setup-notary-profile", "make notary-profile-check", "make signed-dmg", @@ -640,6 +643,7 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(script.contains("cheap boundary smoke only")) XCTAssertTrue(script.contains("local macOS package, DMG, signing, notarization, permission-flow, and release-evidence gates remain authoritative")) XCTAssertTrue(script.contains("release-readiness")) + XCTAssertTrue(script.contains("release-source-state-check")) XCTAssertTrue(script.contains("setup-notary-profile")) XCTAssertTrue(script.contains("notary-profile-check")) XCTAssertTrue(script.contains("release-metadata-check")) @@ -671,6 +675,8 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(script.contains("command -v rg")) XCTAssertTrue(script.contains("apps/macos/Sources")) XCTAssertTrue(script.contains("URLSession")) + XCTAssertTrue(script.contains("UpdateChecker.swift")) + XCTAssertTrue(script.contains("allowsInternetAccess")) XCTAssertTrue(script.contains("NSURLConnection")) XCTAssertTrue(script.contains("NWConnection")) XCTAssertTrue(script.contains("SentrySDK")) @@ -704,6 +710,8 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(script.contains("Last checked: Screen Recording")) XCTAssertTrue(script.contains("Live macOS permission checks decide features.")) XCTAssertTrue(script.contains("System-wide pointer replacement is not enabled in this build.")) + XCTAssertTrue(script.contains("Allow internet access for update checks")) + XCTAssertTrue(script.contains("Check for Updates")) XCTAssertTrue(script.contains("grep -Fq")) XCTAssertTrue(script.contains("Cursor Designer app UI contract check passed.")) XCTAssertTrue(workflow.contains("./scripts/check-app-ui-contract.sh")) @@ -711,6 +719,43 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(requirements.contains("./scripts/check-app-ui-contract.sh")) } + func testPointerPersistenceResearchDocumentsLeastPermissionBoundary() throws { + let research = try loadText(relativeToThisFile: "../../POINTER_PERSISTENCE_RESEARCH.md") + let cursorEngine = try loadText(relativeToThisFile: "../../Sources/PointerDesignerCore/CursorEngine.swift") + let northStar = try loadText(relativeToThisFile: "../../../../NORTH_STAR.md") + let requirements = try loadText(relativeToThisFile: "../../REQUIREMENTS.md") + + XCTAssertTrue(research.contains("NSCursor.set()")) + XCTAssertTrue(research.contains("least-permission durable path starts with a supervised pointer")) + XCTAssertTrue(research.contains("Screen Recording is only justified for dynamic contrast")) + XCTAssertTrue(research.contains("Accessibility is only justified")) + XCTAssertTrue(research.contains("Private WindowServer/CGS cursor replacement should stay")) + XCTAssertTrue(research.contains("https://developer.apple.com/documentation/appkit/nscursor/set%28%29")) + XCTAssertTrue(research.contains("https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/MouseTrackingEvents/MouseTrackingEvents.html")) + XCTAssertTrue(cursorEngine.contains("startPersistenceSupervisor")) + XCTAssertTrue(cursorEngine.contains("NSEvent.addGlobalMonitorForEvents")) + XCTAssertTrue(cursorEngine.contains("NSWorkspace.didActivateApplicationNotification")) + XCTAssertTrue(northStar.contains("POINTER_PERSISTENCE_RESEARCH.md")) + XCTAssertTrue(requirements.contains("APP-10")) + XCTAssertTrue(requirements.contains("reapply supervisor")) + } + + func testUpdateChecksAreExplicitlyInternetGated() throws { + let preferences = try loadText(relativeToThisFile: "../../Sources/PointerDesigner/PreferencesWindowController.swift") + let settings = try loadText(relativeToThisFile: "../../Sources/PointerDesignerCore/CursorSettings.swift") + let checker = try loadText(relativeToThisFile: "../../Sources/PointerDesignerCore/UpdateChecker.swift") + let northStar = try loadText(relativeToThisFile: "../../../../NORTH_STAR.md") + let requirements = try loadText(relativeToThisFile: "../../REQUIREMENTS.md") + + XCTAssertTrue(preferences.contains("Allow internet access for update checks")) + XCTAssertTrue(preferences.contains("Check for Updates")) + XCTAssertTrue(settings.contains("allowsInternetUpdateChecks")) + XCTAssertTrue(checker.contains("guard allowsInternetAccess else")) + XCTAssertTrue(checker.contains("internetAccessNotAllowed")) + XCTAssertTrue(northStar.contains("Update checks are allowed only")) + XCTAssertTrue(requirements.contains("APP-9")) + } + func testTrustCheckDoesNotClaimCursorApplicationRequiresHelper() throws { let trustCheck = try loadText(relativeToThisFile: "../../Scripts/trust-check.sh") @@ -742,6 +787,7 @@ final class IdentityTests: XCTestCase { "../../Scripts/launch-smoke.sh": "ERROR: --app requires a path", "../../Scripts/setup-notary-profile.sh": "ERROR: --notary-profile requires a name", "../../Scripts/notary-profile-check.sh": "ERROR: --notary-profile requires a name", + "../../Scripts/release-source-state-check.sh": "ERROR: --app requires a path", "../../Scripts/release-metadata-check.sh": "ERROR: --repo requires OWNER/REPO", "../../Scripts/release-readiness.sh": "ERROR: --notary-profile requires a name", "../../Scripts/manual-release-evidence-check.sh": "ERROR: --evidence requires a path", @@ -836,8 +882,11 @@ final class IdentityTests: XCTestCase { func testReleaseReadinessGateChecksSigningAndNotarization() throws { let makefile = try loadText(relativeToThisFile: "../../Makefile") let script = try loadText(relativeToThisFile: "../../Scripts/release-readiness.sh") + let sourceStateCheck = try loadText(relativeToThisFile: "../../Scripts/release-source-state-check.sh") XCTAssertTrue(makefile.contains("release-readiness:")) + XCTAssertTrue(makefile.contains("release-source-state-check:")) + XCTAssertTrue(makefile.contains("release-source-state-check.sh")) XCTAssertTrue(makefile.contains(#"--repo "$(GITHUB_REPO)""#)) XCTAssertTrue(script.contains(#"--dmg "$DMG_PATH""#)) XCTAssertTrue(script.contains("--skip-release-metadata")) @@ -846,6 +895,9 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(script.contains("dmg-install-check.sh")) XCTAssertTrue(script.contains("release-metadata-check.sh")) XCTAssertTrue(script.contains("Stable release metadata matches app version and DMG digest")) + XCTAssertTrue(script.contains("Release source tree is clean")) + XCTAssertTrue(script.contains("release-source-state-check.sh")) + XCTAssertTrue(script.contains("Commit the release tranche, rebuild/sign/notarize the DMG")) XCTAssertTrue(script.contains("--require-signature")) XCTAssertTrue(script.contains("--repo")) XCTAssertTrue(script.contains("codesign --verify --deep --strict")) @@ -872,6 +924,14 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(script.contains(#"output=$("$@" 2>&1)"#)) XCTAssertTrue(script.contains("printf '%s\\n' \"$output\"")) XCTAssertTrue(script.contains("failures=(")) + XCTAssertTrue(sourceStateCheck.contains("git -C \"$ROOT_DIR\" status --porcelain=v1 --untracked-files=all -- .")) + XCTAssertTrue(sourceStateCheck.contains("git -C \"$ROOT_DIR\" show -s --format=%ct HEAD")) + XCTAssertTrue(sourceStateCheck.contains("stat -f %m \"$app_executable\"")) + XCTAssertTrue(sourceStateCheck.contains("stat -f %m \"$DMG_PATH\"")) + XCTAssertTrue(sourceStateCheck.contains("release readiness requires a clean committed tree")) + XCTAssertTrue(sourceStateCheck.contains("The signed app and DMG must be built from the same committed state")) + XCTAssertTrue(sourceStateCheck.contains("release app executable is not newer than the release commit")) + XCTAssertTrue(sourceStateCheck.contains("release DMG is not newer than the release commit")) } func testDMGInstallGateChecksMountedArtifactShape() throws { @@ -986,7 +1046,7 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(checklist.contains("privacy-preserving download routing")) XCTAssertTrue(checklist.contains("## Evidence Record Template")) XCTAssertFalse(checklist.contains("App executable SHA-256:\n shasum -a 256 CursorDesigner.dmg")) - XCTAssertTrue(checklist.contains("spctl --assess --type open --verbose=4 CursorDesigner.dmg")) + XCTAssertTrue(checklist.contains("spctl --assess --type open --context context:primary-signature --verbose=4 CursorDesigner.dmg")) XCTAssertTrue(checklist.contains("xcrun stapler validate CursorDesigner.dmg")) XCTAssertTrue(checklist.contains("Pass/fail")) XCTAssertTrue(checklist.contains("Blocker disposition")) @@ -1053,6 +1113,7 @@ final class IdentityTests: XCTestCase { XCTAssertTrue(runbook.contains("does not expose a profile-list command")) XCTAssertTrue(runbook.contains("exact profile name you created with `store-credentials`")) XCTAssertTrue(runbook.contains("make release-candidate")) + XCTAssertTrue(runbook.contains("make release-source-state-check")) XCTAssertTrue(runbook.contains("make release-readiness")) XCTAssertTrue(runbook.contains("make manual-release-evidence-template")) XCTAssertTrue(runbook.contains("make manual-release-evidence-check")) @@ -1074,6 +1135,7 @@ final class IdentityTests: XCTestCase { let executableScripts = [ "../../Scripts/setup-notary-profile.sh", "../../Scripts/notary-profile-check.sh", + "../../Scripts/release-source-state-check.sh", "../../Scripts/release-readiness.sh", "../../Scripts/north-star-audit.sh", "../../Scripts/manual-release-evidence-check.sh", diff --git a/apps/macos/Tests/PointerDesignerTests/UpdateCheckerTests.swift b/apps/macos/Tests/PointerDesignerTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..bbe31e7 --- /dev/null +++ b/apps/macos/Tests/PointerDesignerTests/UpdateCheckerTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import PointerDesignerCore + +final class UpdateCheckerTests: XCTestCase { + func testVersionComparisonDetectsNewerRelease() { + XCTAssertTrue(UpdateChecker.isRelease("v1.0.1", newerThan: "1.0.0")) + XCTAssertTrue(UpdateChecker.isRelease("v2.0.0", newerThan: "1.9.9")) + } + + func testVersionComparisonDoesNotTreatSameOrOlderReleaseAsNewer() { + XCTAssertFalse(UpdateChecker.isRelease("v1.0.0", newerThan: "1.0.0")) + XCTAssertFalse(UpdateChecker.isRelease("v0.9.9", newerThan: "1.0.0")) + } + + func testUpdateCheckRefusesNetworkWhenInternetAccessIsDisabled() { + let checker = UpdateChecker() + let expectation = expectation(description: "update check refused before network") + + checker.checkLatestRelease(allowsInternetAccess: false, currentVersion: "1.0.0") { result in + XCTAssertEqual(result, .failure(.internetAccessNotAllowed)) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/scripts/check-app-ui-contract.sh b/scripts/check-app-ui-contract.sh index c0190e2..85d5fdb 100755 --- a/scripts/check-app-ui-contract.sh +++ b/scripts/check-app-ui-contract.sh @@ -44,6 +44,10 @@ preferences_text=( "Pointer Scope" "Custom pointer preview works in Cursor Designer. System-wide pointer replacement is not enabled in this build." "Launch at Login" + "Updates" + "Allow internet access for update checks" + "Check for Updates" + "Update checks are local-off until internet access is allowed." "Reset to Defaults" "Dynamic contrast is off for contrast mode None." "Dynamic contrast is active for Auto-Invert and Outline." diff --git a/scripts/check-local-first.sh b/scripts/check-local-first.sh index d432799..0dd2633 100755 --- a/scripts/check-local-first.sh +++ b/scripts/check-local-first.sh @@ -48,6 +48,15 @@ for pattern in "${forbidden_patterns[@]}"; do fi if [[ -n "$matches" ]]; then + if [[ "$pattern" == "URLSession" || "$pattern" == "https://" ]]; then + allowed_matches=$(printf '%s\n' "$matches" | rg --fixed-strings "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" || true) + if [[ "$allowed_matches" == "$matches" ]] && + rg --fixed-strings --quiet "allowsInternetAccess" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" && + rg --fixed-strings --quiet "Internet access for update checks is disabled" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift"; then + continue + fi + fi + printf '%s\n' "$matches" echo "Found network, telemetry, or tracking API in macOS app source: $pattern" >&2 echo "Cursor Designer's pointer loop must remain local-first unless a reviewed exception is added." >&2 From 3330f263cce974597c5592b81181e20cea45e384 Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Tue, 12 May 2026 14:52:17 -0500 Subject: [PATCH 2/2] ci(pointer): keep local-first guard portable --- scripts/check-local-first.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check-local-first.sh b/scripts/check-local-first.sh index 0dd2633..4b43d80 100755 --- a/scripts/check-local-first.sh +++ b/scripts/check-local-first.sh @@ -49,10 +49,10 @@ for pattern in "${forbidden_patterns[@]}"; do if [[ -n "$matches" ]]; then if [[ "$pattern" == "URLSession" || "$pattern" == "https://" ]]; then - allowed_matches=$(printf '%s\n' "$matches" | rg --fixed-strings "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" || true) + allowed_matches=$(printf '%s\n' "$matches" | grep -F "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" || true) if [[ "$allowed_matches" == "$matches" ]] && - rg --fixed-strings --quiet "allowsInternetAccess" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" && - rg --fixed-strings --quiet "Internet access for update checks is disabled" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift"; then + grep -Fq "allowsInternetAccess" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift" && + grep -Fq "Internet access for update checks is disabled" "apps/macos/Sources/PointerDesignerCore/UpdateChecker.swift"; then continue fi fi