diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b8ae0f0..c49b9ff 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -13,7 +13,7 @@ body: id: proposal attributes: label: Proposed behavior - description: Explain how it would fit a small, offline Mac cleaning utility. + description: Explain how it would fit a small, private Mac cleaning utility. validations: required: true - type: textarea diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0bc5727..70b5623 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ List the checks you actually ran and any remaining limitations. For input-filter ## Contribution checklist -- [ ] The change is focused and preserves the offline and input-safety boundaries. +- [ ] The change is focused and preserves the privacy and input-safety boundaries, with no network use beyond update checks. - [ ] Relevant tests or content checks pass, or limitations are explained above. - [ ] No credentials, personal diagnostics, or generated app bundles are included. - [ ] I have the right to contribute this work under Apache 2.0; any third-party material is identified. diff --git a/AGENTS.md b/AGENTS.md index c8837e7..e660841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Keydoze -Native macOS 26+ SwiftUI/AppKit utility; Swift 6, no package dependencies or network access. +Native macOS 26+ SwiftUI/AppKit utility; Swift 6. Sparkle 2 is the only package dependency, and update checks and downloads to GitHub are the only network use (PRIVACY.md). - Build and launch: `./script/build_and_run.sh` - Build only: `./script/build_and_run.sh --build` @@ -10,14 +10,18 @@ Native macOS 26+ SwiftUI/AppKit utility; Swift 6, no package dependencies or net - Live filtering check (debug only, 8-second cap): arm `python3 script/test_watchdog.py --seconds 30 --activate` and wait for ARMED, then start a session in an app launched with `--live-check --evidence /abs/path`. Details and fault flags in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). - Icon: `./script/build_icon.sh` regenerates `Resources/Keydoze.icns` from `Resources/Brand/Keydoze-source.png`. -Code map: `Sources/KeydozeCore/Session.swift` (deterministic phases, deadlines, recovery; no AppKit), `Sources/Keydoze/InputEngine.swift` (event tap, run loop thread, watchdog), `SessionModel.swift` (presentation, preferences, permission, lifecycle), `MainView.swift` (UI). `Debug*.swift`, the Preview menu, and every `--flag` are inside `#if DEBUG` and absent from release builds. Tests: `KeydozeCoreTests` (pure) and `KeydozeAdapterTests` (builds real CGEvents in memory; needs a logged-in GUI session for WindowServer, so run outside a headless sandbox). +Code map: `Sources/KeydozeCore/Session.swift` (deterministic phases, deadlines, recovery; no AppKit), `Sources/Keydoze/InputEngine.swift` (event tap, run loop thread, watchdog), `SessionModel.swift` (presentation, preferences, permission, lifecycle), `MainView.swift` (UI), `Updates.swift` (Sparkle and its session guard). `Debug*.swift`, the Preview menu, and every `--flag` are inside `#if DEBUG` and absent from release builds. Tests: `KeydozeCoreTests` (pure) and `KeydozeAdapterTests` (builds real CGEvents in memory; needs a logged-in GUI session for WindowServer, so run outside a headless sandbox). Permission identity: the bundle ID `app.cleanmode.mac` and a consistent Apple Development signing identity are what keep the Accessibility grant across rebuilds. Do not rename the ID. The build script uses the first `Apple Development:` identity or `KEYDOZE_SIGN_IDENTITY`; ad-hoc signing loses the grant on every rebuild. -Releases: version and build live only in `Resources/Info.plist` and are bumped by hand; also update CHANGELOG.md and the README download name. Distribution is a separate Developer ID copy (team 7BVMD7TA97), notarized with `asc` from the keychain, never the development bundle; follow [docs/RELEASING.md](docs/RELEASING.md). 0.1.0 shipped 2026-10-21 as a notarized DMG and ZIP. Evidence and limits are in [docs/VALIDATION.md](docs/VALIDATION.md). +Updates: Sparkle's standard updater and UI, from `UpdateController`. While a session runs, no check starts, Sparkle's windows stay hidden, its alerts are dismissed, and a relaunch waits; keep all of that when changing either side. Feed: `releases/latest/download/appcast.xml`, so every release must attach `appcast.xml`. Sparkle compares `CFBundleVersion`, so the build number must rise every release. The private EdDSA key is only in the login Keychain under account `keydoze`, read by `generate_appcast`; never print or copy it, and `SUPublicEDKey` must match it. Lose it and every installed copy needs a download by hand. Debug builds never touch the real feed: the updater starts only with `--update-feed URL`, and flags that take a value must come before bare flags or AppKit opens no main window. Sparkle writes `SU*` keys to the real `app.cleanmode.mac` defaults even from a test copy, so remove them after a test. Details in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). + +Releases: version and build live only in `Resources/Info.plist` and are bumped by hand; also update CHANGELOG.md and the README download name. `script/make_release.sh ` builds the separate Developer ID copy (team 7BVMD7TA97), notarized with `asc` from the keychain, never the development bundle, plus the DMG, ZIP, appcast and checksums; follow [docs/RELEASING.md](docs/RELEASING.md). Evidence and limits are in [docs/VALIDATION.md](docs/VALIDATION.md). New GitHub issues fire the "Bug report to draft PR" routine through `.github/workflows/issue-fix.yml` (3 per day; the `fix-this` label re-fires it). +The website is `keydoze/` in dlev02/Portfolio (keydoze.drewlevinson.me); keep its privacy and download copy in step with this repository. + The engine owns event filtering and deadlines off the main thread. The deterministic core is in KeydozeCore. Never log typed content or raw events, replay blocked events, automatically restart an interrupted session, or request extra privacy permissions by habit. Idle means no event taps. Live filtering tests require a short duration and an independent external process-kill watchdog armed before activation. UI simulation never creates a tap. Do not test TCC revocation, sleep, or session switching during live filtering on the user's primary Mac; use an isolated test account/device. Hardware gestures, Fn/media keys, Touch ID, and power/security paths must never be advertised as comprehensively blocked without evidence. Preserve unrelated workspace changes. Distribution signing, notarization, and publication require separately authorized credentials/targets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 07cd06d..9acd67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 — 5 November 2026 + +- Keydoze now updates itself. It checks once a day and asks before installing, and every update is signed. Turn automatic checks off in Keydoze > Settings…, or check any time with Keydoze > Check for Updates…. +- Updates stay out of the way of a cleaning session: no check starts, no window appears, and no relaunch happens until input is back. +- Update checks and downloads go to GitHub. See the privacy policy for what GitHub can see. +- About now links to the privacy policy. + +Coming from 0.1.0? It has no updater, so this is the one download you need to install by hand. + ## 0.1.0 — 21 October 2026 - First notarized public release: Developer ID signed, accepted by Apple notarization, and stapled for Apple silicon Macs on macOS 26+. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85f2d4b..680350e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Bug reports, focused fixes, documentation improvements, and careful accessibilit ## Development -Use macOS 26 or later and Xcode 26 or later with Swift 6.2 or newer. The project has no package dependencies. Read [AGENTS.md](AGENTS.md) for its safety invariants and local commands. +Use macOS 26 or later and Xcode 26 or later with Swift 6.2 or newer. Sparkle, for updates, is the only package dependency. Read [AGENTS.md](AGENTS.md) for its safety invariants and local commands. ```sh swift test --disable-sandbox @@ -20,7 +20,7 @@ Live filtering checks are separate from automated tests. Use a short session and Keep a change focused and preserve unrelated work. Describe the user-visible result, relevant checks, and anything you could not verify. Add regression coverage for meaningful behavior changes; content and link checks are usually enough for documentation. Do not include signing keys, credentials, local diagnostics, personal paths, or generated app bundles. -Keydoze must remain offline and must never store typed content, replay suppressed input, automatically restart an interrupted session, or leave an event tap installed while idle. Changes to those boundaries need explicit discussion. +Keydoze must not use the network for anything but update checks and downloads, and must never store typed content, replay suppressed input, automatically restart an interrupted session, or leave an event tap installed while idle. Changes to those boundaries need explicit discussion. ## License and attribution diff --git a/NOTICE b/NOTICE index 254381b..1db0c67 100644 --- a/NOTICE +++ b/NOTICE @@ -4,3 +4,6 @@ Copyright 2026 Drew Levinson Original project: https://github.com/dlev02/Keydoze Licensed under the Apache License, Version 2.0. See LICENSE. + +This product includes Sparkle (https://sparkle-project.org), under the MIT-style +license and third-party notices in Sparkle-LICENSE.txt. diff --git a/PRIVACY.md b/PRIVACY.md index 8a011b2..5db07b8 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,23 @@ # Privacy -Keydoze works offline. It has no accounts, advertising, analytics, remote logging, update checker, or network requests. The app does not collect or send personal data to its developer or any third party. +Last updated: 5 November 2026 + +Keydoze has no accounts, advertising, analytics, or remote logging. Cleaning sessions work without a network connection. The only network requests the app makes are update checks and update downloads, both to GitHub. Nothing is sent to the developer. + +## Update checks + +Keydoze uses [Sparkle](https://sparkle-project.org), an open-source update framework, to keep itself current. + +- **When:** when you open Keydoze if the last check was more than a day ago, then once a day while it stays open. No check starts during a cleaning session. +- **What it requests:** one file, `appcast.xml`, from Keydoze's [GitHub releases](https://github.com/dlev02/Keydoze/releases). It downloads an update only after you choose **Install Update**. +- **What GitHub can see:** as with any web request, your IP address and the time, plus the app's name and version and Sparkle's version (for example `Keydoze/0.2.0 Sparkle/2.10.1`) and your Mac's language preference. GitHub handles this under [its privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). +- **What is not sent:** typed input, input events, your session history, device identifiers, or a hardware or system profile. +- **What the developer can see:** GitHub shows repository owners a running total of downloads for each release file. Checks add to the total for `appcast.xml`. It does not show who made them. +- **What stays on your Mac:** the time of the last check, your choices about updates, and a downloaded update until it is installed. These are in Keydoze's local preferences and caches. + +To turn automatic checks off, choose **Keydoze > Settings…** and turn off **Check for updates automatically**. Keydoze then makes no network requests unless you choose **Keydoze > Check for Updates…**. + +Versions before 0.2.0 have no update checker and make no network requests. ## Input processing @@ -10,7 +27,7 @@ The event tap is removed when the session ends or is interrupted. There is no in ## What stays on your Mac -The app saves the selected input scope and session duration in local macOS preferences. It does not sync those preferences or upload them. macOS manages the permission that allows Keydoze to intercept input; granting or revoking that permission happens in System Settings. +The app saves the selected input scope, session duration, and update settings in local macOS preferences. It does not sync those preferences or upload them. macOS manages the permission that allows Keydoze to intercept input; granting or revoking that permission happens in System Settings. Development builds include optional diagnostics that are excluded from release builds: diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..30628fa --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c74feedec077414596b38c9cde0de68d42677dc4e7eae7e9f52f106592a75021", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "ab7d71291238df6e0492143b120c9b696a35f603", + "version" : "2.10.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 4745644..5526c5d 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,10 @@ let package = Package( name: "Keydoze", platforms: [.macOS("26.0")], products: [.executable(name: "Keydoze", targets: ["Keydoze"])], + dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0")], targets: [ .target(name: "KeydozeCore"), - .executableTarget(name: "Keydoze", dependencies: ["KeydozeCore"]), + .executableTarget(name: "Keydoze", dependencies: ["KeydozeCore", .product(name: "Sparkle", package: "Sparkle")]), .testTarget(name: "KeydozeCoreTests", dependencies: ["KeydozeCore"]), .testTarget(name: "KeydozeAdapterTests", dependencies: ["Keydoze", "KeydozeCore"]) ] diff --git a/README.md b/README.md index e54eabd..fbdbb8b 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,15 @@ A small native macOS utility built with SwiftUI and AppKit. Choose what to pause - **Short sessions:** 30 seconds, 1 minute, or 2 minutes, with time to lift your hands before input pauses. - **Always-visible recovery:** hold both Shift keys for two seconds, or press Escape in pointing-only mode. - **Made for Mac:** a continuous native window, light and dark appearances, subtle SwiftUI transitions, and accessible motion and transparency fallbacks. -- **Completely offline:** no accounts, analytics, advertising, network requests, or saved input. +- **Private:** no accounts, analytics, advertising, or saved input. Sessions work offline; only update checks use the network, and you can turn them off. ## Get started -Download the notarized **Keydoze-0.1.0.dmg** from the [latest release](https://github.com/dlev02/Keydoze/releases/latest), open it, and drag **Keydoze** onto **Applications**. It is Developer ID signed, notarized by Apple, and runs on Apple silicon Macs with **macOS 26+**. A ZIP of the same app is also attached. To build from source instead, you need an Xcode toolchain supporting **Swift 6.2+**: +Download the notarized **Keydoze-0.2.0.dmg** from the [latest release](https://github.com/dlev02/Keydoze/releases/latest), open it, and drag **Keydoze** onto **Applications**. It is Developer ID signed, notarized by Apple, and runs on Apple silicon Macs with **macOS 26+**. A ZIP of the same app is also attached. + +Keydoze keeps itself up to date from there: it checks GitHub once a day and asks before installing. Updates are signed and never start or relaunch during a cleaning session. To turn automatic checks off, choose **Keydoze > Settings…**; **Keydoze > Check for Updates…** always works. Anyone on 0.1.0 needs this one download by hand, because that version has no updater. + +To build from source instead, you need an Xcode toolchain supporting **Swift 6.2+**: ```sh git clone https://github.com/dlev02/Keydoze.git @@ -41,14 +45,14 @@ swift test --disable-sandbox # core and in-memory event-adapter tests ./script/build_and_run.sh --release # local optimized app; no publication ``` -There are no package dependencies. CI passes on macOS 26 with Xcode 26.2, and the native app has been checked on Apple silicon with macOS 27 beta. Interactive macOS 26 and Intel validation are still open. See [development and previews](docs/DEVELOPMENT.md), [release preparation](docs/RELEASING.md), and [contributing](CONTRIBUTING.md). +[Sparkle](https://sparkle-project.org) is the only package dependency. CI passes on macOS 26 with Xcode 26.2, and the native app has been checked on Apple silicon with macOS 27 beta. Interactive macOS 26 and Intel validation are still open. See [development and previews](docs/DEVELOPMENT.md), [release preparation](docs/RELEASING.md), and [contributing](CONTRIBUTING.md). ## Privacy and security -The app processes input only for a user-started session and removes its event tap when the session ends. It saves only your scope and duration preferences. Read the [privacy policy](PRIVACY.md) or [report a security issue](SECURITY.md). +The app processes input only for a user-started session and removes its event tap when the session ends. It saves only your scope, duration, and update preferences. Update checks go to GitHub and can be turned off. Read the [privacy policy](PRIVACY.md) or [report a security issue](SECURITY.md). ## License and credit -Created by **Drew Levinson**. Open source under the [Apache License 2.0](LICENSE). +Created by **Drew Levinson**. Open source under the [Apache License 2.0](LICENSE). Updates use [Sparkle](https://sparkle-project.org), under its [MIT-style license](Resources/Sparkle-LICENSE.txt). Forks and modifications are welcome. When distributing a derivative, retain the applicable attribution and [NOTICE](NOTICE), include the license, and identify modified files as required by the license. See [contributing](CONTRIBUTING.md#license-and-attribution) for details. diff --git a/Resources/Credits.html b/Resources/Credits.html index 918088a..d63c3af 100644 --- a/Resources/Credits.html +++ b/Resources/Credits.html @@ -5,5 +5,5 @@

