diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ace77c --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Release configuration for `make local-dmg` and `make dmg`. +# +# Copy to .env.local (gitignored) and fill in the blanks: +# +# cp .env.example .env.local +# +# A variable already set in your shell wins over this file, so CI and one-off +# command lines keep working exactly as before. + +# Sparkle appcast feed. Matches the value already in the Xcode project. +SPARKLE_FEED_URL=https://jonaskahn.github.io/EasyKey/appcast.xml + +# Sparkle EdDSA public key, baked into the app as SUPublicEDKey. +# Deliberately NOT committed: project.pbxproj must keep "$(SPARKLE_PUBLIC_ED_KEY)" +# (see Scripts/test-release-config.sh). Read it out of an installed build with +# /usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' EasyKey.app/Contents/Info.plist +# or copy it from the sparkle-public.key that `make update-secrets` writes locally. +SPARKLE_PUBLIC_ED_KEY= + +# Shown as the support link in the app. +EASYKEY_SUPPORT_URL=https://github.com/jonaskahn/EasyKey + +# Shown as the privacy link in the app. No privacy page exists yet, so this is +# a stand-in — set a real HTTPS URL before a public release. +EASYKEY_PRIVACY_POLICY_URL= + +# --- Required only by `make dmg` (Developer ID signing + notarization) --- +# The signed path needs these; `make local-dmg` does not. `make update-secrets` +# stages the same names as GitHub secrets for CI. +# DEVELOPER_ID_APPLICATION= +# DEVELOPMENT_TEAM= +# APPLE_ID= +# APPLE_TEAM_ID= +# APPLE_APP_SPECIFIC_PASSWORD= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b65e94f..0d59a22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,20 +34,51 @@ jobs: with: xcode-version: latest-stable - # TODO: re-enable Developer ID signing + notarization once Apple cert is available. - # Was: "Import Developer ID certificate" step + `make dmg` (signed/notarized) below. - - name: Build ${{ matrix.target.name }} DMG + - name: Import Developer ID certificate + env: + APPLE_SIGN_P12_BASE64: ${{ secrets.APPLE_SIGN_P12_BASE64 }} + APPLE_SIGN_P12_PASSWORD: ${{ secrets.APPLE_SIGN_P12_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + if [ -z "$APPLE_SIGN_P12_BASE64" ]; then + echo "APPLE_SIGN_P12_BASE64 is not set; run 'make update-secrets' first" >&2 + exit 1 + fi + keychain="$RUNNER_TEMP/signing.keychain-db" + security create-keychain -p actions "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p actions "$keychain" + printf '%s' "$APPLE_SIGN_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_SIGN_P12_PASSWORD" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/cert.p12" + security set-key-partition-list -S apple-tool:,apple:,codesign: -k actions "$keychain" + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity=$(security find-identity -v -p codesigning "$keychain" | grep "Developer ID Application" | head -n 1 | sed 's/.*) \([A-F0-9]*\) .*/\1/') + [ -n "$identity" ] || { echo "no Developer ID identity in the imported certificate" >&2; exit 1; } + { + echo "DEVELOPER_ID_APPLICATION=$identity" + echo "DEVELOPMENT_TEAM=$APPLE_TEAM_ID" + } >> "$GITHUB_ENV" + echo "imported Developer ID identity $identity" + + - name: Build, sign, and notarize ${{ matrix.target.name }} DMG env: SPARKLE_FEED_URL: ${{ vars.SPARKLE_FEED_URL }} SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }} EASYKEY_SUPPORT_URL: ${{ vars.EASYKEY_SUPPORT_URL }} EASYKEY_PRIVACY_POLICY_URL: ${{ vars.EASYKEY_PRIVACY_POLICY_URL }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + # REQUIRED_ARCHS is read from the environment by Scripts/verify-arch.sh + # inside `make dmg`; it is not passed on the make command line. Do not + # drop it from this block. ARCHS: ${{ matrix.target.archs }} REQUIRED_ARCHS: ${{ matrix.target.required_archs }} TARGET_NAME: ${{ matrix.target.name }} TAG_NAME: ${{ github.ref_name }} run: | - make local-dmg + make dmg built_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' build/export/EasyKey.app/Contents/Info.plist) [[ "$TAG_NAME" == "v$built_version" ]] || { printf 'Tag %s does not match built version %s.\n' "$TAG_NAME" "$built_version" >&2 diff --git a/.gitignore b/.gitignore index ac44e1f..8635c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,11 @@ node_modules/ dSYM/ *.dSYM/ -# Local secrets (Sparkle signing keys, etc.) +# Local secrets (Sparkle signing keys, Developer ID exports, etc.) keys/ *.key *.pem +*.p12 +*.p12.b64 +.env.local .nodeterm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d39849..302669e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,26 @@ # Changelog -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ Notable changes to EasyKey. Format follows Keep a Changelog; versioning follows Semantic Versioning. EasyKey requires macOS 14.0 or later; tested OS and Mac minimums are owned by [platform compatibility](docs/reference.md#platform-compatibility) in the compact reference. Supported report scope for a given version is in [SECURITY.md](SECURITY.md). How a version is built and published is in [CONTRIBUTING.md](CONTRIBUTING.md) and [engineering](docs/engineering.md#release). ## [Unreleased] +## [0.0.15] - 2026-10-16 + +**Security** + +- Release DMGs are now signed with a Developer ID certificate and notarized by Apple, so Gatekeeper opens them without the Control-click workaround. The archived app, its login helper and Sparkle are all signed with the hardened runtime, which notarization requires and which `make verify-release` now asserts. + **Fixed** - Vietnamese text typed into Chromium/Electron web-page fields and Safari is now precomposed (NFC) again: these apps had been forced to combining-diacritic output, which rendered correctly but broke website search and find-in-page because sites compare precomposed text. All Unicode-class typing output is now canonically composed at the emission point (legacy TCVN3/VNI Windows/CP1258 output is unchanged), and the user's typing-encoding setting is honored in every app. - The Chromium zero-width-space (and VSCode word-joiner) workaround is now transient — inserted and immediately deleted — instead of being left in the field until the next edit. A leftover invisible character used to travel inside search queries (live filtering while typing, and submits after "query + space + Enter") so site search never matched. +**Added** + +- `make update-secrets` stages the signing, notarization and Sparkle credentials in GitHub for the Release workflow. The Developer ID `.p12` is exported from the keychain rather than pasted, and the Sparkle keypair is read from it instead of regenerated, so a published update-trust key is never rotated by accident. + ## [0.0.14] - 2026-08-27 **Fixed** diff --git a/EasyKey.xcodeproj/project.pbxproj b/EasyKey.xcodeproj/project.pbxproj index af7bdb7..086e593 100644 --- a/EasyKey.xcodeproj/project.pbxproj +++ b/EasyKey.xcodeproj/project.pbxproj @@ -2080,7 +2080,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -2093,7 +2093,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykeyKit; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -2138,7 +2138,7 @@ CODE_SIGN_ENTITLEMENTS = EasyKeyApp/EasyKeyApp.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; EASYKEY_PRIVACY_POLICY_URL = ""; EASYKEY_SUPPORT_URL = "https://github.com/jonaskahn/EasyKey"; GENERATE_INFOPLIST_FILE = NO; @@ -2147,7 +2147,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykey; PRODUCT_NAME = EasyKey; SPARKLE_FEED_URL = "https://jonaskahn.github.io/EasyKey/appcast.xml"; @@ -2177,14 +2177,14 @@ CODE_SIGN_ENTITLEMENTS = EasyKeyLoginHelper/EasyKeyLoginHelper.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = EasyKeyLoginHelper/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykey.LoginHelper; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2212,7 +2212,7 @@ CODE_SIGN_ENTITLEMENTS = EasyKeyApp/EasyKeyApp.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; EASYKEY_PRIVACY_POLICY_URL = ""; EASYKEY_SUPPORT_URL = "https://github.com/jonaskahn/EasyKey"; GENERATE_INFOPLIST_FILE = NO; @@ -2221,7 +2221,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykey; PRODUCT_NAME = EasyKey; SPARKLE_FEED_URL = "https://jonaskahn.github.io/EasyKey/appcast.xml"; @@ -2235,7 +2235,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -2248,7 +2248,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = com.easykey.EasyEngineCore; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -2264,14 +2264,14 @@ CODE_SIGN_ENTITLEMENTS = EasyKeyLoginHelper/EasyKeyLoginHelper.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = EasyKeyLoginHelper/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykey.LoginHelper; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2283,7 +2283,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -2296,7 +2296,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = one.ifelse.easykeyKit; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -2324,7 +2324,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -2337,7 +2337,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = com.easykey.EasyEngineCore; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Makefile b/Makefile index 98652fb..0cbfec3 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ XCODEBUILD = xcodebuild -project "$(PROJECT)" -scheme "$(SCHEME)" -destination " .DEFAULT_GOAL := help -.PHONY: all build release run test test-parallel coverage coverage-parallel coverage-gate build-for-testing lint format clean clean-local clean-derived clean-all qa archive export verify-arch verify-compatibility verify-release dmg local-dmg help +.PHONY: all build release run test test-parallel coverage coverage-parallel coverage-gate build-for-testing lint format clean clean-local clean-derived clean-all qa archive export verify-arch verify-compatibility verify-release dmg local-dmg update-secrets update-secret help # `make` with no args prints grouped help. `make all` still builds. all: build @@ -134,11 +134,10 @@ archive: export: Scripts/export.sh +# Fail early with an actionable message when release configuration is missing. +# Values come from the environment or .env.local (cp .env.example .env.local). release-config-check: - @if [ -z "$$SPARKLE_PUBLIC_ED_KEY" ]; then \ - echo "Error: SPARKLE_PUBLIC_ED_KEY environment variable is not set." >&2; \ - exit 1; \ - fi + @Scripts/check-release-config.sh verify-arch: Scripts/verify-arch.sh @@ -173,6 +172,19 @@ local-dmg: release-config-check RELEASE_LOCAL=1 Scripts/verify-release.sh Scripts/create-dmg.sh "$(BUILD_DIR)/export/EasyKey.app" +# Prompt for release secrets and store them with gh. The Developer ID .p12 is +# exported from the keychain and the Sparkle keypair is read from it; nothing +# is pasted by hand. Empty input at any prompt keeps the existing value. +# ALL=1 re-ask everything +# ARGS=... e.g. ARGS="--list" to audit names without writing anything +update-secrets: + @if [ "$(ALL)" = "1" ]; then Scripts/update-secrets.sh --all $(ARGS); else Scripts/update-secrets.sh $(ARGS); fi + +# Alias — `update-secrets` (plural) is the canonical name, matching the +# reference implementation; the singular spelling is kept because it is the +# one people type first. +update-secret: update-secrets + # --------------------------------------------------------------------------- # Help — grouped by usage. Run `make` (no args) or `make help`. # --------------------------------------------------------------------------- @@ -206,6 +218,7 @@ help: @echo " make local-dmg Universal ad-hoc DMG (no Developer ID, no notarization)" @echo "" @echo " Release (signed / distributed)" + @echo " make update-secrets Upload signing/notarization/Sparkle secrets to GitHub (gh)" @echo " make archive Create signed archive (needs Developer ID env)" @echo " make export Export .app from archive" @echo " make verify-arch Confirm arm64 + x86_64 in exported app" diff --git a/README.md b/README.md index 2a9a0f2..c1af761 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ EasyKey