A quiet moment for your Mac.

Created by Drew Levinson.

Open source under the Apache License 2.0.
License and attribution are included in this app.

-

Works offline. No accounts, analytics, or input recordings.

+

No accounts, analytics, or input recordings. Only update checks use the network.

diff --git a/Resources/DMG/dmg-layout.DS_Store b/Resources/DMG/dmg-layout.DS_Store new file mode 100644 index 0000000..bcda759 Binary files /dev/null and b/Resources/DMG/dmg-layout.DS_Store differ diff --git a/Resources/Info.plist b/Resources/Info.plist index 04b8a07..9c78ae4 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -8,9 +8,16 @@ CFBundleIconFileKeydoze CFBundleIconNameKeydoze CFBundlePackageTypeAPPL -CFBundleShortVersionString0.1.0 -CFBundleVersion3 +CFBundleShortVersionString0.2.0 +CFBundleVersion4 LSMinimumSystemVersion26.0 NSPrincipalClassNSApplication NSHumanReadableCopyright© 2026 Drew Levinson +SUFeedURLhttps://github.com/dlev02/Keydoze/releases/latest/download/appcast.xml +SUPublicEDKeyM268PTj2i8EyBrX9bPcnoDBSsOR9Juuty+psdt345No= +SUEnableAutomaticChecks +SUAllowsAutomaticUpdates +SURequireSignedFeed +SUVerifyUpdateBeforeExtraction +SUSignedFeedFailureExpirationInterval0 diff --git a/Resources/Sparkle-LICENSE.txt b/Resources/Sparkle-LICENSE.txt new file mode 100644 index 0000000..abe6e04 --- /dev/null +++ b/Resources/Sparkle-LICENSE.txt @@ -0,0 +1,134 @@ +Copyright (c) 2006-2013 Andy Matuschak. +Copyright (c) 2009-2013 Elgato Systems GmbH. +Copyright (c) 2011-2014 Kornel Lesiński. +Copyright (c) 2015-2017 Mayur Pawashe. +Copyright (c) 2014 C.W. Betts. +Copyright (c) 2014 Petroules Corporation. +Copyright (c) 2014 Big Nerd Ranch. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================= +EXTERNAL LICENSES +================= + +bspatch.c and bsdiff.c, from bsdiff 4.3 : + +Copyright 2003-2005 Colin Percival +All rights reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +-- + +sais.c and sais.h, from sais-lite (2010/08/07) : + +The sais-lite copyright is as follows: + +Copyright (c) 2008-2010 Yuta Mori All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +-- + +Portable C implementation of Ed25519, from https://github.com/orlp/ed25519 + +Copyright (c) 2015 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In no event will the +authors be held liable for any damages arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, including commercial +applications, and to alter it and redistribute it freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim that you wrote the + original software. If you use this software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be misrepresented as + being the original software. + +3. This notice may not be removed or altered from any source distribution. + +-- + +SUSignatureVerifier.m: + +Copyright (c) 2011 Mark Hamlin. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/SECURITY.md b/SECURITY.md index 2dee5bc..b3e4980 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security -Keydoze intercepts input only for a user-started cleaning session. A failure that leaves input unavailable, resumes filtering unexpectedly, exposes input content, or bypasses a safety limit is security-sensitive. +Keydoze intercepts input only for a user-started cleaning session. A failure that leaves input unavailable, resumes filtering unexpectedly, exposes input content, bypasses a safety limit, or lets an unsigned or tampered update install is security-sensitive. ## Reporting a vulnerability diff --git a/Sources/Keydoze/AboutView.swift b/Sources/Keydoze/AboutView.swift index 065c820..be8385f 100644 --- a/Sources/Keydoze/AboutView.swift +++ b/Sources/Keydoze/AboutView.swift @@ -29,15 +29,17 @@ struct AboutView: View { VStack(spacing: 8) { Text("A quiet moment for your Mac.") .font(.body) - Text("Works offline. No accounts or tracking.") + Text("No accounts or tracking. Only update checks use the network.") .font(.callout) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } VStack(spacing: 10) { HStack(spacing: 16) { Link("Source Code", destination: URL(string: "https://github.com/dlev02/Keydoze")!) Link("Apache 2.0 License", destination: URL(string: "https://github.com/dlev02/Keydoze/blob/main/LICENSE")!) + Link("Privacy", destination: URL(string: "https://github.com/dlev02/Keydoze/blob/main/PRIVACY.md")!) } .font(.callout) diff --git a/Sources/Keydoze/App.swift b/Sources/Keydoze/App.swift index 0295e84..38f0686 100644 --- a/Sources/Keydoze/App.swift +++ b/Sources/Keydoze/App.swift @@ -6,6 +6,7 @@ struct KeydozeApp: App { @Environment(\.openWindow) private var openWindow @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @State private var model = SessionModel() + @State private var updates = UpdateController() init() { #if DEBUG PermissionProbe.runIfRequested() @@ -21,6 +22,7 @@ struct KeydozeApp: App { }.onAppear { delegate.model = model model.refreshPermission() + updates.start(session: model) #if DEBUG if ProcessInfo.processInfo.arguments.contains("--request-permission") { model.showPermission = true @@ -37,6 +39,8 @@ struct KeydozeApp: App { .commands { CommandGroup(replacing: .appInfo) { Button("About Keydoze") { openWindow(id: "about") } + Button("Check for Updates…") { updates.checkForUpdates() } + .disabled(!updates.canCheck) } CommandGroup(replacing: .newItem) { } CommandGroup(replacing: .help) { @@ -61,6 +65,10 @@ struct KeydozeApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) .defaultPosition(.center) + + Settings { + SettingsView(updates: updates) + } } } diff --git a/Sources/Keydoze/SessionModel.swift b/Sources/Keydoze/SessionModel.swift index 1b51a87..8fe0a6c 100644 --- a/Sources/Keydoze/SessionModel.swift +++ b/Sources/Keydoze/SessionModel.swift @@ -6,7 +6,13 @@ import KeydozeCore @MainActor @Observable final class SessionModel { enum Screen: Equatable { case ready, arming, active, releasing, finished(EndReason) } - var screen: Screen = .ready + var screen: Screen = .ready { + didSet { + let wasRunning = oldValue == .arming || oldValue == .active || oldValue == .releasing + if running != wasRunning { onRunningChanged?(running) } + } + } + @ObservationIgnored var onRunningChanged: ((Bool) -> Void)? var scope: InputScope { didSet { if !isSimulation { UserDefaults.standard.set(scope.rawValue, forKey: "inputScope") } } } diff --git a/Sources/Keydoze/Updates.swift b/Sources/Keydoze/Updates.swift new file mode 100644 index 0000000..e8df973 --- /dev/null +++ b/Sources/Keydoze/Updates.swift @@ -0,0 +1,128 @@ +import AppKit +import SwiftUI +@preconcurrency import Sparkle + +/// Sparkle's standard updater and UI, kept out of the way of a cleaning session: while one runs, +/// no check starts, Sparkle's windows stay hidden, and a relaunch waits until input is back. +/// Quitting always ends a session first (`AppDelegate`), so an update can't leave input paused. +@MainActor @Observable +final class UpdateController: NSObject { + private var updaterIsIdle = false + private var sessionRunning = false + var canCheck: Bool { updaterIsIdle && !sessionRunning } + var checksAutomatically = false { + didSet { + guard let updater, updater.automaticallyChecksForUpdates != checksAutomatically else { return } + updater.automaticallyChecksForUpdates = checksAutomatically + } + } + @ObservationIgnored private var standard: SPUStandardUpdaterController? + @ObservationIgnored private var canCheckObservation: NSKeyValueObservation? + @ObservationIgnored private var hiddenWindows: [NSWindow] = [] + @ObservationIgnored private var hideTimer: Timer? + @ObservationIgnored private var postponedRelaunch: (() -> Void)? + + private var updater: SPUUpdater? { standard?.updater } + + func start(session: SessionModel) { + guard standard == nil else { return } + session.onRunningChanged = { [weak self] running in self?.sessionChanged(running: running) } + // A window Sparkle closes while it's hidden must not come back when the session ends. + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: nil, queue: .main) { [weak self] note in + guard let window = note.object as? NSWindow else { return } + MainActor.assumeIsolated { self?.hiddenWindows.removeAll { $0 === window } } + } + sessionChanged(running: session.running) + #if DEBUG + // Development copies stay off the real feed: see docs/DEVELOPMENT.md. + guard Self.debugFeed != nil else { return } + #endif + let standard = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) + self.standard = standard + canCheckObservation = standard.updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in + MainActor.assumeIsolated { self?.updaterIsIdle = updater.canCheckForUpdates } + } + standard.startUpdater() + checksAutomatically = standard.updater.automaticallyChecksForUpdates + } + + func checkForUpdates() { + if canCheck { updater?.checkForUpdates() } + } + + private func sessionChanged(running: Bool) { + guard running != sessionRunning else { return } + sessionRunning = running + if running { + hideUpdateWindows() + let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.hideUpdateWindows() } + } + RunLoop.main.add(timer, forMode: .common) + hideTimer = timer + } else { + hideTimer?.invalidate() + hideTimer = nil + hiddenWindows.forEach { $0.makeKeyAndOrderFront(nil) } + hiddenWindows = [] + let relaunch = postponedRelaunch + postponedRelaunch = nil + relaunch?() + } + } + + private func hideUpdateWindows() { + // Sparkle's "up to date" and error alerts are its only modal windows, and they only inform. + if NSApp.modalWindow != nil { NSApp.abortModal() } + let sparkle = Bundle(for: SPUUpdater.self) + for window in NSApp.windows where window.isVisible { + guard let controller = window.windowController, Bundle(for: type(of: controller)) == sparkle else { continue } + window.orderOut(nil) + if !hiddenWindows.contains(window) { hiddenWindows.append(window) } + } + } + + #if DEBUG + private static var debugFeed: String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "--update-feed"), arguments.count > index + 1 else { return nil } + return arguments[index + 1] + } + #endif +} + +extension UpdateController: SPUUpdaterDelegate { + func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { + if sessionRunning { + throw NSError(domain: "Keydoze.Updates", code: 1, userInfo: [NSLocalizedDescriptionKey: "A cleaning session is running."]) + } + } + + func updater(_ updater: SPUUpdater, shouldPostponeRelaunchForUpdate item: SUAppcastItem, untilInvokingBlock installHandler: @escaping () -> Void) -> Bool { + guard sessionRunning else { return false } + postponedRelaunch = installHandler + return true + } + + #if DEBUG + func feedURLString(for updater: SPUUpdater) -> String? { Self.debugFeed } + #endif +} + +struct SettingsView: View { + @Bindable var updates: UpdateController + + var body: some View { + Form { + Toggle("Check for updates automatically", isOn: $updates.checksAutomatically) + LabeledContent("Updates") { + Button("Check Now") { updates.checkForUpdates() } + .disabled(!updates.canCheck) + } + } + .formStyle(.grouped) + .scrollDisabled(true) + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 947dadd..d4477dc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5,14 +5,16 @@ ```sh ./script/build_and_run.sh --simulate # After building debug, quit the app before launching a different preview: -open -n 'dist/Keydoze.app' --args --simulate --state recovery --appearance dark +open -n 'dist/Keydoze.app' --args --state recovery --appearance dark --simulate ``` +Put flags that take a value before bare flags. A value left unpaired looks to AppKit like a document to open, and SwiftUI then opens no main window. + Debug-only states: `ready`, `permission`, `arming`, `active`, `recovery`, `releasing`, `done`, `error`. The Preview menu changes states without creating a tap. `--appearance` accepts `light`, `dark`, `contrast-light`, `contrast-dark`; `--reduce-motion` and `--reduce-transparency` exercise app-only fallbacks in simulation without modifying system preferences. Frozen previews render states; normal `--simulate` can run the model's simulated timer. All are visibly marked as nonblocking previews and excluded from optimized builds. ## Live development checks -Only short, supervised checks are appropriate on a primary Mac. First launch a debug build with `--live-check --evidence /absolute/local/path`. This limits a session to eight seconds and shows transient aggregate counters; launching it does **not** start blocking. In another terminal, run: +Only short, supervised checks are appropriate on a primary Mac. First launch a debug build with `--evidence /absolute/local/path --live-check`. This limits a session to eight seconds and shows transient aggregate counters; launching it does **not** start blocking. In another terminal, run: ```sh python3 script/test_watchdog.py --seconds 30 --activate @@ -28,13 +30,27 @@ For supervised recovery checks, `--fault-ui-stall` deliberately stalls only the - `Sources/Keydoze/InputEngine.swift`: event tap, dedicated run loop, independent watchdog and cleanup. - `Sources/Keydoze/SessionModel.swift`: presentation, local preferences, permissions and system lifecycle. - `Sources/Keydoze/MainView.swift`: native UI and recovery guidance. +- `Sources/Keydoze/Updates.swift`: Sparkle's standard updater, its session guard, and Settings. - `Tests/`: deterministic safety and macOS adapter coverage, without installing taps. +## Updates + +`UpdateController` runs Sparkle's standard updater and UI, with checks on by default and a toggle in Settings. It gates three things on `SessionModel.running`: `updater(_:mayPerform:)` refuses a check, a 20 Hz sweep hides Sparkle's windows and dismisses its modal alerts (windows come back when the session ends), and `updater(_:shouldPostponeRelaunchForUpdate:untilInvokingBlock:)` holds a relaunch until input is back. Quitting still ends a session first, and the event tap can't outlive the process. + +Debug builds start the updater only with `--update-feed URL`, so development copies never touch the real feed. To try an update end to end, without the real key: + +1. Generate a throwaway Ed25519 key. `generate_appcast --ed-key-file` takes the base64 of the 32-byte seed. +2. Copy a built app twice. In each copy's `Info.plist`, set `SUPublicEDKey` to the test key and `SUFeedURL` to a local server, give the first a lower `CFBundleVersion` and the second a higher one, and re-sign both with `script/sign_app.sh`. +3. ZIP the higher copy with `ditto -c -k --sequesterRsrc --keepParent`, run `generate_appcast` on its folder, and serve the ZIP and `appcast.xml` locally. +4. Delete the `SU*` keys from the `app.cleanmode.mac` defaults, so the first launch checks at once, and open the lower copy. + +The Preview menu's Active and Done states exercise the session guard without a tap, with `--update-feed URL --simulate`. Sparkle writes `SU*` keys to the real defaults domain even from a test copy (same bundle ID), so remove them afterwards. + ## Development signing The stable local bundle is `dist/Keydoze.app`. Its historical identifier, `app.cleanmode.mac`, is retained from the original prototype to preserve existing Accessibility authorization. The product and source modules are named Keydoze. -The build script uses an available Apple Development identity consistently for debug and release. Set `KEYDOZE_SIGN_IDENTITY` to select a certificate explicitly. Without one it uses ad-hoc signing, whose permission identity can change on rebuild. Prefer a stable development certificate and do not reset unrelated privacy grants. See [permissions and signing](RESEARCH.md#permissions-and-signing). +The build script signs from the inside out with `script/sign_app.sh`, using an available Apple Development identity consistently for debug and release. Set `KEYDOZE_SIGN_IDENTITY` to select a certificate explicitly. Without one it uses ad-hoc signing, whose permission identity can change on rebuild. Prefer a stable development certificate and do not reset unrelated privacy grants. See [permissions and signing](RESEARCH.md#permissions-and-signing). ## Icon diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f058a4c..6c1d06d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -14,24 +14,20 @@ After Apple reports **Accepted**, staple and validate the ticket, verify Gatekee ## Distribution workflow -The default local optimized app/archive is **Apple silicon only** and uses hardened runtime and development signing. It is not notarized by the build script. Sign a separate distribution copy, preserving the stable development app's permission identity: +1. Set `CFBundleShortVersionString` and a higher `CFBundleVersion` in `Resources/Info.plist`. Sparkle compares `CFBundleVersion`, so a build number that doesn't rise never reaches anyone. +2. Add the version's section to `CHANGELOG.md`, in the form `## 0.2.0 — date`. Its text becomes the release notes in the update window, so write it for people. +3. Update the README's download name and merge. Build from the merged `main`, with no local changes. +4. Run `script/make_release.sh `. It checks this Mac first (Developer ID certificate, Sparkle key, version, build number against the published feed), then builds the Developer ID copy from the inside out with hardened runtime and secure timestamps, notarizes and staples the app, makes the ZIP, makes, signs, notarizes and staples the DMG, signs the appcast, and writes checksums to `dist/release/`. It uploads nothing. +5. Create the release as a draft, attach the DMG, ZIP, `appcast.xml` and their `.sha256` files, then publish it as the latest. A draft or prerelease is invisible to installed copies. +6. Download the published `appcast.xml` and DMG and check them against the checksums. Then update the website if the download or privacy copy changed. -```sh -./script/build_and_run.sh --release -mkdir -p dist/distribution -ditto 'dist/Keydoze.app' 'dist/distribution/Keydoze.app' -codesign --force --options runtime --timestamp --sign 'Developer ID Application: YOUR NAME (TEAMID)' 'dist/distribution/Keydoze.app' -codesign --verify --strict 'dist/distribution/Keydoze.app' -ditto -c -k --keepParent 'dist/distribution/Keydoze.app' dist/Keydoze-submission.zip -asc notarization submit --file dist/Keydoze-submission.zip --wait --output json -# Continue only when the submission status is Accepted. -xcrun stapler staple 'dist/distribution/Keydoze.app' -xcrun stapler validate 'dist/distribution/Keydoze.app' -spctl --assess --type execute --verbose 'dist/distribution/Keydoze.app' -ditto -c -k --keepParent 'dist/distribution/Keydoze.app' dist/Keydoze.zip -``` +Don't publish a later release without `appcast.xml`: installed copies read it from the latest release, and a 404 stops them finding updates. Only the ZIP goes in the appcast. The DMG is for first installs, and its drag-to-Applications window comes from `Resources/DMG/dmg-layout.DS_Store`. + +## Update signing key + +Updates are signed with an EdDSA key made by Sparkle's `generate_keys --account keydoze`. The private half is only in the login Keychain and is read by `generate_appcast`; macOS may ask once to allow that. The public half is `SUPublicEDKey` in `Resources/Info.plist`. Don't use Sparkle's default account: that key is another project's. -For a drag-to-Applications installer, stage the stapled app beside a symlink to `/Applications`, create a read-write image with `hdiutil create -srcfolder`, arrange the icons in Finder (icon view, app left, Applications right), convert it with `hdiutil convert -format UDZO`, then `codesign --timestamp`, `asc notarization submit --wait`, and `xcrun stapler staple` the DMG itself. +Back it up once, somewhere safe outside this repository, with `generate_keys --account keydoze -x `, and delete the exported file after storing it. Lose this key and every installed copy needs a download by hand, because `SUSignedFeedFailureExpirationInterval` is 0: nothing falls back to accepting an unsigned feed. Restore with `generate_keys --account keydoze -f `. ## Source publication checklist diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 1905da0..e3b450f 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -20,6 +20,22 @@ Recorded 6 September 2026. Compatibility target: macOS 26+. Actual environment: Local debug evidence is under ignored `docs/evidence/`; it contains lifecycle state, permission booleans and aggregate results only. Older completion records used a nil-engine fallback for the tap field; only the corrected `live-10` snapshot is cited as direct completion evidence. +## Updates (Sparkle) + +Recorded 5 November 2026, on Apple silicon with macOS 27. Checks used throwaway Developer ID signed copies, a throwaway Ed25519 key and a local feed. Sparkle's `SU*` defaults were removed afterwards. + +- **Full update:** a 0.1.8 copy found 0.1.9 at launch, showed the release notes, then on **Install Update** and **Install and Relaunch** downloaded, verified the EdDSA signature, replaced itself and relaunched as 0.1.9 under a new process, with a valid signature. +- **What was sent:** the local server recorded `GET /appcast.xml` with `User-Agent: Keydoze/0.1.8 Sparkle/2.10.1` and the headers `Host`, `Accept`, `Accept-Language`, `Connection` and `Accept-Encoding`. There was no query string, cookie or system profile. +- **Refused:** a tampered ZIP (validation failed before unarchiving), a feed and ZIP signed with a different key, a feed edited after signing, and a feed with no signature. In each case the app on disk was unchanged. A refused feed is silent on an automatic check and shows **Update Error!** on a manual one. +- **Off switch:** with the toggle off and a check overdue, a relaunch made no request at all. **Check for Updates…** still worked. +- **Sessions, simulated (no tap):** a check that came due during a session made no request. With a response arriving mid-session, the Sparkle window was hidden before it had any size on screen and came back when the session ended. An open update window hid when a session started and returned afterwards. A mid-session "up to date" alert was dismissed and its checking window did not return. The menu command and Settings button were disabled. +- **Relaunch, with a temporary test build that left the window clickable:** choosing **Install and Relaunch** during a session changed nothing for the 8 seconds observed. The relaunch and the new version came as soon as the session ended. +- **Session, real tap:** one keyboard-only `--live-check` session with the external watchdog armed, tap installed from 5.4 s to 13.65 s. An update response arrived during it. No Sparkle window appeared, the process did not change, and the session ended normally with `timedOut` and no tap. + +- **Run from a disk image:** a copy opened from a read-only volume, with an update available, offered no update and logged that it can't update from a read-only location. + +Not tested: an update while offline or on a slow network beyond a delayed feed, a copy in a folder the user can't write to, and a translocated copy. + ## UI checks on the packaged native app The final Keydoze design was inspected in actual native windows. Light and dark setup, duration selection, permission setup and its troubleshooting disclosure, countdown, pointing-only Escape recovery, keyboard-only Finish now, recovery progress, and coverage help were checked through Accessibility and screenshots. Increased-contrast, reduced-motion, and reduced-transparency preview paths were also exercised. The app's original permission grant survived the rename and the rebuilt app showed Start cleaning without another permission request. @@ -68,6 +84,6 @@ codesign --verify --strict 'dist/Keydoze.app' plutil -lint 'dist/Keydoze.app/Contents/Info.plist' ``` -The default local release is development signed. The separate Developer ID distribution copy is notarized and stapled and is published as the `v0.1.0` release. The repository contains no production service, credentials, network dependency or privileged helper. +The default local release is development signed. The separate Developer ID distribution copy is notarized and stapled, and each published release records that in [RELEASING.md](RELEASING.md). The repository contains no production service, credentials or privileged helper. The only network use is Sparkle's update checks and downloads. The final archive checksum is recorded beside the local package in `dist/Keydoze-0.1.0-local-arm64.zip.sha256`. Generated bundles and local diagnostics are excluded from source control. diff --git a/docs/design/reference-notes.md b/docs/design/reference-notes.md index dd52fa9..772498b 100644 --- a/docs/design/reference-notes.md +++ b/docs/design/reference-notes.md @@ -23,7 +23,7 @@ These informed general interface principles. Their artwork is not incorporated o - Content is 440 points wide with 32-point side margins and a 510-point content height. These are app-specific optical dimensions, not claimed platform constants. - Setup and countdown are centered. Labels and instructions use leading alignment; the recovery group is centered with left-aligned text beside its symbols. - Scope choices are 65 points high. Duration choices have a 32-point button within a 40-point capsule track. Primary actions are approximately 42 points high. -- Scope and duration form one group; a larger gap separates them from Start. Permission help appears only on request. Offline/privacy details live in About and repository documentation. +- Scope and duration form one group; a larger gap separates them from Start. Permission help appears only on request. Privacy details live in About and repository documentation. - The ready screen retains one concise, clickable coverage limit. Active states show the scope, time until input returns, and one recovery instruction. Only safe development previews include the small preview label. ## Motion diff --git a/script/build_and_run.sh b/script/build_and_run.sh index cb457c0..bc4a32c 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -33,8 +33,18 @@ swift build --disable-sandbox -c "$KEYDOZE_CONFIG" KEYDOZE_BIN="$(swift build --disable-sandbox -c "$KEYDOZE_CONFIG" --show-bin-path)" mkdir -p "$KEYDOZE_APP/Contents/MacOS" "$KEYDOZE_APP/Contents/Resources" cp "$KEYDOZE_BIN/Keydoze" "$KEYDOZE_APP/Contents/MacOS/Keydoze" +# The app finds Sparkle in its own bundle; the linker's absolute build-folder rpath must not ship. +while read -r KEYDOZE_RPATH; do + install_name_tool -delete_rpath "$KEYDOZE_RPATH" "$KEYDOZE_APP/Contents/MacOS/Keydoze" +done < <(otool -l "$KEYDOZE_APP/Contents/MacOS/Keydoze" | awk '/cmd LC_RPATH/{getline;getline;if($2 ~ /^\//)print $2}') +install_name_tool -add_rpath @executable_path/../Frameworks "$KEYDOZE_APP/Contents/MacOS/Keydoze" +# ditto keeps the framework's symlinks. Keydoze isn't sandboxed, so Sparkle's XPC services go unused. +rm -rf "$KEYDOZE_APP/Contents/Frameworks" +mkdir -p "$KEYDOZE_APP/Contents/Frameworks" +ditto "$KEYDOZE_BIN/Sparkle.framework" "$KEYDOZE_APP/Contents/Frameworks/Sparkle.framework" +rm -rf "$KEYDOZE_APP/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices" "$KEYDOZE_APP/Contents/Frameworks/Sparkle.framework/XPCServices" cp Resources/Info.plist "$KEYDOZE_APP/Contents/Info.plist" -cp LICENSE NOTICE PRIVACY.md Resources/Credits.html "$KEYDOZE_APP/Contents/Resources/" +cp LICENSE NOTICE PRIVACY.md Resources/Credits.html Resources/Sparkle-LICENSE.txt "$KEYDOZE_APP/Contents/Resources/" cp Resources/Keydoze.icns "$KEYDOZE_APP/Contents/Resources/Keydoze.icns" KEYDOZE_SIGN_IDENTITY="${KEYDOZE_SIGN_IDENTITY:-}" if [[ -z "$KEYDOZE_SIGN_IDENTITY" ]]; then @@ -42,7 +52,7 @@ if [[ -z "$KEYDOZE_SIGN_IDENTITY" ]]; then fi KEYDOZE_SIGN_OPTIONS=(--force --sign "${KEYDOZE_SIGN_IDENTITY:--}") if [[ "$KEYDOZE_CONFIG" == release ]]; then KEYDOZE_SIGN_OPTIONS+=(--options runtime --timestamp=none); fi -codesign "${KEYDOZE_SIGN_OPTIONS[@]}" "$KEYDOZE_APP" +script/sign_app.sh "$KEYDOZE_APP" "${KEYDOZE_SIGN_OPTIONS[@]}" codesign --verify --strict "$KEYDOZE_APP" case "$KEYDOZE_MODE" in --build|--release) echo "$KEYDOZE_APP" ;; diff --git a/script/make_release.sh b/script/make_release.sh new file mode 100755 index 0000000..24e6490 --- /dev/null +++ b/script/make_release.sh @@ -0,0 +1,136 @@ +#!/bin/zsh +# Builds a Keydoze release in dist/release/: a Developer ID signed, notarized and stapled +# DMG and ZIP, a signed Sparkle appcast.xml pointing at the release's tag, and SHA-256 checksums. +# It uploads nothing. See docs/RELEASING.md. +# +# script/make_release.sh 0.2.0 +# +# Set the version and a higher build number in Resources/Info.plist first and commit. Needs, on this Mac: +# - The Developer ID Application certificate for team 7BVMD7TA97, in the login Keychain. +# - The notarization key that `asc auth login` stores in the keychain. +# - The Sparkle private key, in the login Keychain under the account `keydoze`. macOS may ask once +# to let generate_appcast read it. Lose this key and every installed copy needs a download by hand. +set -euo pipefail + +cd "${0:A:h:h}" + +die() { print -u2 "make_release: $*"; exit 1; } +step() { print "\n== $*"; } + +version="${1:-}" +[[ "$version" =~ '^[0-9]+(\.[0-9]+)+$' ]] || die "usage: make_release.sh , for example 0.2.0" + +team=7BVMD7TA97 +account=keydoze +tag="v$version" +repo=https://github.com/dlev02/Keydoze +feed="$repo/releases/latest/download/appcast.xml" +out="dist/release/$version" +sparkle_bin=.build/artifacts/sparkle/Sparkle/bin +plist=Resources/Info.plist +dmg="Keydoze-$version.dmg" +zip="Keydoze-$version.zip" + +# Everything that needs setting up on this Mac fails here, not after a long build. +git diff --quiet HEAD -- || die "tracked files have changes: commit first, so the release is what's in the repository" +[[ "$(plutil -extract CFBundleShortVersionString raw "$plist")" == "$version" ]] || die "$plist doesn't say $version" +build=$(plutil -extract CFBundleVersion raw "$plist") +[[ "$build" =~ '^[0-9]+$' ]] || die "the build number must be a whole number, not $build" +print -r -- "$(sed -n "/^## $version /,/^## /p" CHANGELOG.md | sed '1d;$d')" | grep -q '[^[:space:]]' || die "CHANGELOG.md has no section for $version" + +identity=$(security find-identity -v -p codesigning | awk -v team="($team)" '/Developer ID Application/ && index($0, team) { print $2; exit }') +[[ -n "$identity" ]] || die "no Developer ID Application certificate for team $team in the Keychain" + +swift package --disable-sandbox resolve +public_key=$("$sparkle_bin/generate_keys" --account "$account" -p) || die "no Sparkle key for account $account in the Keychain" +[[ "$public_key" == "$(plutil -extract SUPublicEDKey raw "$plist")" ]] || die "the Keychain's Sparkle key doesn't match SUPublicEDKey" +[[ "$(plutil -extract SUFeedURL raw "$plist")" == "$feed" ]] || die "SUFeedURL isn't $feed" + +# Sparkle compares CFBundleVersion, so a build number that doesn't rise never reaches anyone. +published=$(curl -fsL "$feed" | sed -n 's|.*\([0-9]*\).*|\1|p' | sort -n | tail -1 || true) +if [[ -n "$published" ]] && (( build <= published )); then + die "build $build isn't higher than the published build $published" +fi + +rm -rf "$out" +mkdir -p "$out" +app="$out/Keydoze.app" + +step "Building" +./script/build_and_run.sh --release >"$out/build.log" 2>&1 || { tail -30 "$out/build.log"; die "the build failed; see $out/build.log"; } +ditto dist/Keydoze.app "$app" + +step "Signing with Developer ID" +script/sign_app.sh "$app" --force --options runtime --timestamp --sign "$identity" +codesign --verify --deep --strict "$app" || die "the app's signature doesn't verify" +sparkle="$app/Contents/Frameworks/Sparkle.framework/Versions/B" +for code in "$app" "$sparkle" "$sparkle/Autoupdate" "$sparkle/Updater.app"; do + details=$(codesign -dvv "$code" 2>&1) + [[ "$details" == *"Authority=Developer ID Application"* && "$details" == *"TeamIdentifier=$team"* ]] || die "$code isn't signed with team $team's Developer ID" + [[ "$details" == *"(runtime)"* ]] || die "$code has no hardened runtime" + [[ "$details" == *"Timestamp="* ]] || die "$code has no secure timestamp" +done +if strings "$app/Contents/MacOS/Keydoze" | grep -q '/Users/'; then die "the executable contains a local path"; fi + +notarize() { + local result="$out/notary-${1:t}.json" + asc notarization submit --file "$1" --wait --output json >"$result" || true + local verdict; verdict=$(plutil -extract data.attributes.status raw "$result" 2>/dev/null || print unknown) + if [[ "$verdict" != "Accepted" ]]; then + local id; id=$(plutil -extract data.id raw "$result" 2>/dev/null || print "") + [[ -n "$id" ]] && asc notarization log --id "$id" || true + die "notarizing ${1:t} came back $verdict" + fi +} + +step "Notarizing the app" +ditto -c -k --sequesterRsrc --keepParent "$app" "$out/submission.zip" +notarize "$out/submission.zip" +rm "$out/submission.zip" +xcrun stapler staple -q "$app" +xcrun stapler validate -q "$app" +spctl --assess --type execute "$app" || die "Gatekeeper refuses the stapled app" + +step "Making the ZIP" +# ditto keeps the framework's symlinks, which a plain zip would break. +ditto -c -k --sequesterRsrc --keepParent "$app" "$out/$zip" + +step "Making and notarizing the DMG" +# dmg-layout.DS_Store holds the Finder window for the drag to Applications: app left, Applications right. +folder="$out/dmg" +mkdir "$folder" +ditto "$app" "$folder/Keydoze.app" +ln -s /Applications "$folder/Applications" +cp Resources/DMG/dmg-layout.DS_Store "$folder/.DS_Store" +hdiutil create -quiet -volname Keydoze -srcfolder "$folder" -ov -format UDZO "$out/$dmg" +rm -rf "$folder" +codesign --sign "$identity" --timestamp "$out/$dmg" +notarize "$out/$dmg" +xcrun stapler staple -q "$out/$dmg" +xcrun stapler validate -q "$out/$dmg" +spctl --assess --type open --context context:primary-signature "$out/$dmg" || die "Gatekeeper refuses the DMG" + +step "Signing the appcast" +# Only the ZIP goes in: Sparkle installs from it, and the DMG is for people. The changelog section +# becomes the release notes in the update window. The appcast must always be attached to the latest +# release: installed copies read it from there. +stage="$out/appcast-stage" +mkdir "$stage" +ln "$out/$zip" "$stage/$zip" +print -r -- "$(sed -n "/^## $version /,/^## /p" CHANGELOG.md | sed '1d;$d')" >"$stage/${zip:r}.md" +"$sparkle_bin/generate_appcast" --account "$account" --embed-release-notes --maximum-deltas 0 \ + --download-url-prefix "$repo/releases/download/$tag/" --link "$repo/releases/tag/$tag" \ + -o "$PWD/$out/appcast.xml" "$stage" | tee "$out/appcast.log" +rm -rf "$stage" +xmllint --noout "$out/appcast.xml" +grep -q "url=\"$repo/releases/download/$tag/$zip\"" "$out/appcast.xml" || die "the appcast's enclosure doesn't point at $tag" +grep -q "$build" "$out/appcast.xml" || die "the appcast doesn't offer build $build" +grep -q 'sparkle:edSignature=' "$out/appcast.xml" || die "the download isn't signed in the appcast" +grep -q 'sparkle-signatures:' "$out/appcast.xml" || die "the appcast itself isn't signed, and the app requires that" + +step "Checksums" +(cd "$out" && shasum -a 256 "$dmg" >"$dmg.sha256" && shasum -a 256 "$zip" >"$zip.sha256" && shasum -a 256 appcast.xml >appcast.xml.sha256 && cat ./*.sha256) + +print "\nReady in $out. Nothing has been uploaded. To publish, attach these to the release tagged $tag:" +print " $dmg $dmg.sha256 $zip $zip.sha256 appcast.xml appcast.xml.sha256" +print "Installed copies see it once that release is the latest: a draft or prerelease is invisible to them." diff --git a/script/sign_app.sh b/script/sign_app.sh new file mode 100755 index 0000000..ea8fda6 --- /dev/null +++ b/script/sign_app.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Signs a Keydoze.app from the inside out with the given codesign options, Sparkle's helpers first. +# No --deep: it would apply one set of options to everything. +# script/sign_app.sh path/to/Keydoze.app --force --sign IDENTITY [--options runtime] [--timestamp] +set -euo pipefail +app="$1"; shift +sparkle="$app/Contents/Frameworks/Sparkle.framework" +codesign "$@" "$sparkle/Versions/B/Autoupdate" +codesign "$@" "$sparkle/Versions/B/Updater.app" +codesign "$@" "$sparkle" +codesign "$@" "$app"