Fast, private Vietnamese typing for macOS

Download EasyKey - Latest version + Latest version CI status 90% coverage gate

@@ -22,7 +22,7 @@ vieejt nam → việt nam 2. Open the DMG, drag **EasyKey** into **Applications**, and launch it. 3. Grant **Accessibility** access when prompted (required for system-wide typing). -> If macOS blocks the app: Control-click **EasyKey** → **Open**, or use **System Settings → Privacy & Security → Open Anyway**. +Release DMGs are signed with a Developer ID certificate and notarized by Apple, so macOS opens them normally. If you are still running a DMG downloaded before notarization was enabled, re-download the latest release — or Control-click **EasyKey** → **Open**, or use **System Settings → Privacy & Security → Open Anyway**. ## 🖊️ Usage diff --git a/SECURITY.md b/SECURITY.md index 28e19af..a398ff8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ # Security policy -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ -**0.0.14** is the only version in scope for security reports. Report through this repository’s issue tracker, marked as a security report. There is no published private contact, no `security.txt`, and no published safe-harbor commitment. +**0.0.15** is the only version in scope for security reports. Report through this repository’s issue tracker, marked as a security report. There is no published private contact, no `security.txt`, and no published safe-harbor commitment. Technical posture (threat model, data classes, permissions) is owned by compact [security](docs/security.md). Sparkle HTTPS appcast and EdDSA-signed update archives are owned by compact [engineering](docs/engineering.md). @@ -10,8 +10,8 @@ Technical posture (threat model, data classes, permissions) is owned by compact | Version | Supported | |---|---| -| 0.0.14 | yes — current marketing version and the README version badge | -| Older tagged releases | no documented support window; treat only 0.0.14 as in scope | +| 0.0.15 | yes — current marketing version and the README version badge | +| Older tagged releases | no documented support window; treat only 0.0.15 as in scope | In scope for reports: @@ -33,7 +33,7 @@ This repository publishes no issue template and no dedicated private reporting a What to include: -- Reproduction steps, app version (for example 0.0.14), and macOS version +- Reproduction steps, app version (for example 0.0.15), and macOS version - Believed impact, and which data or capability is affected - Configuration that matters (permissions granted, providers configured, persistence enabled) diff --git a/Scripts/archive.sh b/Scripts/archive.sh index 188d893..4d7ad37 100755 --- a/Scripts/archive.sh +++ b/Scripts/archive.sh @@ -2,6 +2,13 @@ set -euo pipefail project_root="${0:A:h:h}" + +# Local release defaults from .env.local (see .env.example). A variable already +# exported in the shell wins, so CI and one-off command lines are unaffected. +release_env_root="$project_root" +# shellcheck source=release-env.sh +source "$project_root/Scripts/release-env.sh" + archive_path="${ARCHIVE_PATH:-$project_root/build/archives/EasyKey.xcarchive}" # Universal binary for Apple Silicon and Intel distribution (choice A). archs="${ARCHS:-arm64 x86_64}" @@ -32,10 +39,17 @@ if [[ "$release_local" == "1" ]]; then done print "Archiving local Release build for $archs (ad-hoc sign, no Developer ID)." + # No hardened runtime here: it enables dyld library validation, which + # requires the process and its frameworks to share a Team ID. An ad-hoc + # signature has none, so the app would exit at launch failing to load + # EasyEngineCore.framework. The hardened runtime is a notarization + # requirement (set on the Developer ID path below); local builds skip + # notarization entirely (see verify-release.sh). xcodebuild "${common_args[@]}" \ CODE_SIGN_STYLE=Automatic \ CODE_SIGN_IDENTITY="-" \ DEVELOPMENT_TEAM="" \ + ENABLE_HARDENED_RUNTIME=NO \ "SPARKLE_FEED_URL=$SPARKLE_FEED_URL" \ "SPARKLE_PUBLIC_ED_KEY=$SPARKLE_PUBLIC_ED_KEY" \ "EASYKEY_SUPPORT_URL=$EASYKEY_SUPPORT_URL" \ @@ -55,10 +69,16 @@ for url in "$SPARKLE_FEED_URL" "$EASYKEY_SUPPORT_URL" "$EASYKEY_PRIVACY_POLICY_U [[ "$url" == https://* ]] || { print -u2 "Release URLs must use HTTPS: $url"; exit 1; } done +# Apple refuses to notarize a Developer ID binary without the hardened +# runtime. Set here rather than in the Xcode project so Debug builds and +# tests keep their current behavior and the pbxproj stays free of +# release-only policy. Applies to every target in the scheme, so nested +# code (LoginHelper, EasyKeyKit, EasyEngineCore, Sparkle) is covered too. xcodebuild "${common_args[@]}" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$DEVELOPER_ID_APPLICATION" \ DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + ENABLE_HARDENED_RUNTIME=YES \ "SPARKLE_FEED_URL=$SPARKLE_FEED_URL" \ "SPARKLE_PUBLIC_ED_KEY=$SPARKLE_PUBLIC_ED_KEY" \ "EASYKEY_SUPPORT_URL=$EASYKEY_SUPPORT_URL" \ diff --git a/Scripts/check-release-config.sh b/Scripts/check-release-config.sh new file mode 100755 index 0000000..0761583 --- /dev/null +++ b/Scripts/check-release-config.sh @@ -0,0 +1,43 @@ +#!/bin/zsh +# Pre-flight for the DMG targets: fail early and say what to fix when release +# configuration is missing. Values come from the environment or .env.local. +set -euo pipefail + +project_root="${0:A:h:h}" +release_env_root="$project_root" +# shellcheck source=release-env.sh +source "$project_root/Scripts/release-env.sh" + +required=( + SPARKLE_PUBLIC_ED_KEY + SPARKLE_FEED_URL + EASYKEY_SUPPORT_URL + EASYKEY_PRIVACY_POLICY_URL +) + +missing=() +for name in "${required[@]}"; do + if [[ -z "${(P)name:-}" ]]; then + missing+=("$name") + fi +done + +if (( ${#missing[@]} > 0 )); then + print -u2 "Missing release configuration: ${missing[*]}" + print -u2 "" + print -u2 " cp .env.example .env.local # then fill in the blanks" + print -u2 "" + print -u2 "or export the names above before running make. See .env.example for" + print -u2 "where each value comes from." + exit 1 +fi + +for name in SPARKLE_FEED_URL EASYKEY_SUPPORT_URL EASYKEY_PRIVACY_POLICY_URL; do + value="${(P)name}" + if [[ "$value" != https://* ]]; then + print -u2 "$name must be an https:// URL, got: $value" + exit 1 + fi +done + +print "Release configuration OK." diff --git a/Scripts/release-env.sh b/Scripts/release-env.sh new file mode 100755 index 0000000..314da7b --- /dev/null +++ b/Scripts/release-env.sh @@ -0,0 +1,21 @@ +#!/bin/zsh +# Loads .env.local into the environment for release builds. Sourced by +# archive.sh and check-release-config.sh — not a standalone entry point. +# +# A variable already set in the shell is never overwritten, so CI and one-off +# command lines keep working exactly as before. +set -euo pipefail + +release_env_root="${release_env_root:-${0:A:h:h}}" + +if [[ -f "$release_env_root/.env.local" ]]; then + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + key="${line%%=*}" + value="${line#*=}" + [[ -z "$key" || "$key" == "$line" ]] && continue + if [[ -z "${(P)key:-}" ]]; then + export "$key=$value" + fi + done < "$release_env_root/.env.local" +fi diff --git a/Scripts/sparkle-keys.sh b/Scripts/sparkle-keys.sh new file mode 100755 index 0000000..8222729 --- /dev/null +++ b/Scripts/sparkle-keys.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Sparkle EdDSA key helpers, sourced by update-secrets.sh. +# +# The keypair lives in the login keychain: generate_keys reuses the existing +# key, so calling it is idempotent and never rotates a published key — but it +# DOES create a keypair when none exists, so callers must check +# sparkle_private_key first unless they mean to mint a new one. Helpers print +# only the requested value on stdout so callers can capture it. +set -euo pipefail + +sparkle_version="2.9.4" +sparkle_sha256="ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9" + +# ensure_sparkle fetches the pinned Sparkle release into a cache outside build/ +# and prints the cache directory. The tarball checksum is verified before it is +# unpacked, and the pin matches publish-appcast.yml / check-sparkle-pin.sh. +ensure_sparkle() { + local cache scratch archive + cache="${EASYKEY_CACHE:-$HOME/Library/Caches/EasyKey}/sparkle-$sparkle_version" + if [ -d "$cache/Sparkle.framework" ]; then + printf '%s' "$cache" + return 0 + fi + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' RETURN + archive="$scratch/sparkle-$sparkle_version.tar.xz" + curl -fSL --retry 3 --progress-bar -o "$archive" \ + "https://github.com/sparkle-project/Sparkle/releases/download/${sparkle_version}/Sparkle-${sparkle_version}.tar.xz" + echo "${sparkle_sha256} $archive" | shasum -a 256 -c - >&2 + mkdir -p "$cache" + tar -xf "$archive" -C "$cache" + [ -d "$cache/Sparkle.framework" ] || { + echo "Sparkle framework missing from $archive" >&2 + return 1 + } + printf '%s' "$cache" +} + +# sparkle_public_key prints the SUPublicEDKey string. Runs generate_keys, which +# reuses the keypair already in the login keychain and creates one if absent. +sparkle_public_key() { + local key + key="$("$(ensure_sparkle)/bin/generate_keys" 2>/dev/null | grep -o '[^<]*' | sed 's/<[^>]*>//g')" + if [ -z "$key" ]; then + echo "could not read the Sparkle public key from generate_keys" >&2 + return 1 + fi + printf '%s' "$key" +} + +# sparkle_private_key prints the Ed25519 private key held in the login keychain +# (Sparkle's generate_keys stores it under this service/account). Fails when no +# key is present, which callers use to avoid minting one by accident. +sparkle_private_key() { + security find-generic-password -s "https://sparkle-project.org" -a "ed25519" -w +} diff --git a/Scripts/update-secrets.sh b/Scripts/update-secrets.sh new file mode 100755 index 0000000..9b30ff5 --- /dev/null +++ b/Scripts/update-secrets.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# Stores release secrets with gh. Every value travels through stdin, never the +# argument list, so a secret never appears in ps output or shell history. Names +# are auto-detected from .github/workflows (secrets.X / vars.X, minus automatic +# tokens like GITHUB_TOKEN); every name is always prompted, empty input skips +# (so existing values are kept unless overwritten). Pass NAMEs to override +# detection ("FOO" = secret, "VAR:FOO" / "vars.FOO" = variable); --list prints +# the names without asking. +# +# Keys that exist locally (Sparkle keypair, Developer ID .p12, Team ID) are read +# at runtime; the script asks before touching them, and the files it writes stay +# under $EASYKEY_SECRET_DIR (default /Volumes/WS/Secret/easykey), never in the +# repo. The .p12 is always exported from your keychain with `security export` — +# nothing is pasted by hand. +# +# The Sparkle EdDSA keypair is the one value that must NOT be regenerated: +# installed apps verify updates against the public key already baked into them. +# Reading the existing key is the default; creating a new keypair requires +# --rotate-sparkle and an explicit confirmation. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=sparkle-keys.sh +. "$root/Scripts/sparkle-keys.sh" + +# Public key currently baked into shipping builds (see Scripts/test-release-config.sh). +# Used only to warn when an upload would replace it. +published_sparkle_public_key="TnOFIWrYd6LbTsYMPCSJq6IKZZhGWgJJ0fsRgTNXFy4=" + +if ! command -v gh >/dev/null 2>&1; then + echo "gh is not installed: brew install gh" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh is not authenticated: run gh auth login first" >&2 + exit 1 +fi + +list_only=0 +rotate_sparkle=0 +only_names="" +for arg in "$@"; do + case "$arg" in + --all) ;; # accepted for compatibility; every name is always prompted now + --list) list_only=1 ;; + --rotate-sparkle) rotate_sparkle=1 ;; + -h|--help) + echo "usage: update-secrets.sh [--all] [--list] [--rotate-sparkle] [NAME...]" >&2 + echo " no NAMEs: auto-detect secrets.X / vars.X from .github/workflows" >&2 + echo " NAMEs: only prompt for those names (VAR:NAME forces a variable)" >&2 + echo " --rotate-sparkle: allow creating a NEW Sparkle keypair" >&2 + echo " (breaks updates for every installed build)" >&2 + exit 0 ;; + --*) echo "usage: update-secrets.sh [--all] [--list] [--rotate-sparkle] [NAME...]" >&2; exit 2 ;; + *) only_names="$only_names $arg" ;; + esac +done +# NAMEs override auto-detection: "FOO" = secret, "VAR:FOO" = variable. +only_names="$(printf '%s' "$only_names" | xargs 2>/dev/null || printf '%s' "$only_names")" + +# Auto-detect every secrets.NAME / vars.NAME referenced by workflows, minus +# automatic tokens (e.g. GITHUB_TOKEN) that never need `gh secret set`. +detect_names() { + grep -rhoE '(secrets|vars)\.[A-Za-z0-9_]+' "$root/.github/workflows" 2>/dev/null \ + | sort -u | grep -v 'GITHUB_TOKEN' || true +} + +if [ -n "$only_names" ]; then + detected="$only_names" +else + detected="$(detect_names)" +fi + +if [ "$list_only" -eq 1 ]; then + printf '%s\n' $detected + exit 0 +fi + +if [ -z "$detected" ]; then + echo "no secrets.X / vars.X found under $root/.github/workflows" >&2 + exit 1 +fi + +existing="$(gh secret list 2>/dev/null | awk '{print $1}')" +existing_vars="$(gh variable list 2>/dev/null | awk '{print $1}')" + +# already_set prints " (already set)" when the name exists, else "". +already_set() { + printf '%s\n' "$existing" | grep -qxF "$1" && printf ' (already set)' || true +} + +already_set_var() { + printf '%s\n' "$existing_vars" | grep -qxF "$1" && printf ' (already set)' || true +} + +# kind_of echoes "secret" or "var" for a name, from what the workflows use. +kind_of() { + local entry + for entry in $detected; do + case "$entry" in + "vars.$1"|"VAR:$1") echo var; return 0 ;; + esac + done + echo secret +} + +# push stores a value as the kind the workflows expect for that name. The value +# is written on stdin only, so it never reaches ps or the shell history. +push() { + local name="$1" value="$2" + if [ "$(kind_of "$name")" = "var" ]; then + printf '%s' "$value" | gh variable set "$name" + echo "set $name variable" + else + printf '%s' "$value" | gh secret set "$name" + echo "set $name" + fi +} + +# read_tty reads one line into the named variable from the terminal, so prompts +# still work when stdin is piped. Falls back to stdin with no tty attached. +read_tty() { + if [ -r /dev/tty ]; then + IFS= read -rs "$1" < /dev/tty + else + IFS= read -rs "$1" + fi +} + +prompt_secret() { + local name="$1" label="$2" value + printf '%s: ' "$label" > /dev/tty 2>/dev/null || printf '%s: ' "$label" + read_tty value + printf '\n' + if [ -z "$value" ]; then + echo "skipping $name (empty)" + return 0 + fi + push "$name" "$value" + unset value +} + +prompt_var() { + prompt_secret "$@" +} + +ask_yes() { + local answer + printf '%s [y/N]: ' "$1" > /dev/tty 2>/dev/null || printf '%s [y/N]: ' "$1" + read_tty answer + printf '\n' + [ "$answer" = "y" ] || [ "$answer" = "Y" ] +} + +# Local copies of what we upload, kept out of the repo and chmod-600. +secret_dir="${EASYKEY_SECRET_DIR:-/Volumes/WS/Secret/easykey}" +if ! mkdir -p "$secret_dir" 2>/dev/null || ! chmod 700 "$secret_dir" 2>/dev/null; then + secret_dir="$HOME/.easykey/secrets" + mkdir -p "$secret_dir" && chmod 700 "$secret_dir" + echo "note: writing local key copies to $secret_dir" +fi + +# read_sparkle pushes the Sparkle keypair already in the login keychain. It +# never creates a keypair: if none exists this refuses unless --rotate-sparkle, +# because a fresh key would make every installed build reject future updates. +read_sparkle() { + local private public + if ! private="$(sparkle_private_key 2>/dev/null)" || [ -z "$private" ]; then + if [ "$rotate_sparkle" -eq 1 ]; then + generate_sparkle + return 0 + fi + echo "no Sparkle EdDSA key found in the login keychain." >&2 + echo "Installed apps trust the key already shipped in them, so this script" >&2 + echo "will not create a new one. Either import the existing key, or pass" >&2 + echo "--rotate-sparkle if you really mean to invalidate every install." >&2 + return 1 + fi + # generate_keys is idempotent: with a key already in the keychain it prints + # that keypair rather than replacing it. + public="$(sparkle_public_key)" + if [ "$public" != "$published_sparkle_public_key" ]; then + echo "WARNING: the Sparkle key in your keychain is not the published one." >&2 + echo " keychain: $public" >&2 + echo " published: $published_sparkle_public_key" >&2 + echo "Uploading it will break updates for builds that trust the published key." >&2 + ask_yes "continue anyway?" || { echo "skipping Sparkle keys"; return 0; } + fi + push SPARKLE_PUBLIC_ED_KEY "$public" + push SPARKLE_PRIVATE_ED_KEY "$private" + mkdir -p "$secret_dir" && chmod 700 "$secret_dir" + printf '%s' "$public" > "$secret_dir/sparkle-public.key" + printf '%s' "$private" > "$secret_dir/sparkle-private.key" + chmod 600 "$secret_dir/sparkle-private.key" + echo "wrote $secret_dir/sparkle-public.key and sparkle-private.key" +} + +generate_sparkle() { + local public private + echo "NOTE: creating a NEW Sparkle keypair. Builds already installed trust" >&2 + echo "the old key and will reject every update signed with this one." >&2 + ask_yes "type y only if no EasyKey build is installed anywhere" || { + echo "skipping Sparkle keys" + return 0 + } + public="$(sparkle_public_key)" + private="$(sparkle_private_key)" + push SPARKLE_PUBLIC_ED_KEY "$public" + push SPARKLE_PRIVATE_ED_KEY "$private" + mkdir -p "$secret_dir" && chmod 700 "$secret_dir" + printf '%s' "$public" > "$secret_dir/sparkle-public.key" + printf '%s' "$private" > "$secret_dir/sparkle-private.key" + chmod 600 "$secret_dir/sparkle-private.key" + echo "wrote $secret_dir/sparkle-public.key and sparkle-private.key" +} + +# export_p12 exports the Developer ID identity as .p12 from the keychain, +# base64-encodes it for the CI secret, and derives the Team ID from the +# certificate itself. Nothing is pasted by hand. +export_p12() { + local password p12 b64 team + password="$(openssl rand -base64 24)" + p12="$secret_dir/easykey-signing.p12" + b64="$secret_dir/easykey-signing.p12.b64" + mkdir -p "$secret_dir" && chmod 700 "$secret_dir" + security export -k "${KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}" \ + -t identities -f pkcs12 -o "$p12" -P "$password" + chmod 600 "$p12" + base64 -i "$p12" -o "$b64" + chmod 600 "$b64" + printf '%s' "$password" > "$secret_dir/p12-password.txt" + chmod 600 "$secret_dir/p12-password.txt" + gh secret set APPLE_SIGN_P12_BASE64 < "$b64" + echo "set APPLE_SIGN_P12_BASE64" + printf '%s' "$password" | gh secret set APPLE_SIGN_P12_PASSWORD + echo "set APPLE_SIGN_P12_PASSWORD" + # Derive the Team ID from the certificate's OU. A missing or unparseable + # certificate must not abort the run under `set -e`/`pipefail` — the caller + # can still set APPLE_TEAM_ID at the prompt below. + team="$( { security find-certificate -c "Developer ID Application" -p 2>/dev/null \ + || true; } | { openssl x509 -noout -subject -nameopt multiline 2>/dev/null \ + || true; } | awk -F'= ' '/organizationalUnitName/ {print $2; exit}' || true)" + if [ -n "$team" ]; then + printf '%s' "$team" | gh secret set APPLE_TEAM_ID + echo "set APPLE_TEAM_ID ($team)" + printf '%s' "$team" > "$secret_dir/apple-team-id.txt" + else + echo "could not read the Team ID from the certificate; you will be prompted for APPLE_TEAM_ID" >&2 + fi +} + +if [ -z "$only_names" ]; then + if ask_yes "read the Sparkle keypair from the login keychain and set it as secrets?"; then + # Not fatal: a missing key only skips this pair, the rest still uploads. + read_sparkle || echo "skipped the Sparkle keys" + else + prompt_secret SPARKLE_PRIVATE_ED_KEY "Sparkle EdDSA private key$(already_set SPARKLE_PRIVATE_ED_KEY) (empty to skip)" + prompt_secret SPARKLE_PUBLIC_ED_KEY "Sparkle EdDSA public key$(already_set SPARKLE_PUBLIC_ED_KEY) (empty to skip)" + fi + + if ask_yes "export the Developer ID .p12 from the keychain and set it as secrets?"; then + export_p12 + else + prompt_secret APPLE_SIGN_P12_BASE64 "Developer ID .p12 as base64$(already_set APPLE_SIGN_P12_BASE64) (empty to skip)" + prompt_secret APPLE_SIGN_P12_PASSWORD ".p12 password$(already_set APPLE_SIGN_P12_PASSWORD) (empty to skip)" + prompt_secret APPLE_TEAM_ID "Apple Team ID$(already_set APPLE_TEAM_ID) (empty to skip)" + fi +fi + +prompt_detected() { + local entry kind name label + for entry in $detected; do + case "$entry" in + secrets.*) kind=secret; name="${entry#secrets.}" ;; + vars.*) kind=var; name="${entry#vars.}" ;; + VAR:*) kind=var; name="${entry#VAR:}" ;; + *) kind=secret; name="$entry" ;; + esac + # Sparkle / Apple signing have local readers above; the generic loop only + # handles the rest so each name is asked for exactly once. + case "$name" in + SPARKLE_PRIVATE_ED_KEY|SPARKLE_PUBLIC_ED_KEY|APPLE_SIGN_P12_BASE64|APPLE_SIGN_P12_PASSWORD|APPLE_TEAM_ID) continue ;; + esac + label="$name (empty to skip)" + case "$name" in + APPLE_APP_SPECIFIC_PASSWORD) label="app-specific password (empty to skip)" ;; + APPLE_ID) label="Apple ID email (empty to skip)" ;; + SPARKLE_FEED_URL) label="HTTPS Sparkle appcast URL (empty to skip)" ;; + EASYKEY_SUPPORT_URL) label="HTTPS support URL (empty to skip)" ;; + EASYKEY_PRIVACY_POLICY_URL) label="HTTPS privacy-policy URL (empty to skip)" ;; + esac + if [ "$kind" = "var" ]; then + prompt_var "$name" "$label$(already_set_var "$name")" + else + prompt_secret "$name" "$label$(already_set "$name")" + fi + done +} + +prompt_detected + +echo "done; verify with: gh secret list && gh variable list" diff --git a/Scripts/verify-release.sh b/Scripts/verify-release.sh index 14ed2a9..ef962aa 100755 --- a/Scripts/verify-release.sh +++ b/Scripts/verify-release.sh @@ -11,10 +11,36 @@ release_local="${RELEASE_LOCAL:-0}" "$project_root/Scripts/verify-arch.sh" "$app_path" "$project_root/Scripts/verify-macos-compatibility.sh" "$app_path" +# Ad-hoc signed code cannot use the hardened runtime: it enables dyld library +# validation, which requires the process and its frameworks to share a Team ID, +# and an ad-hoc signature has none. The app then dies at launch failing to load +# its own EasyEngineCore.framework. Catch it here rather than on double-click. +main_binary="$app_path/Contents/MacOS/EasyKey" +if [[ -f "$main_binary" ]]; then + main_sig="$(codesign -d --verbose=2 "$main_binary" 2>&1)" + # Match the hardened runtime in the CodeDirectory flags. The flag group is + # "(runtime)" under a real signing identity but "(adhoc,runtime)" when + # ad-hoc, so match the token rather than the whole parenthesised group. + if print -- "$main_sig" | grep -qE 'flags=0x[0-9a-f]+\([a-z,]*runtime' \ + && print -- "$main_sig" | grep -q 'TeamIdentifier=not set'; then + print -u2 "App is ad-hoc signed but enables the hardened runtime; it will not launch." + print -u2 "Ad-hoc signatures carry no Team ID, so dyld library validation rejects the" + print -u2 "app's own embedded frameworks. Disable the hardened runtime for local" + print -u2 "builds (see Scripts/archive.sh)." + exit 1 + fi +fi + if [[ "$release_local" == "1" ]]; then print "Local release: skipping Developer ID codesign/spctl assessment." else codesign --verify --deep --strict --verbose=2 "$app_path" + # Notarization requires the hardened runtime; a build that loses it would + # still pass codesign --verify but be rejected by Apple and by Gatekeeper. + if ! codesign -d --verbose=2 "$app_path" 2>&1 | grep -q '(runtime)'; then + print -u2 "App is not signed with the hardened runtime; notarization would fail." + exit 1 + fi spctl --assess --type execute --verbose=4 "$app_path" if [[ -n "$dmg_path" ]]; then [[ -f "$dmg_path" ]] || { print -u2 "DMG not found: $dmg_path"; exit 1; } diff --git a/docs/architecture.md b/docs/architecture.md index c80103f..8d5c2ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ **In one sentence:** EasyKey composes Vietnamese in other macOS apps through a session CGEvent tap, and optionally keeps clipboard history, macros, and translation (on-device Apple Translation or opt-in cloud HTTPS). @@ -66,14 +66,14 @@ flowchart LR **Failure domains** (multi-process: host, helper, Sparkle, remote feeds): Accessibility/tap down degrades typing and selected-text capture; Clipboard persistence errors fail closed on load while RAM capture can continue; cloud translation errors stay in the panel; Sparkle absence skips background updates; login helper failure affects launch-at-login only. -**Deployment.** Execution spans a developer Mac, the CI pipeline (`macos-15` and tag `local-dmg`), the HTTPS appcast host, the release DMG host, and the end-user Mac (deployment target macOS 14). Omitted: container orchestration — this is a desktop app, not a multi-node service mesh. +**Deployment.** Execution spans a developer Mac, the CI pipeline (`macos-15` and tag `dmg`), the HTTPS appcast host, the release DMG host, and the end-user Mac (deployment target macOS 14). Omitted: container orchestration — this is a desktop app, not a multi-node service mesh. ```mermaid flowchart LR accTitle: Where EasyKey binaries run accDescr: Developers and the CI pipeline produce artifacts; the appcast and release archive feed Sparkle on the user's Mac. Dev["Developer Mac"] -->|"xcodebuild / make"| CI["the CI pipeline"] - CI -->|"unsigned local-dmg on tag"| Rel["the release archive host"] + CI -->|"signed + notarized dmg on tag"| Rel["the release archive host"] CI -->|"appcast"| Pages["the appcast host"] UserMac["User Mac EasyKey.app"] -->|"Sparkle HTTPS"| Pages UserMac -->|"download DMG"| Rel @@ -234,7 +234,7 @@ Build/tooling (Xcode, Swift 5, the CI pipeline, make) is summarized: CI cannot c |---|---|---|---| | Login helper Team ID sentinel | Release helper compares team to `TEAMID12345` | Signed helper with a real Team ID can terminate at launch | Replace sentinel with the shipping Team ID or drop the check | | `system.checkForUpdates` unused by Sparkle | Toggle persists but UpdateService always starts the updater when configured | Users who switch the toggle off still get Sparkle start; button still checks | Wire automatic checks to the setting or remove the toggle | -| CI tag uses `make local-dmg` | Unsigned CI artifact vs local notarized `make dmg` | Testers may install a different trust path than production | Align workflows or document as the only supported channel | +| CI tag signing secrets | Signing depends on GitHub secrets staged by `make update-secrets` | A tag pushed without them fails at the import step, before any artifact | Fail fast is intended; stage secrets before cutting a release | | GitNexus labels many app symbols EasyKeyTests | Indexer module names follow TEST_HOST | Impact analysis mis-attributes ownership | Treat path as source of truth | -Fowler framing: Team ID and unused toggle are inadvertent/reckless relative to the intended product; CI unsigned DMG is deliberate/prudent for hosted runners that cannot notarize. User-visible “everything on your Mac” wording versus Sparkle/cloud is a documentation limitation owned by [product.md](product.md) and [security.md](security.md), not a code shortcut. +Fowler framing: Team ID and unused toggle are inadvertent/reckless relative to the intended product. User-visible “everything on your Mac” wording versus Sparkle/cloud is a documentation limitation owned by [product.md](product.md) and [security.md](security.md), not a code shortcut. diff --git a/docs/engineering.md b/docs/engineering.md index bf04d77..719057f 100644 --- a/docs/engineering.md +++ b/docs/engineering.md @@ -1,12 +1,12 @@ # Engineering -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ This file is the compact engineering section for people who clone EasyKey, change it, and ship a DMG. It answers how you get a running checkout, how you run tests, how you cut a version, and how the the release archive plus Sparkle appcast actually get published. Product behavior lives in [product.md](product.md); deployable structure lives in [architecture.md](architecture.md); CI versus operator notarization environments live in [operations.md](operations.md). ## At a glance -Clone the repo, Debug-build EasyKey.app with Xcode via Make, then run the serial test suite before you change anything. CI on `main` and pull requests lint, build Core and Kit alone, shard tests on `macos-15`, and enforce 90% line coverage excluding the login helper. Shipping a user-visible version is SemVer in the Xcode marketing version, a `v*` tag that must match that version, CI `make local-dmg` (unsigned), a draft the release archive, then Sparkle 2.9.4 appcast publish when the release goes public. Notarized Developer ID output is a separate operator path: `make dmg`. +Clone the repo, Debug-build EasyKey.app with Xcode via Make, then run the serial test suite before you change anything. CI on `main` and pull requests lint, build Core and Kit alone, shard tests on `macos-15`, and enforce 90% line coverage excluding the login helper. Shipping a user-visible version is SemVer in the Xcode marketing version, a `v*` tag that must match that version, CI `make dmg` (Developer ID signed and notarized), a draft the release archive, then Sparkle 2.9.4 appcast publish when the release goes public. `make dmg` on an operator Mac is the same signed path for local reproduction; `make local-dmg` is the ad-hoc build for iteration. Reading paths: [Setup](#setup) for a working app; [Testing](#testing) when checks are red; [Release](#release) to cut a version; [Publishing](#publishing) for DMG identity, Sparkle signing, and rollback of a bad artifact. [contributing.md](contributing.md) owns who reviews; [operations.md](operations.md) owns where CI runners and operator machines differ. @@ -29,7 +29,7 @@ This file owns the verified contributor path: prerequisites to a launched Debug 2. Run `make build` — verify: xcodebuild exits 0 and `build/Build/Products/Debug/EasyKey.app` exists (`PROJECT=EasyKey.xcodeproj`, `SCHEME=EasyKeyApp`, `DESTINATION=platform=macOS`, Debug). 3. Run `make run` — verify: macOS opens that Debug app (menu-bar accessory). Grant Accessibility when the app asks; typing in other apps will not work until that is on. -**Configuration:** none for a Debug run. Sparkle feed URL, EdDSA public key, support URL, and privacy-policy URL are required only for `make local-dmg` / `make dmg` (HTTPS). Do not put secret values in the project; name the environment variables only (`SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, `EASYKEY_SUPPORT_URL`, `EASYKEY_PRIVACY_POLICY_URL`). +**Configuration:** none for a Debug run. Sparkle feed URL, EdDSA public key, support URL, and privacy-policy URL are required only for `make local-dmg` / `make dmg` (HTTPS). Do not put secret values in the project; name the environment variables only (`SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, `EASYKEY_SUPPORT_URL`, `EASYKEY_PRIVACY_POLICY_URL`). `make dmg` additionally needs `DEVELOPER_ID_APPLICATION`, `DEVELOPMENT_TEAM`, `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`; `make update-secrets` stages those for CI in GitHub, and you can use the same names as local environment variables. **Symptom → recovery** @@ -68,14 +68,14 @@ Release gates (tag, DMG, coverage job `needs: test`) are owned by [Release](#rel ## Release -**Version scheme:** Semantic Versioning as stated in the changelog (Keep a Changelog). User-facing version is `MARKETING_VERSION` / `CFBundleShortVersionString` (current **0.0.14**). Sparkle also records `CFBundleVersion` (`CURRENT_PROJECT_VERSION`; currently **13** — bump it when you ship a new binary even if marketing stays). **Major:** incompatible change to the public contract (the repo does not list extra major triggers). **Minor:** backward-compatible additions (changelog `Added`). **Patch:** backward-compatible fixes (changelog `Fixed`). Tags must be `v` plus the built short version (`v0.0.14`). +**Version scheme:** Semantic Versioning as stated in the changelog (Keep a Changelog). User-facing version is `MARKETING_VERSION` / `CFBundleShortVersionString` (current **0.0.15**). Sparkle also records `CFBundleVersion` (`CURRENT_PROJECT_VERSION`; currently **14** — bump it when you ship a new binary even if marketing stays). **Major:** incompatible change to the public contract (the repo does not list extra major triggers). **Minor:** backward-compatible additions (changelog `Added`). **Patch:** backward-compatible fixes (changelog `Fixed`). Tags must be `v` plus the built short version (`v0.0.15`). -1. **Prerequisites:** change is on a branch CI will accept; `main` CI is green (lint, structure, test shards except known-broken, coverage 90%). Set HTTPS Sparkle public feed and public EdDSA key plus support/privacy URLs in the environment before any DMG target (`make dmg` and `make local-dmg` both run `release-config-check` and fail if `SPARKLE_PUBLIC_ED_KEY` is empty). Operator notarization additionally needs Developer ID identity and team (unsigned CI does not). +1. **Prerequisites:** change is on a branch CI will accept; `main` CI is green (lint, structure, test shards except known-broken, coverage 90%). Set HTTPS Sparkle public feed and public EdDSA key plus support/privacy URLs in the environment before any DMG target (`make dmg` and `make local-dmg` both run `release-config-check` and fail if `SPARKLE_PUBLIC_ED_KEY` is empty). Signing additionally needs a Developer ID identity, team and notarization credentials — GitHub secrets for the CI Release job (`make update-secrets` stages them), environment variables when you run `make dmg` locally. `make local-dmg` needs none of that. 2. **Version bump:** set `MARKETING_VERSION` (and `CURRENT_PROJECT_VERSION` when the build number should move) in the Xcode project so every target’s Info.plist still uses `$(MARKETING_VERSION)` — verify: `PlistBuddy` on a built app prints the intended short version. 3. **Build (unsigned universal, no secrets):** `make release` — verify: Release configuration, `ONLY_ACTIVE_ARCH=NO`, `ARCHS="arm64 x86_64"`, xcodebuild succeeds. This is not the the release archive. 4. **Verification (contributor / CI):** `make coverage` and `make qa` — verify: coverage gate passed; QA gate passed. Maintainer owns merge to `main`. CI coverage job is the merge gate (`COVERAGE_THRESHOLD: "90"`). -5. **Publication (CI path, current workflows):** tag `v*` and push — verify: Release workflow runs `make local-dmg` with `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, `EASYKEY_SUPPORT_URL`, `EASYKEY_PRIVACY_POLICY_URL`, optional `ARCHS` / `REQUIRED_ARCHS` / `TARGET_NAME`; built short version equals the tag without the `v`; `gh release create` uploads `build/EasyKey-*.dmg` as a **draft** with generated notes. Publish the the release archive when the DMG is the one you want users to see — verify: release type `released` starts Publish Appcast. -6. **Publication (operator notarized path):** `make dmg` — verify: archive + export, `verify-arch`, `codesign --verify --deep --strict --verbose=2`, notarize and staple app, `create-dmg`, notarize and staple DMG, `verify-release` on app and DMG. CI does **not** run `make dmg` today (workflow TODO: re-enable Developer ID once the Apple cert is available). +5. **Publication (CI path, current workflows):** tag `v*` and push — verify: Release workflow imports the Developer ID certificate into a throwaway keychain, then runs `make dmg` with `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, `EASYKEY_SUPPORT_URL`, `EASYKEY_PRIVACY_POLICY_URL`, `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, optional `ARCHS` / `REQUIRED_ARCHS` / `TARGET_NAME`; built short version equals the tag without the `v`; `gh release create` uploads `build/EasyKey-*.dmg` as a **draft** with generated notes. Publish the the release archive when the DMG is the one you want users to see — verify: release type `released` starts Publish Appcast. +6. **Publication (operator path):** `make dmg` with the same credentials as environment variables — verify: archive + export, `verify-arch`, `codesign --verify --deep --strict --verbose=2`, hardened-runtime flag present, notarize and staple app, `create-dmg`, notarize and staple DMG, `verify-release` on app and DMG. This is the same path the CI Release job runs, for local reproduction; it is not a separate distribution channel. 7. **Rollback:** triggered by a bad draft or a bad published build (wrong version, failed arch, Gatekeeper failure, broken Sparkle enclosure). **Draft:** do not publish; delete or supersede the draft release — verify: no public tag assets, appcast unchanged. **Published:** do not yank by an evidenced registry command; ship the next SemVer (`patch` or `minor`) with a new tag so Sparkle sees a newer item — verify: new tag’s short version matches, new draft, then published appcast item. Changelog content stays in the changelog file, not here. ## Publishing @@ -94,7 +94,7 @@ EasyEngineCore and EasyKeyKit are Xcode frameworks **embedded in EasyKey.app**. **Build, sign, publish** -1. Build — `make local-dmg` (CI/unsigned) or `make dmg` (Developer ID + notarize) — verify: `build/export/EasyKey.app` exists; local path prints that it skips Developer ID `codesign`/`spctl`; notarized path runs deep codesign verify. +1. Build — `make dmg` (Developer ID + notarize, what CI runs on tags) or `make local-dmg` (ad-hoc, local iteration only) — verify: `build/export/EasyKey.app` exists; local path prints that it skips Developer ID `codesign`/`spctl`; signed path runs deep codesign verify plus the hardened-runtime flag check. 2. Sign (Sparkle EdDSA, not Apple notarization) — Publish Appcast runs Sparkle **2.9.4** `sign_update` with the private EdDSA key from the secret mechanism `SPARKLE_PRIVATE_ED_KEY` (never put the key in git) — verify: `sign_update` prints signatures for each `EasyKey-*.dmg`; tarball checksum is pinned in that workflow. 3. Publish channel — `gh release create` (draft) then human publish; appcast enclosure URL is `the project repository URL — verify: exactly one `EasyKey-*-universal.dmg` downloaded for appcast; tag still matches mounted app short version; `git push` on `gh-pages` commits `appcast.xml`. diff --git a/docs/operations.md b/docs/operations.md index c5276ac..d2f874e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,12 +1,12 @@ # Operations -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ -Treat CI green and a the release archive draft as **hosted-runner trust**, not as proof that Accessibility, onboarding UI, or Apple TranslationSession tests passed on a real Mac, and not as proof the DMG is Developer ID–signed or notarized. This file is the compact operations section for operators and engineers who ship EasyKey.app: how a change reaches a DMG and Sparkle appcast, what you can actually observe, and which failure modes are named but not written as procedures. Local checkout setup and everyday test commands belong in the engineering section; threat and secret handling belong in the security section. +Treat CI green and a the release archive draft as **hosted-runner trust**, not as proof that Accessibility, onboarding UI, or Apple TranslationSession tests passed on a real Mac. Signing is a separate claim: the Release workflow runs `make dmg`, whose `verify-release` step asserts Developer ID signing, the hardened runtime, notarization stapling and `spctl` acceptance, so a green Release job is evidence of those — not of real-Mac behavior. This file is the compact operations section for operators and engineers who ship EasyKey.app: how a change reaches a DMG and Sparkle appcast, what you can actually observe, and which failure modes are named but not written as procedures. Local checkout setup and everyday test commands belong in the engineering section; threat and secret handling belong in the security section. ## At a glance -A change lands on `main` through the CI workflow (lint, standalone Core/Kit builds, sharded tests, 90% coverage). Shipping a user-visible build is a **tag `v*` → Release workflow (`make local-dmg`) → draft the release archive → a human publishes the release → Publish Appcast writes `appcast.xml` on `gh-pages`**. Installed apps then pull that HTTPS feed through Sparkle. Two trust paths stay distinct: hosted `macos-15` runners decide merge, and an operator Mac with a logged-in GUI session is the only place Accessibility/window-activation and TranslationSession tests are expected to be real. Signed notarized distribution is `make dmg` on a machine that holds Developer ID secrets; the Release workflow does **not** run that path. +A change lands on `main` through the CI workflow (lint, standalone Core/Kit builds, sharded tests, 90% coverage). Shipping a user-visible build is a **tag `v*` → Release workflow (`make dmg`) → draft the release archive → a human publishes the release → Publish Appcast writes `appcast.xml` on `gh-pages`**. Installed apps then pull that HTTPS feed through Sparkle. Two trust paths stay distinct: hosted `macos-15` runners decide merge, and an operator Mac with a logged-in GUI session is the only place Accessibility/window-activation and TranslationSession tests are expected to be real. The Release workflow imports the Developer ID certificate from GitHub secrets and signs and notarizes every DMG; `make dmg` on an operator Mac runs the identical path for local reproduction. Reading paths: [Deployment](#deployment) for environments and rollback; [Observability](#observability) for signals and blind spots; [Distribution](#distribution) for the release archive host and Sparkle; [Runbook index](#runbook-index) for named incidents. Parent index: [Documentation](README.md). Written sibling for structure: [architecture.md](architecture.md). @@ -18,13 +18,13 @@ This file owns CI merge gates, the coverage gate, DMG artifact paths, the releas **Guarantee.** Merge trusts blocking CI shards plus the 90% coverage job. User-facing shipping trusts a published the release archive plus a pushed appcast. Those are not the same gate. -**Environments (name every one, then detail).** the CI pipeline CI (`macos-15`, push/PR to `main`). the CI pipeline Release (`macos-15` tag `v*`, then Ubuntu to attach assets). Operator Mac (signed `make dmg`). Installed user Mac (Sparkle client). There is no blue-green, canary, or rolling fleet: CI is merge-to-`main`; Release is **gated draft-then-publish**; Sparkle is a **single appcast** (broadcast). +**Environments (name every one, then detail).** the CI pipeline CI (`macos-15`, push/PR to `main`). the CI pipeline Release (`macos-15` tag `v*`, then Ubuntu to attach assets). Operator Mac (signed `make dmg`, same path CI runs). Installed user Mac (Sparkle client). There is no blue-green, canary, or rolling fleet: CI is merge-to-`main`; Release is **gated draft-then-publish**; Sparkle is a **single appcast** (broadcast). **CI (`macos-15`).** Artifact is the checkout plus `xcodebuild` products under `build/`. Rollout is merge to `main` (concurrency group `ci-*`, cancel-in-progress). Jobs: `lint` (`swiftformat --lint .`, `swiftlint lint`); `structure` (test-target registration script, standalone EasyEngineCore then EasyKeyKit Debug builds); `test` matrix (four unit shards, five UI shards, plus `ui-known-broken-on-hosted-runner`); `coverage` after all test shards (threshold `90`, LoginHelper lines excluded). Each blocking test shard: 60-minute job timeout, per-test allowance 600s, **no** retry-on-failure, fail if the shard executed zero tests. Verify: every blocking shard green; coverage prints `Coverage gate passed (>= 90%)`. Rollback: revert the merge commit and re-run CI. **Do not treat a green merge as AX/onboarding/TranslationSession proof** — shard `ui-known-broken-on-hosted-runner` uses `continue-on-error` so those tests never block merge. -**Release (tag `v*`).** Artifact is `make local-dmg` (ad-hoc sign, `RELEASE_LOCAL=1` archive/export, no Developer ID, no notarization). Matrix: universal (`arm64 x86_64`), arm64, amd64 (`x86_64`); non-universal DMGs are renamed off the `-universal` suffix. Requires `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, support and privacy HTTPS URLs. Verify: `CFBundleShortVersionString` equals the tag without the `v` prefix; upload `EasyKey-*-{universal,arm64,amd64}.dmg`. Rollout: `gh release create --draft`. Rollback: leave the draft unpublished or delete the draft; do not fire Publish Appcast. The workflow comments that Developer ID signing and `make dmg` are **not** re-enabled. +**Release (tag `v*`).** Artifact is `make dmg` (Developer ID signed, hardened runtime, notarized and stapled). Matrix: universal (`arm64 x86_64`), arm64, amd64 (`x86_64`); non-universal DMGs are renamed off the `-universal` suffix. Requires `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_ED_KEY`, support and privacy HTTPS URLs, plus the signing/notarization secrets listed under [Distribution](#distribution). The job first runs **Import Developer ID certificate**, which decodes `APPLE_SIGN_P12_BASE64` into a throwaway keychain under `$RUNNER_TEMP`, unlocks it for `codesign`, and exports `DEVELOPER_ID_APPLICATION` (the certificate SHA-1) and `DEVELOPMENT_TEAM` for the build step. Verify: `CFBundleShortVersionString` equals the tag without the `v` prefix; upload `EasyKey-*-{universal,arm64,amd64}.dmg`; `verify-release` inside `make dmg` prints `Release verification passed` after `codesign --verify`, the hardened-runtime flag check, `spctl --assess --type execute` and stapler/`spctl` checks on the DMG. Rollout: `gh release create --draft`. Rollback: leave the draft unpublished or delete the draft; do not fire Publish Appcast. A missing secret fails the import step with `run 'make update-secrets' first`, so no unsigned artifact can reach the draft. -**Operator Mac (signed).** Run `make dmg` only with Developer ID, team, Sparkle public key, and notarization secrets. Path: `release-config-check` → archive → export → arch check → `codesign --verify --deep --strict` → notarize app → staple app → create DMG → notarize DMG → staple DMG → `verify-release`. Verify: that command prints `Release verification passed` and `spctl`/stapler checks succeed. Rollback: do not upload; keep the previous published DMG as the channel artifact. Incident recovery after a bad **published** feed is not a deploy step (register below). +**Operator Mac (signed).** Run `make dmg` with the same credentials CI uses, supplied as environment variables instead of GitHub secrets (`DEVELOPER_ID_APPLICATION`, `DEVELOPMENT_TEAM`, `SPARKLE_PUBLIC_ED_KEY`, `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, plus the Sparkle/support/privacy URLs). Path: `release-config-check` → archive → export → arch check → `codesign --verify --deep --strict` → notarize app → staple app → create DMG → notarize DMG → staple DMG → `verify-release`. Verify: that command prints `Release verification passed` and `spctl`/stapler checks succeed. This is a local reproduction of the CI Release job, not a separate distribution path. Rollback: do not upload; keep the previous published DMG as the channel artifact. Incident recovery after a bad **published** feed is not a deploy step (register below). **Installed Mac.** No server rollout. Verify: Sparkle starts only when the bundle has HTTPS `SUFeedURL` and a non-placeholder `SUPublicEDKey`, and never from a test process. Rollback: install the previous the release archive DMG; Sparkle does not auto-rollback. @@ -47,11 +47,13 @@ This file owns CI merge gates, the coverage gate, DMG artifact paths, the releas ## Distribution -**Channels in use.** (1) Internal/ad-hoc DMG — operator `make local-dmg` and the Release workflow. (2) Direct download — the release archive host DMGs. (3) In-app Sparkle — the appcast host `appcast.xml` after a **published** (not draft) release. No App Store or TestFlight channel is evidenced. Authorized roles: a repo actor who can push `v*` tags and publish a release; the CI pipeline (`contents: write`) creates the draft, signs Sparkle enclosures with `SPARKLE_PRIVATE_ED_KEY`, and the CI bot commits the appcast; Developer ID/`make dmg` stays with whoever holds Apple notarization secrets (not CI today). Never put key material in docs. +**Channels in use.** (1) Internal/ad-hoc DMG — operator `make local-dmg`, used for local smoke builds only. (2) Direct download — the release archive host DMGs. (3) In-app Sparkle — the appcast host `appcast.xml` after a **published** (not draft) release. No App Store or TestFlight channel is evidenced. Authorized roles: a repo actor who can push `v*` tags and publish a release; the CI pipeline (`contents: write`) imports the Developer ID certificate, signs and notarizes each DMG, creates the draft, signs Sparkle enclosures with `SPARKLE_PRIVATE_ED_KEY`, and the CI bot commits the appcast. The Apple material itself lives in GitHub secrets, staged with `make update-secrets` by whoever holds the Developer ID identity and the notarization credentials. Never put key material in docs. -**Ad-hoc / CI DMG.** Build: `make local-dmg` with HTTPS Sparkle feed URL, public EdDSA key, support URL, privacy URL. Sign: ad-hoc (`CODE_SIGN_IDENTITY=-`). Package: `create-dmg` → `build/EasyKey--universal.dmg` (arch matrix may rename). Publish: CI uploads workflow artifacts only until `gh release create --draft`. Verify: tag matches short version; `verify-release` in `RELEASE_LOCAL=1` skips Developer ID/`spctl`/stapler. Update: cut a new tag. Rollback: do not publish the draft. +**Secrets staging (`make update-secrets`).** Names are auto-detected from `.github/workflows`, so a workflow rename is picked up automatically. `make update-secrets --list` audits the expected names without writing anything; `make update-secrets` prompts for each (empty input keeps the existing value). The Developer ID `.p12` is **exported from the login keychain** with `security export` (overridable with `KEYCHAIN=`) and never pasted; the Sparkle keypair is **read** from the keychain and only regenerated under `--rotate-sparkle`, because installed apps trust the public key already shipped to them. Local copies land under `$EASYKEY_SECRET_DIR` (default `/Volumes/WS/Secret/easykey`) with mode 600. Expected inventory: `APPLE_SIGN_P12_BASE64`, `APPLE_SIGN_P12_PASSWORD`, `APPLE_TEAM_ID`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `SPARKLE_PRIVATE_ED_KEY`, `SPARKLE_PUBLIC_ED_KEY` (secrets); `SPARKLE_FEED_URL`, `EASYKEY_SUPPORT_URL`, `EASYKEY_PRIVACY_POLICY_URL` (variables). Rotation is a re-run plus the new value typed at its prompt. -**the release archive host (direct download).** Build/package: same Release workflow DMGs (universal is what Sparkle consumes). Sign: still ad-hoc on CI; operator `make dmg` is the notarized path when certs exist. Publish: convert the draft to a public release (human). Verify: `gh release download` yields exactly one `EasyKey-*-universal.dmg` for appcast. Update: publish a newer tag. Rollback: keep the previous public DMG; do not point the appcast at the bad tag (or revert the appcast commit). +**Ad-hoc / CI DMG.** Build: `make local-dmg` with HTTPS Sparkle feed URL, public EdDSA key, support URL, privacy URL. Sign: ad-hoc (`CODE_SIGN_IDENTITY=-`). Package: `create-dmg` → `build/EasyKey--universal.dmg` (arch matrix may rename). Publish: CI uploads workflow artifacts only until `gh release create --draft`. Verify: tag matches short version; `verify-release` in `RELEASE_LOCAL=1` skips Developer ID/`spctl`/stapler. Update: cut a new tag. Rollback: do not publish the draft. This channel is for local iteration — it is not what ships to users. + +**the release archive host (direct download).** Build/package: same Release workflow DMGs (universal is what Sparkle consumes). Sign: Developer ID with hardened runtime, notarized and stapled on CI via `make dmg`; `verify-release` asserts the signature, the runtime flag, `spctl` and the stapled ticket. Publish: convert the draft to a public release (human). Verify: `gh release download` yields exactly one `EasyKey-*-universal.dmg` for appcast. Update: publish a newer tag. Rollback: keep the previous public DMG; do not point the appcast at the bad tag (or revert the appcast commit). **Sparkle (installed apps).** Build: reuse the published universal DMG. Sign: download Sparkle **2.9.4** tarball, verify pinned SHA-256, `sign_update` with the private EdDSA key; pin check script greps the workflow for `expected_sha256` and `shasum`. Package: enclosure URL `the project repository URL Publish: `generate-appcast.py` updates `gh-pages/appcast.xml` (HTTPS URL, positive length, unique build, no DOCTYPE) then `git push`. Verify: tag matches mounted app short version; feed URL in the bundle is HTTPS. Update: next published release rewrites the appcast. Rollback: revert the `gh-pages` appcast commit so clients keep the previous enclosure. Sparkle is suppressed in tests so it cannot throw a blocking “Update failed” sheet. @@ -65,7 +67,8 @@ No folded runbook section is in this file, so **no procedure is written below**. | TranslationSession on hosted runner | Test-process stability vs merge | `testAppleTranslationSessionBridge_*` SIGSEGV (no system translation daemon) | register only | | Coverage gate | Merge when line coverage (excl. LoginHelper) is below 90% | `coverage` job / `make coverage` / `make coverage-gate` | register only | | Sparkle appcast publish | Clients seeing a resolvable signed enclosure | `release` type `released`; missing/extra universal DMG; tag/version mismatch | register only | -| Unsigned CI DMG vs notarized `make dmg` | Gate confusion (Gatekeeper vs ad-hoc) | Operator expected notarization from a tag build | register only | +| Missing signing secrets on a tag | Release job failure before any artifact is uploaded | Import step prints `APPLE_SIGN_P12_BASE64 is not set; run 'make update-secrets' first` | register only | +| Gatekeeper blocks a DMG | Locating which channel produced the artifact | Tag build whose `verify-release` did not run, or a pre-notarization download | register only | A register-only row is not an on-call playbook. Re-run the blocking CI jobs, inspect xcresult artifacts, run the skipped tests on a logged-in Mac, and keep drafts unpublished until the universal DMG and appcast checks pass. diff --git a/docs/product.md b/docs/product.md index f54443a..8d195dd 100644 --- a/docs/product.md +++ b/docs/product.md @@ -1,8 +1,8 @@ # Product -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ -EasyKey 0.0.14 is a macOS 14+ menu-bar app for people who type Vietnamese in other apps and want clipboard history, macros, and translation without switching to a system Input Method. This file answers what ships, who it is for, what it will not do, how to get a first result, and which interface locales exist. Engineers and first-time users both start here; architecture, runtime sequences, and lookup tables live next door. +EasyKey 0.0.15 is a macOS 14+ menu-bar app for people who type Vietnamese in other apps and want clipboard history, macros, and translation without switching to a system Input Method. This file answers what ships, who it is for, what it will not do, how to get a first result, and which interface locales exist. Engineers and first-time users both start here; architecture, runtime sequences, and lookup tables live next door. ## At a glance diff --git a/docs/reference.md b/docs/reference.md index 4f8651b..d1b3bb0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,6 +1,6 @@ # Reference -_Last reviewed: 2026-08-27_ +_Last reviewed: 2026-10-16_ Lookup for EasyKey settings, known limits, declared stack, and compatibility. Engineers use it to see what a preference actually reads; beginners use it to see defaults and why a change can do nothing. Typing, launch, and threat detail live in sibling sections, not here. @@ -84,7 +84,7 @@ Precedence (first match wins): (1) in-memory store after a Settings change; (2) | Menu-bar icon scale | 130% | system JSON | public | 100–150 step 10 | | Interface language | Vietnamese if unset | UserDefaults | public | system \| en \| vi; catalogs `en` and `vi` | | Onboarding completed | unset until finished | UserDefaults | public | bool key `hasCompletedOnboarding` | -| Marketing version | 0.0.14 | bundle | public | `CFBundleShortVersionString` | +| Marketing version | 0.0.15 | bundle | public | `CFBundleShortVersionString` | | Minimum OS | 14.0 | bundle | public | `LSMinimumSystemVersion` | | Menu-bar accessory | on | bundle | public | `LSUIElement` | | Sparkle feed | `the HTTPS appcast URL in the bundle | bundle | public URL | HTTPS; live updater also needs EdDSA key |