From ceecc5bd2ea4d318589643bc3480ee5b53f7f09b Mon Sep 17 00:00:00 2001 From: jonaskahn Date: Fri, 16 Oct 2026 13:22:21 +0700 Subject: [PATCH 1/3] fix: type precomposed NFC and drop leftover zero-width spaces Vietnamese output to Chromium/Electron page fields and Safari was forced to combining-diacritic (NFD) form by the unicodeCombiningOutput workaround. It rendered correctly but broke website search: Blink stores the decomposed scalars while sites compare precomposed text. All Unicode-class typing output is now canonically composed (NFC) at the KeySynthesizer emission boundary, with deletion bookkeeping derived from the normalized form. Legacy TCVN3/VNI Windows/CP1258 output is exempt. The workaround and its forced override are removed, so the user's typing-encoding setting is honored in every app. --- .codegraph/.gitignore | 5 + CHANGELOG.md | 5 +- EasyEngineCore/Engine/EncodingTable.swift | 13 ++ .../Keyboard/Context/AppCompatibility.swift | 4 +- .../Keyboard/KeyboardInputPipeline.swift | 12 +- .../KeyboardEngineConfigurationFactory.swift | 14 +-- .../Keyboard/Synthesis/KeySynthesizer.swift | 98 ++++++++------- EasyKeyTests/AppCompatibilityTests.swift | 26 ++-- .../KeySynthesizerEventOrderingTests.swift | 94 +++++++++++--- EasyKeyTests/KeySynthesizerPostingTests.swift | 15 +-- EasyKeyTests/KeySynthesizerTests.swift | 85 ++----------- EasyKeyTests/KeySynthesizerUtilityTests.swift | 14 +-- .../KeyboardInputPipelineProcessTests.swift | 116 +++++++++++++++--- .../KeyboardInputPipelineStaticTests.swift | 6 +- .../KeyboardPipelineCoverageTests.swift | 17 +-- EasyKeyTests/KitCoverageGapTests.swift | 6 +- .../VietnameseEngineEdgeCaseTests.swift | 16 +++ 17 files changed, 320 insertions(+), 226 deletions(-) create mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 1530ba4..7d39849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ Notable changes to EasyKey. Format follows Keep a Changelog; versioning follows ## [Unreleased] -Nothing recorded in this window. +**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. ## [0.0.14] - 2026-08-27 diff --git a/EasyEngineCore/Engine/EncodingTable.swift b/EasyEngineCore/Engine/EncodingTable.swift index 5b5b5b8..41eecad 100644 --- a/EasyEngineCore/Engine/EncodingTable.swift +++ b/EasyEngineCore/Engine/EncodingTable.swift @@ -6,4 +6,17 @@ public enum EncodingTable: String, Codable, CaseIterable, Sendable { case tcvn3 case vniWindows case cp1258 + + /// Whether typed output for this encoding is Unicode text that should be + /// canonically composed (NFC) before it hits the wire. Legacy code-page + /// outputs are exempt: CP1258 in particular is deliberately + /// combining-based and normalizing it would corrupt the stream. + public var isUnicodeTypingOutput: Bool { + switch self { + case .unicode, .unicodeCombining: + return true + case .tcvn3, .vniWindows, .cp1258: + return false + } + } } diff --git a/EasyKeyKit/Keyboard/Context/AppCompatibility.swift b/EasyKeyKit/Keyboard/Context/AppCompatibility.swift index 5fd2415..bd5406d 100644 --- a/EasyKeyKit/Keyboard/Context/AppCompatibility.swift +++ b/EasyKeyKit/Keyboard/Context/AppCompatibility.swift @@ -16,7 +16,6 @@ public enum BackspaceUnit: String, Equatable, Sendable { public struct AppCompatibilityRule: Equatable, Sendable { public enum Workaround: String, CaseIterable, Hashable, Sendable { - case unicodeCombiningOutput case spotlightSelection case emptyCharacterInsertion case alternateEmptyCharacter @@ -40,7 +39,6 @@ public struct AppCompatibilityRule: Equatable, Sendable { public enum AppCompatibility { public static let rules: [AppCompatibilityRule] = [ - .init(bundleIdentifier: "com.apple.Safari", workarounds: [.unicodeCombiningOutput]), .init(bundleIdentifier: "com.apple.Spotlight", workarounds: [.spotlightSelection]), .init(bundleIdentifier: "com.microsoft.VSCode", workarounds: [.alternateEmptyCharacter]), ] @@ -56,7 +54,7 @@ public enum AppCompatibility { if compatibilityModeApplicationBundleIdentifiers.contains(bundleIdentifier) { return AppCompatibilityRule( bundleIdentifier: bundleIdentifier, - workarounds: [.unicodeCombiningOutput, .emptyCharacterInsertion, .chromium], + workarounds: [.emptyCharacterInsertion, .chromium], // Chromium compatibility-mode apps are Blink web-page fields, // whose backspace deletes one UTF-16 code unit. The omnibox // (a native field) overrides back to .grapheme in the diff --git a/EasyKeyKit/Keyboard/KeyboardInputPipeline.swift b/EasyKeyKit/Keyboard/KeyboardInputPipeline.swift index f382a94..a081f98 100644 --- a/EasyKeyKit/Keyboard/KeyboardInputPipeline.swift +++ b/EasyKeyKit/Keyboard/KeyboardInputPipeline.swift @@ -178,9 +178,9 @@ final class KeyboardInputPipeline { // Spotlight is an agent overlay: opening it fires no activation event, // so the previous app's compatibility rule would otherwise leak into - // Spotlight (zero-width spaces, combining output, autocomplete - // breaks). Re-derive the effective rule per event and rebuild the - // engine configuration when the context flips. + // Spotlight (zero-width-space breaks, autocomplete breaks). Re-derive + // the effective rule per event and rebuild the engine configuration + // when the context flips. let effectiveRule = currentCompatibilityRule() if effectiveRule != lastAppliedRule { engine.configuration = Self.engineConfiguration(for: settings, rule: effectiveRule) @@ -223,6 +223,7 @@ final class KeyboardInputPipeline { synthesizer.backspaceUnit = inChromiumAddressBar ? .grapheme : (rule?.backspaceUnit ?? .grapheme) + synthesizer.normalizesUnicodeOutput = engine.configuration.outputEncoding.isUnicodeTypingOutput guard synthesizer.postMacroExpansion( proxy: proxy, backspaceCount: expansion.triggerLength, @@ -251,6 +252,7 @@ final class KeyboardInputPipeline { synthesizer.backspaceUnit = inChromiumAddressBar ? .grapheme : (rule?.backspaceUnit ?? .grapheme) + synthesizer.normalizesUnicodeOutput = engine.configuration.outputEncoding.isUnicodeTypingOutput for edit in output.edits { switch edit { @@ -291,9 +293,9 @@ final class KeyboardInputPipeline { || output.edits.contains(.insert("\t")) if !commitsPhysicalKey, !inChromiumAddressBar { if rule?.workarounds.contains(.emptyCharacterInsertion) == true { - guard synthesizer.insertEmptyCharacter(proxy: proxy, "\u{200B}") else { return false } + guard synthesizer.postEmptyCharacterBreak(proxy: proxy, "\u{200B}") else { return false } } else if rule?.workarounds.contains(.alternateEmptyCharacter) == true { - guard synthesizer.insertEmptyCharacter(proxy: proxy, "\u{2060}") else { return false } + guard synthesizer.postEmptyCharacterBreak(proxy: proxy, "\u{2060}") else { return false } } } if output.sessionEffect == .resetSession { diff --git a/EasyKeyKit/Keyboard/Processing/KeyboardEngineConfigurationFactory.swift b/EasyKeyKit/Keyboard/Processing/KeyboardEngineConfigurationFactory.swift index b188b93..a7b9ac1 100644 --- a/EasyKeyKit/Keyboard/Processing/KeyboardEngineConfigurationFactory.swift +++ b/EasyKeyKit/Keyboard/Processing/KeyboardEngineConfigurationFactory.swift @@ -1,17 +1,15 @@ import EasyEngineCore import Foundation -/// Applies the active app's compatibility rule on top of the core -/// settings→configuration mapping. +/// Builds the engine configuration from the core settings→configuration +/// mapping. Compatibility rules no longer rewrite the output encoding: +/// typed output is normalized to NFC at the synthesis boundary instead, so +/// the user's encoding setting is honored in every app. enum KeyboardEngineConfigurationFactory { static func make( for settings: EasyKeySettings, - rule: AppCompatibilityRule? + rule _: AppCompatibilityRule? ) -> EngineConfiguration { - var configuration = EngineConfiguration(settings: settings) - if rule?.workarounds.contains(.unicodeCombiningOutput) == true { - configuration.outputEncoding = .unicodeCombining - } - return configuration + EngineConfiguration(settings: settings) } } diff --git a/EasyKeyKit/Keyboard/Synthesis/KeySynthesizer.swift b/EasyKeyKit/Keyboard/Synthesis/KeySynthesizer.swift index 5701941..eca48e7 100644 --- a/EasyKeyKit/Keyboard/Synthesis/KeySynthesizer.swift +++ b/EasyKeyKit/Keyboard/Synthesis/KeySynthesizer.swift @@ -40,7 +40,6 @@ public final class KeySynthesizer { private let eventFactory: EventFactory private let eventPoster: EventPoster private var encodedUnitStack: [EncodedUnit] = [] - private var pendingEmptyCharacter = false /// How the receiving app's backspace deletes text; drives backspace /// counting for replacements. The pipeline sets this per app/context: @@ -48,6 +47,13 @@ public final class KeySynthesizer { /// Spotlight, the Chromium omnibox, VSCode) uses .grapheme. var backspaceUnit: BackspaceUnit = .grapheme + /// Whether unicode text is canonically composed (NFC) before it is posted. + /// The pipeline sets this per batch from the active output encoding: + /// Unicode-class encodings normalize so receiving apps' search inputs can + /// match the typed text; legacy code-page outputs (CP1258 is deliberately + /// combining-based) pass through untouched. + var normalizesUnicodeOutput = true + public init() { let eventSource = CGEventSource(stateID: .privateState) eventFactory = { keyCode, keyDown in @@ -113,22 +119,23 @@ public final class KeySynthesizer { useSelectionReplacement: Bool = false, breakAutocomplete: Bool = false ) -> ReplacementStrategy { + let (normalizedText, normalizedUnits) = precomposed(text, encodedUnits) if useSelectionReplacement { let selectionCount = selectionDeleteCount(deleteCount) guard let selectionEvents = makePhysicalKeyEvents( keyCode: KeyboardKeyCode.leftArrow, modifiers: .maskShift, count: selectionCount - ), let insertionEvents = makeUnicodeEvents(text) + ), let insertionEvents = makeUnicodeEvents(normalizedText) else { return .failed } _ = prepareDeleteForSelection(deleteCount: deleteCount) post(selectionEvents, proxy: proxy) - post(insertionEvents, proxy: proxy, encodedUnits: encodedUnits) + post(insertionEvents, proxy: proxy, encodedUnits: normalizedUnits) return .selectionReplacement } let shouldBreakAutocomplete = breakAutocomplete - guard let insertionEvents = makeUnicodeEvents(text), + guard let insertionEvents = makeUnicodeEvents(normalizedText), let breakEvents = makeUnicodeEvents(shouldBreakAutocomplete ? Self.autocompleteBreakCharacter : ""), let breakBackspaceEvents = makePhysicalKeyEvents( keyCode: KeyboardKeyCode.backspace, @@ -146,28 +153,36 @@ public final class KeySynthesizer { let removedUnits = prepareDelete(deleteCount: deleteCount) assert(removedUnits == deletionEvents.count) post(deletionEvents, proxy: proxy) - post(insertionEvents, proxy: proxy, encodedUnits: encodedUnits) + post(insertionEvents, proxy: proxy, encodedUnits: normalizedUnits) return shouldBreakAutocomplete ? .breakAutocompleteAndBackspace : .physicalBackspace } @discardableResult func insert(proxy: CGEventTapProxy, _ text: String, encodedUnits: [String]? = nil) -> Bool { - guard let events = makeUnicodeEvents(text), clearPendingEmptyCharacter(proxy: proxy) else { return false } - post(events, proxy: proxy, encodedUnits: encodedUnits ?? text.map(String.init)) + let (normalizedText, normalizedUnits) = precomposed(text, encodedUnits ?? text.map(String.init)) + guard let events = makeUnicodeEvents(normalizedText) else { return false } + post(events, proxy: proxy, encodedUnits: normalizedUnits) return true } + /// Posts the empty-character workaround as a transient insert + immediate + /// backspace pair (the autocomplete-break pattern): the insert event is + /// what breaks Blink's IME session so the next word's letters aren't + /// swallowed, and the immediate backspace keeps the invisible character + /// from resting in the field — a leftover U+200B/U+2060 poisons website + /// search queries. @discardableResult - func insertEmptyCharacter(proxy: CGEventTapProxy, _ text: String) -> Bool { - guard let events = makeUnicodeEvents(text), clearPendingEmptyCharacter(proxy: proxy) else { return false } + func postEmptyCharacterBreak(proxy: CGEventTapProxy, _ text: String) -> Bool { + guard let events = makeUnicodeEvents(text), + let backspaceEvents = makePhysicalKeyEvents(keyCode: KeyboardKeyCode.backspace, modifiers: [], count: 1) + else { return false } post(events, proxy: proxy, encodedUnits: []) - pendingEmptyCharacter = true + post(backspaceEvents, proxy: proxy) return true } func resetEncodedUnits() { encodedUnitStack.removeAll(keepingCapacity: true) - pendingEmptyCharacter = false } public static func isSelfPosted(_ event: CGEvent) -> Bool { @@ -181,10 +196,6 @@ public final class KeySynthesizer { encodedUnitStack.count } - var hasPendingEmptyCharacter: Bool { - pendingEmptyCharacter - } - func postMacroExpansion( proxy: CGEventTapProxy, backspaceCount: Int, @@ -193,7 +204,7 @@ public final class KeySynthesizer { useSelectionReplacement: Bool, breakAutocomplete: Bool ) -> Bool { - let encodedUnits = text.map(String.init) + let (normalizedText, encodedUnits) = precomposed(text, text.map(String.init)) if useSelectionReplacement { let selectionCount = macroExpansionSelectionCount(backspaceCount) @@ -201,7 +212,7 @@ public final class KeySynthesizer { keyCode: KeyboardKeyCode.leftArrow, modifiers: .maskShift, count: selectionCount - ), let insertionEvents = makeUnicodeEvents(text), + ), let insertionEvents = makeUnicodeEvents(normalizedText), let delimiterEvents = makePhysicalKeyEvents(keyCode: physicalKeyCode, modifiers: [], count: 1) else { return false } _ = prepareDeleteForSelection(deleteCount: backspaceCount) @@ -212,7 +223,7 @@ public final class KeySynthesizer { } let physicalCount = macroExpansionDeleteCount(backspaceCount) - guard let insertionEvents = makeUnicodeEvents(text), + guard let insertionEvents = makeUnicodeEvents(normalizedText), let breakEvents = makeUnicodeEvents(breakAutocomplete ? Self.autocompleteBreakCharacter : ""), let breakBackspaceEvents = makePhysicalKeyEvents( keyCode: KeyboardKeyCode.backspace, @@ -233,24 +244,12 @@ public final class KeySynthesizer { @discardableResult func prepareDelete(deleteCount: Int) -> Int { - var physicalCount = 0 - if pendingEmptyCharacter { - physicalCount += 1 - pendingEmptyCharacter = false - } - physicalCount += removeEncodedUnits(deleteCount) - return physicalCount + removeEncodedUnits(deleteCount) } @discardableResult func prepareDeleteForSelection(deleteCount: Int) -> Int { - var selectionCount = 0 - if pendingEmptyCharacter { - selectionCount += 1 - pendingEmptyCharacter = false - } - selectionCount += removeEncodedUnits(deleteCount) - return selectionCount + removeEncodedUnits(deleteCount) } func trackEncodedUnits(_ encodedUnits: [String]) { @@ -259,21 +258,23 @@ public final class KeySynthesizer { }) } - func markPendingEmptyCharacter() { - pendingEmptyCharacter = true - } - - private func clearPendingEmptyCharacter(proxy: CGEventTapProxy) -> Bool { - guard pendingEmptyCharacter else { return true } - guard postBackspace(proxy: proxy, count: 1) else { return false } - pendingEmptyCharacter = false - return true + /// Canonical composition applied to text and its per-atom units together, + /// so the deletion bookkeeping always describes the bytes that reach the + /// wire. Per-unit normalization is safe because no unit ever splits a + /// combining sequence (encoders emit base+marks+tone per atom). + private func precomposed(_ text: String, _ units: [String]) -> (text: String, units: [String]) { + guard normalizesUnicodeOutput else { return (text, units) } + return ( + text.precomposedStringWithCanonicalMapping, + units.map { $0.precomposedStringWithCanonicalMapping } + ) } @discardableResult private func postUnicodeText(proxy: CGEventTapProxy, _ text: String, encodedUnits: [String]) -> Bool { - guard let events = makeUnicodeEvents(text) else { return false } - post(events, proxy: proxy, encodedUnits: encodedUnits) + let (normalizedText, normalizedUnits) = precomposed(text, encodedUnits) + guard let events = makeUnicodeEvents(normalizedText) else { return false } + post(events, proxy: proxy, encodedUnits: normalizedUnits) return true } @@ -317,20 +318,17 @@ public final class KeySynthesizer { } private func physicalDeleteCount(_ logicalCount: Int) -> Int { - let pendingCount = pendingEmptyCharacter ? 1 : 0 let count = min(max(0, logicalCount), encodedUnitStack.count) // The number of physical backspaces must match how the receiving app // deletes: one grapheme cluster per backspace (Safari, Spotlight, the // omnibox) or one UTF-16 code unit per backspace (Chromium web-page // fields). Counting in the wrong unit under-deletes (leaving duplicate // characters) or over-deletes (eating the preceding space). - return pendingCount + encodedUnitStack.suffix(count).reduce(0) { $0 + unitLength($1) } + return encodedUnitStack.suffix(count).reduce(0) { $0 + unitLength($1) } } private func selectionDeleteCount(_ logicalCount: Int) -> Int { - let pendingCount = pendingEmptyCharacter ? 1 : 0 - let count = min(max(0, logicalCount), encodedUnitStack.count) - return pendingCount + encodedUnitStack.suffix(count).reduce(0) { $0 + unitLength($1) } + physicalDeleteCount(logicalCount) } private func unitLength(_ unit: EncodedUnit) -> Int { @@ -344,14 +342,14 @@ public final class KeySynthesizer { private func macroExpansionDeleteCount(_ logicalCount: Int) -> Int { guard encodedUnitStack.count >= logicalCount else { - return logicalCount + (pendingEmptyCharacter ? 1 : 0) + return logicalCount } return physicalDeleteCount(logicalCount) } private func macroExpansionSelectionCount(_ logicalCount: Int) -> Int { guard encodedUnitStack.count >= logicalCount else { - return logicalCount + (pendingEmptyCharacter ? 1 : 0) + return logicalCount } return selectionDeleteCount(logicalCount) } diff --git a/EasyKeyTests/AppCompatibilityTests.swift b/EasyKeyTests/AppCompatibilityTests.swift index 8aed955..b657331 100644 --- a/EasyKeyTests/AppCompatibilityTests.swift +++ b/EasyKeyTests/AppCompatibilityTests.swift @@ -5,7 +5,7 @@ import XCTest final class AppCompatibilityTests: XCTestCase { func testRule_Chromium_HasExpectedWorkarounds() { let rule = AppCompatibility.rule(for: "com.google.Chrome") - XCTAssertEqual(rule?.workarounds, [.unicodeCombiningOutput, .emptyCharacterInsertion, .chromium]) + XCTAssertEqual(rule?.workarounds, [.emptyCharacterInsertion, .chromium]) } func testRule_ConfiguredApplicationsHaveCompatibilityWorkarounds() { @@ -21,13 +21,14 @@ final class AppCompatibilityTests: XCTestCase { for: bundleIdentifier, compatibilityModeApplicationBundleIdentifiers: bundleIdentifiers ) - XCTAssertEqual(rule?.workarounds, [.unicodeCombiningOutput, .emptyCharacterInsertion, .chromium], bundleIdentifier) + XCTAssertEqual(rule?.workarounds, [.emptyCharacterInsertion, .chromium], bundleIdentifier) } } - func testRule_Safari_UsesUnicodeCombiningOutput() { - let rule = AppCompatibility.rule(for: "com.apple.Safari") - XCTAssertEqual(rule?.workarounds, [.unicodeCombiningOutput]) + func testRule_Safari_HasNoRule() { + // Safari types precomposed NFC like every other app; the former + // unicodeCombiningOutput workaround made its text unsearchable. + XCTAssertNil(AppCompatibility.rule(for: "com.apple.Safari")) } func testRule_CustomChromiumBrowserUsesConfiguredWorkarounds() { @@ -35,7 +36,7 @@ final class AppCompatibilityTests: XCTestCase { for: "dev.example.CustomBrowser", compatibilityModeApplicationBundleIdentifiers: ["dev.example.CustomBrowser"] ) - XCTAssertEqual(rule?.workarounds, [.unicodeCombiningOutput, .emptyCharacterInsertion, .chromium]) + XCTAssertEqual(rule?.workarounds, [.emptyCharacterInsertion, .chromium]) } func testRule_RemovedDefaultBrowserHasNoChromiumWorkaround() { @@ -52,8 +53,8 @@ final class AppCompatibilityTests: XCTestCase { func testRule_Chromium_UsesCodePointBackspaceUnit() { // Blink web-page fields delete one UTF-16 code unit per backspace; - // the Chromium rule must count replacements in code units so combining - // output ("ề" = 3 code units) is fully deleted before re-insertion. + // the Chromium rule must count replacements in code units so any + // multi-unit text is fully deleted before re-insertion. let rule = AppCompatibility.rule(for: "com.google.Chrome") XCTAssertEqual(rule?.backspaceUnit, .codePoint) } @@ -70,10 +71,11 @@ final class AppCompatibilityTests: XCTestCase { } func testRule_NativeFields_UseGraphemeBackspaceUnit() { - // Safari, Spotlight, and VSCode are native fields: one backspace - // deletes one grapheme cluster, so they keep grapheme counting even - // though Chromium switched to code-point counting. - XCTAssertEqual(AppCompatibility.rule(for: "com.apple.Safari")?.backspaceUnit, .grapheme) + // Spotlight and VSCode are native fields: one backspace deletes one + // grapheme cluster, so they keep grapheme counting even though + // Chromium uses code-point counting. Safari has no rule at all + // (defaults to grapheme at the pipeline). + XCTAssertEqual(AppCompatibility.rule(for: "com.apple.Safari"), nil) XCTAssertEqual(AppCompatibility.rule(for: "com.apple.Spotlight")?.backspaceUnit, .grapheme) XCTAssertEqual(AppCompatibility.rule(for: "com.microsoft.VSCode")?.backspaceUnit, .grapheme) } diff --git a/EasyKeyTests/KeySynthesizerEventOrderingTests.swift b/EasyKeyTests/KeySynthesizerEventOrderingTests.swift index 5e26f74..3217d8c 100644 --- a/EasyKeyTests/KeySynthesizerEventOrderingTests.swift +++ b/EasyKeyTests/KeySynthesizerEventOrderingTests.swift @@ -105,10 +105,11 @@ final class KeySynthesizerEventOrderingTests: XCTestCase { } func testReplaceBackward_CombiningWord_CountsGraphemesNotUTF16Units() { - // "tuyền" with combining output = t,u,y,ề(e+U+0302+U+0300),n — - // 5 grapheme clusters but 7 UTF-16 units. Backspaces delete grapheme - // clusters in the receiving app, so the word commit must post exactly - // 5 backspaces; UTF-16 counting posted 7 and ate the preceding space. + // Seeds legacy NFD bookkeeping directly on the stack: "tuyền" as + // t,u,y,ề(e+U+0302+U+0300),n — 5 grapheme clusters but 7 UTF-16 + // units. Backspaces delete grapheme clusters in the receiving app, + // so the word commit must post exactly 5 backspaces; UTF-16 counting + // posted 7 and ate the preceding space. let synthesizer = makeSynthesizer() synthesizer.trackEncodedUnits(["t", "u", "y", "e\u{0302}\u{0300}", "n"]) @@ -140,11 +141,11 @@ final class KeySynthesizerEventOrderingTests: XCTestCase { } func testReplaceBackward_CodePointUnit_PostsUtf16CountBackspaces() { - // Chrome/Blink web-page fields delete one UTF-16 code unit per - // backspace. "tuyền" with combining output is t,u,y,ề(e+U+0302+U+0300),n - // = 7 code units; posting only 5 backspaces under-deletes, leaving - // part of "ề" behind and producing the duplicated-character - // regression reported in Chrome ("ttututuyền"). + // Seeds legacy NFD bookkeeping directly on the stack. Chrome/Blink + // web-page fields delete one UTF-16 code unit per backspace: the + // seeded "tuyền" (t,u,y,ề=e+U+0302+U+0300,n) is 7 code units, so + // posting only 5 backspaces under-deletes and produces the + // duplicated-character regression ("ttututuyền"). let synthesizer = makeSynthesizer() synthesizer.backspaceUnit = .codePoint synthesizer.trackEncodedUnits(["t", "u", "y", "e\u{0302}\u{0300}", "n"]) @@ -235,23 +236,80 @@ final class KeySynthesizerEventOrderingTests: XCTestCase { XCTAssertEqual(Array(chunkStrings[1].utf16), Array("😀b".utf16), "surrogate pair stays in one chunk") } - func testInsertEmptyCharacter_PendingDeletionIsAppliedExactlyOnce() { + func testPostEmptyCharacterBreak_OrdersInsertThenImmediateBackspace() { let synthesizer = makeSynthesizer() - XCTAssertTrue(synthesizer.insertEmptyCharacter(proxy: fakeProxy(), "\u{200C}")) - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) + XCTAssertTrue(synthesizer.postEmptyCharacterBreak(proxy: fakeProxy(), "\u{200C}")) + + XCTAssertEqual(posted.count, 4, "insert key-down/up pair then one backspace key-down/up pair") + XCTAssertEqual(posted[0].isKeyDown, true) + XCTAssertEqual(posted[0].unicode, "\u{200C}", "break character posted first") + XCTAssertEqual(posted[1].isKeyDown, false) + XCTAssertEqual(posted[2].keyCode, 51) + XCTAssertTrue(posted[2].isKeyDown, "immediate backspace deletes the break character") + XCTAssertEqual(posted[3].keyCode, 51) + XCTAssertFalse(posted[3].isKeyDown) + XCTAssertEqual(synthesizer.encodedUnitCount, 0, "break character is never tracked for deletion") + + // A later replacement must not count a phantom pending unit: one + // logical unit on the stack deletes with exactly one backspace pair. + XCTAssertTrue(synthesizer.insert(proxy: fakeProxy(), "x")) posted = [] - let strategy = synthesizer.replaceBackward( proxy: fakeProxy(), deleteCount: 1, - insert: "x", - encodedUnits: ["x"], + insert: "y", + encodedUnits: ["y"], breakAutocomplete: false ) XCTAssertEqual(strategy, .physicalBackspace) + XCTAssertEqual(posted.filter { $0.keyCode == 51 }.count, 2, "exactly one backspace key pair for one logical unit") + } + + func testInsert_NFDText_PostsPrecomposedAndTracksNFCUnits() { + let synthesizer = makeSynthesizer() + synthesizer.backspaceUnit = .codePoint + XCTAssertTrue(synthesizer.insert(proxy: fakeProxy(), "o\u{031B}\u{0301}", encodedUnits: ["o\u{031B}\u{0301}"])) + + let keyDowns = posted.filter(\.isKeyDown).map(\.unicode).joined() + XCTAssertEqual(keyDowns, "ớ", "wire text must be precomposed NFC") + XCTAssertFalse(keyDowns.unicodeScalars.contains { (0x0300 ... 0x036F).contains($0.value) }) + XCTAssertEqual(synthesizer.encodedUnitCount, 1, "deletion bookkeeping follows the normalized form") + + // One normalized unit: replacement deletes it with a single backspace + // even under .codePoint counting. + XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 1), 1) + } + + func testNormalizesUnicodeOutputFalse_LegacyCombiningPassesThrough() { + let synthesizer = makeSynthesizer() + synthesizer.normalizesUnicodeOutput = false + synthesizer.backspaceUnit = .codePoint + XCTAssertTrue(synthesizer.insert(proxy: fakeProxy(), "o\u{031B}\u{0301}", encodedUnits: ["o\u{031B}\u{0301}"])) + + let keyDowns = posted.filter(\.isKeyDown).map(\.unicode).joined() + XCTAssertEqual(keyDowns, "o\u{031B}\u{0301}", "legacy code-page output must not be recomposed") + XCTAssertEqual(synthesizer.encodedUnitCount, 1) + + // NFD bookkeeping preserved: one atom, three UTF-16 units. + XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 1), 3) + } + + func testPostMacroExpansion_NFDExpansion_PostsNFCWithMatchingUnits() { + let synthesizer = makeSynthesizer() + synthesizer.backspaceUnit = .codePoint + XCTAssertTrue(synthesizer.postMacroExpansion( + proxy: fakeProxy(), + backspaceCount: 0, + text: "e\u{0302}\u{0300}", + physicalKeyCode: 49, + useSelectionReplacement: false, + breakAutocomplete: false + )) - let deletionEvents = posted.filter { $0.keyCode == 51 } - XCTAssertEqual(deletionEvents.count, 2, "pending empty character contributes exactly one deletion key pair") - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) + let keyDowns = posted.filter(\.isKeyDown).map(\.unicode).joined() + let scalars = keyDowns.unicodeScalars.map { String(format: "U+%04X", $0.value) } + XCTAssertTrue(keyDowns.hasPrefix("ề"), "macro expansion must post precomposed text, got \(scalars)") + XCTAssertEqual(synthesizer.encodedUnitCount, 1, "macro units track the normalized form") + XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 1), 1) } } diff --git a/EasyKeyTests/KeySynthesizerPostingTests.swift b/EasyKeyTests/KeySynthesizerPostingTests.swift index 6449f73..aad2a20 100644 --- a/EasyKeyTests/KeySynthesizerPostingTests.swift +++ b/EasyKeyTests/KeySynthesizerPostingTests.swift @@ -69,13 +69,9 @@ final class KeySynthesizerPostingTests: XCTestCase { synthesizer.postShiftLeft(proxy: fakeProxy(), count: 2) } - func testInsert_ClearsPendingEmptyCharacterFirst() { + func testInsert_PostsWithoutLeadingBackspace() { let synthesizer = KeySynthesizer() - synthesizer.insertEmptyCharacter(proxy: fakeProxy(), "\u{200B}") - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) - synthesizer.insert(proxy: fakeProxy(), "a") - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) XCTAssertEqual(synthesizer.encodedUnitCount, 1) } @@ -85,10 +81,9 @@ final class KeySynthesizerPostingTests: XCTestCase { XCTAssertEqual(synthesizer.encodedUnitCount, 2) } - func testInsertEmptyCharacter_MarksPending() { + func testPostEmptyCharacterBreak_NeverTracksUnits() { let synthesizer = KeySynthesizer() - synthesizer.insertEmptyCharacter(proxy: fakeProxy(), "\u{2060}") - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) + XCTAssertTrue(synthesizer.postEmptyCharacterBreak(proxy: fakeProxy(), "\u{2060}")) XCTAssertEqual(synthesizer.encodedUnitCount, 0) } @@ -213,10 +208,9 @@ final class KeySynthesizerPostingTests: XCTestCase { XCTAssertEqual(synthesizer.encodedUnitCount, 0) } - func testPostMacroExpansion_WithPendingEmptyCharacter_AccountsForItInDeletion() { + func testPostMacroExpansion_DeletionCountsMatchTriggerUnitsOnly() { let synthesizer = KeySynthesizer() synthesizer.trackEncodedUnits(["s", "i", "g"]) - synthesizer.insertEmptyCharacter(proxy: fakeProxy(), "\u{200B}") let posted = synthesizer.postMacroExpansion( proxy: fakeProxy(), @@ -228,7 +222,6 @@ final class KeySynthesizerPostingTests: XCTestCase { ) XCTAssertTrue(posted) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) XCTAssertEqual(synthesizer.encodedUnitCount, 3) } diff --git a/EasyKeyTests/KeySynthesizerTests.swift b/EasyKeyTests/KeySynthesizerTests.swift index 0480833..f3c557b 100644 --- a/EasyKeyTests/KeySynthesizerTests.swift +++ b/EasyKeyTests/KeySynthesizerTests.swift @@ -3,85 +3,33 @@ import CoreGraphics import XCTest final class KeySynthesizerTests: XCTestCase { - func testPrepareDelete_WithPendingEmptyCharacter_AddsOnePhysicalUnitWithoutStackPop() { + func testPrepareDelete_RemovesLogicalUnits() { let synthesizer = KeySynthesizer() - synthesizer.trackEncodedUnits(["h"]) - synthesizer.markPendingEmptyCharacter() - - let physical = synthesizer.prepareDelete(deleteCount: 1) - - XCTAssertEqual(physical, 2) - XCTAssertEqual(synthesizer.encodedUnitCount, 0) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) - } - - func testPrepareDelete_FromHToHoWithPendingEmpty_KeepsStackAligned() { - let synthesizer = KeySynthesizer() - synthesizer.trackEncodedUnits(["h"]) - synthesizer.markPendingEmptyCharacter() - - let physical = synthesizer.prepareDelete(deleteCount: 1) - - XCTAssertEqual(physical, 2) - XCTAssertEqual(synthesizer.encodedUnitCount, 0) - - synthesizer.trackEncodedUnits(["h", "o"]) - synthesizer.markPendingEmptyCharacter() - - XCTAssertEqual(synthesizer.encodedUnitCount, 2) - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) - } - - func testPrepareDelete_HoaCompositionWithPendingEmpty_StaysAlignedWithAtoms() { - let synthesizer = KeySynthesizer() - let steps: [(deleteCount: Int, units: [String])] = [ - (0, ["h"]), - (1, ["h", "o"]), - (2, ["h", "o", "a"]), - (3, ["h", "o", "á"]), - ] - - var hadPending = false - for (index, step) in steps.enumerated() { - let physical = synthesizer.prepareDelete(deleteCount: step.deleteCount) - let expectedPhysical = step.deleteCount + (hadPending ? 1 : 0) - - XCTAssertEqual( - physical, - expectedPhysical, - "Step \(index) (\(step.units.joined())): physical delete skew" - ) - XCTAssertEqual(synthesizer.encodedUnitCount, 0) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) - - synthesizer.trackEncodedUnits(step.units) - synthesizer.markPendingEmptyCharacter() - hadPending = true + synthesizer.trackEncodedUnits(["h", "o", "a"]) - XCTAssertEqual(synthesizer.encodedUnitCount, step.units.count) - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) - } + XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 2), 2) + XCTAssertEqual(synthesizer.encodedUnitCount, 1) } - func testResetEncodedUnits_WithPendingEmpty_ClearsStackAndFlag() { + func testResetEncodedUnits_ClearsStack() { let synthesizer = KeySynthesizer() synthesizer.trackEncodedUnits(["a"]) - synthesizer.markPendingEmptyCharacter() synthesizer.resetEncodedUnits() XCTAssertEqual(synthesizer.encodedUnitCount, 0) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 1), 0) } - func testPrepareDelete_WithoutPending_RemovesOnlyLogicalUnits() { + /// Seeds legacy NFD bookkeeping directly on the stack: the unit-length + /// contract (.grapheme vs .codePoint) is independent of the wire + /// normalization applied at emission. + func testPrepareDelete_SeedsNFDUnits_CountsPerBackspaceUnit() { let synthesizer = KeySynthesizer() - synthesizer.trackEncodedUnits(["h", "o", "a"]) + synthesizer.backspaceUnit = .codePoint + synthesizer.trackEncodedUnits(["e\u{302}\u{300}"]) - XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 2), 2) - XCTAssertEqual(synthesizer.encodedUnitCount, 1) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) + XCTAssertEqual(synthesizer.prepareDelete(deleteCount: 1), 3) } func testSelectionReplacement_WithoutFocusedReplacement() { @@ -108,15 +56,6 @@ final class KeySynthesizerTests: XCTestCase { XCTAssertEqual(synthesizer.encodedUnitCount, 0) } - func testPrepareDeleteForSelection_WithPendingEmptyCharacter_AddsOne() { - let synthesizer = KeySynthesizer() - synthesizer.trackEncodedUnits(["h"]) - synthesizer.markPendingEmptyCharacter() - - XCTAssertEqual(synthesizer.prepareDeleteForSelection(deleteCount: 1), 2) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) - } - private func fakeProxy() -> CGEventTapProxy { unsafeBitCast(UInt(0), to: CGEventTapProxy.self) } diff --git a/EasyKeyTests/KeySynthesizerUtilityTests.swift b/EasyKeyTests/KeySynthesizerUtilityTests.swift index 2a5a01a..542935b 100644 --- a/EasyKeyTests/KeySynthesizerUtilityTests.swift +++ b/EasyKeyTests/KeySynthesizerUtilityTests.swift @@ -20,23 +20,13 @@ final class KeySynthesizerUtilityTests: XCTestCase { XCTAssertEqual(synthesizer.encodedUnitCount, 0) } - func testMarkPendingEmptyCharacter() { - let synthesizer = KeySynthesizer() - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) - synthesizer.markPendingEmptyCharacter() - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) - } - func testResetEncodedUnits() { let synthesizer = KeySynthesizer() synthesizer.trackEncodedUnits(["a", "b"]) - synthesizer.markPendingEmptyCharacter() XCTAssertEqual(synthesizer.encodedUnitCount, 2) - XCTAssertTrue(synthesizer.hasPendingEmptyCharacter) synthesizer.resetEncodedUnits() XCTAssertEqual(synthesizer.encodedUnitCount, 0) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) } func testPrepareDeleteNoPending() { @@ -78,12 +68,10 @@ final class KeySynthesizerUtilityTests: XCTestCase { func testMultipleOperations() { let synthesizer = KeySynthesizer() synthesizer.trackEncodedUnits(["h", "o"]) - synthesizer.markPendingEmptyCharacter() let physical = synthesizer.prepareDelete(deleteCount: 2) - XCTAssertEqual(physical, 3) + XCTAssertEqual(physical, 2) XCTAssertEqual(synthesizer.encodedUnitCount, 0) - XCTAssertFalse(synthesizer.hasPendingEmptyCharacter) synthesizer.trackEncodedUnits(["n", "e", "w"]) XCTAssertEqual(synthesizer.encodedUnitCount, 3) diff --git a/EasyKeyTests/KeyboardInputPipelineProcessTests.swift b/EasyKeyTests/KeyboardInputPipelineProcessTests.swift index cddf7cb..6a33e0b 100644 --- a/EasyKeyTests/KeyboardInputPipelineProcessTests.swift +++ b/EasyKeyTests/KeyboardInputPipelineProcessTests.swift @@ -567,7 +567,7 @@ final class KeyboardInputPipelineProcessTests: XCTestCase { } } - func testProcess_SpaceCommitInChromium_PostsZeroWidthSpace() { + func testProcess_SpaceCommitInChromium_PostsTransientZeroWidthSpaceBreak() { var settings = EasyKeySettings.defaults settings.input.language = .vietnamese var posted: [CGEvent] = [] @@ -595,16 +595,22 @@ final class KeyboardInputPipelineProcessTests: XCTestCase { XCTFail("Expected a Space character to be inserted") return } - var foundZWS = false - for event in posted[(spaceInsertIndex + 1)...] { + var zwspIndex: Int? + for (offset, event) in posted[(spaceInsertIndex + 1)...].enumerated() { if unicodeText(of: event).contains("\u{200B}") { - foundZWS = true + zwspIndex = spaceInsertIndex + 1 + offset break } } - XCTAssertTrue( - foundZWS, - "Zero-width space must be posted after Space commit in Chromium to maintain IME session" + guard let zwspIndex else { + XCTFail("Zero-width space must be posted after Space commit in Chromium to maintain IME session") + return + } + let followUpKeyDown = posted[(zwspIndex + 1)...].first { $0.type == .keyDown } + XCTAssertEqual( + followUpKeyDown?.getIntegerValueField(.keyboardEventKeycode), + 51, + "the zero-width space must be transient: immediately deleted so it never rests in the field" ) } @@ -671,25 +677,23 @@ final class KeyboardInputPipelineProcessTests: XCTestCase { } func testProcess_TypingTwoWordsInChromiumPageField_PreservesSpaceBetweenWords() { - // Regression: typing "tuyeenf nguyeenx" in a Chrome page field with - // combining output. Blink fields delete one UTF-16 code unit per - // backspace ("ề" = 3 units), so the pipeline counts replacements in - // code points. Counting graphemes instead under-deletes and leaves - // accumulating duplicates ("ttututuyền nnngnguyễn"). + // Regression: typing "tuyeenf nguyeenx" in a Chrome page field. + // Blink fields delete one UTF-16 code unit per backspace, so the + // pipeline counts replacements in code points. Counting in the wrong + // unit under-deletes and leaves accumulating duplicates + // ("ttututuyền nnngnguyễn"). Workaround characters (U+200B, U+202F) + // are transient insert+backspace pairs that must net to zero here. let posted = postedForTwoWords(bundleIdentifier: "com.google.Chrome") let visible = replayedField(from: posted, deleteUnit: .codePoint) - .replacingOccurrences(of: "\u{200B}", with: "") - .replacingOccurrences(of: "\u{202F}", with: "") XCTAssertEqual(visible, "tuyền nguyễn") } func testProcess_TypingTwoWordsInSafari_PreservesSpaceWithGraphemeDeletion() { // Safari is a native field: one backspace deletes one grapheme - // cluster, and the Safari rule keeps grapheme counting. + // cluster, and with no Safari rule the pipeline keeps grapheme + // counting. let posted = postedForTwoWords(bundleIdentifier: "com.apple.Safari") let visible = replayedField(from: posted, deleteUnit: .grapheme) - .replacingOccurrences(of: "\u{200B}", with: "") - .replacingOccurrences(of: "\u{202F}", with: "") XCTAssertEqual(visible, "tuyền nguyễn") } @@ -703,11 +707,85 @@ final class KeyboardInputPipelineProcessTests: XCTestCase { chromiumAddressBarDetector: { true } ) let visible = replayedField(from: posted, deleteUnit: .grapheme) - .replacingOccurrences(of: "\u{200B}", with: "") - .replacingOccurrences(of: "\u{202F}", with: "") XCTAssertEqual(visible, "tuyền nguyễn") } + func testProcess_TypingInChromium_WireTextIsPrecomposedNFC() { + // Regression: decomposed (NFD) output renders fine but breaks website + // search — Blink stores the combining scalars while sites compare NFC. + let posted = postedForTwoWords(bundleIdentifier: "com.google.Chrome") + for event in posted where event.type == .keyDown { + let text = unicodeText(of: event) + XCTAssertEqual( + text, + text.precomposedStringWithCanonicalMapping, + "wire text must be precomposed NFC, got \(text.unicodeScalars.map { String(format: "U+%04X", $0.value) })" + ) + } + } + + func testProcess_TypingLeavesNoInvisibleCharacters_AtRest() { + // Regression: a leftover U+200B/U+2060 in the field poisons website + // search queries (instant search mid-typing; "query + space + Enter"). + for (bundleIdentifier, deleteUnit) in [ + ("com.google.Chrome", BackspaceUnit.codePoint), + ("com.microsoft.VSCode", BackspaceUnit.grapheme), + ] { + let posted = postedForTwoWords(bundleIdentifier: bundleIdentifier) + let field = replayedField(from: posted, deleteUnit: deleteUnit) + XCTAssertFalse(field.contains("\u{200B}"), "\(bundleIdentifier): leftover zero-width space") + XCTAssertFalse(field.contains("\u{2060}"), "\(bundleIdentifier): leftover word joiner") + XCTAssertEqual(field, "tuyền nguyễn", bundleIdentifier) + } + } + + func testProcess_UserSelectedUnicodeCombining_WireIsStillNFC() { + // All Unicode-class typing output is normalized at the emission + // point: even the explicit "Unicode Combining" encoding types NFC. + var settings = EasyKeySettings.defaults + settings.input.language = .vietnamese + settings.input.encoding = .unicodeCombining + var posted: [CGEvent] = [] + let pipeline = KeyboardInputPipeline( + settings: settings, + eventPoster: { event, _ in posted.append(event) } + ) + pipeline.setActiveApplication("com.google.Chrome") + + for (character, keyCode) in [("v", UInt16(9)), ("i", 34), ("s", 1)] { + let event = keyEvent(character: character, keyCode: keyCode) + _ = pipeline.process(proxy: fakeProxy(), type: .keyDown, event: event, keyCode: keyCode) + } + for event in posted where event.type == .keyDown { + let text = unicodeText(of: event) + XCTAssertEqual(text, text.precomposedStringWithCanonicalMapping, "explicit combining mode still types NFC") + } + } + + func testProcess_CP1258Mode_PreservesCombiningWireText() { + // Legacy code-page output is exempt from normalization: CP1258 is + // deliberately combining-based. + var settings = EasyKeySettings.defaults + settings.input.language = .vietnamese + settings.input.encoding = .cp1258 + var posted: [CGEvent] = [] + let pipeline = KeyboardInputPipeline( + settings: settings, + eventPoster: { event, _ in posted.append(event) } + ) + pipeline.setActiveApplication("com.google.Chrome") + + for (character, keyCode) in [("v", UInt16(9)), ("i", 34), ("s", 1)] { + let event = keyEvent(character: character, keyCode: keyCode) + _ = pipeline.process(proxy: fakeProxy(), type: .keyDown, event: event, keyCode: keyCode) + } + let wire = posted.filter { $0.type == .keyDown }.map(unicodeText(of:)).joined() + XCTAssertTrue( + wire.unicodeScalars.contains { $0.value == 0x0301 }, + "CP1258 output keeps combining scalars, got \(wire.unicodeScalars.map { String(format: "U+%04X", $0.value) })" + ) + } + func testProcess_SpotlightContext_DoesNotInheritPreviousAppChromiumWorkarounds() { // Spotlight is an agent overlay that fires no activation event, so the // pipeline must not apply the previous app's (Chrome) compatibility diff --git a/EasyKeyTests/KeyboardInputPipelineStaticTests.swift b/EasyKeyTests/KeyboardInputPipelineStaticTests.swift index 4bebc27..0e3b183 100644 --- a/EasyKeyTests/KeyboardInputPipelineStaticTests.swift +++ b/EasyKeyTests/KeyboardInputPipelineStaticTests.swift @@ -59,12 +59,12 @@ final class KeyboardInputPipelineStaticTests: XCTestCase { func testEngineConfigurationWithRule() { let settings = EasyKeySettings.defaults let rule = AppCompatibilityRule( - bundleIdentifier: "com.apple.Safari", - workarounds: [.unicodeCombiningOutput] + bundleIdentifier: "com.google.Chrome", + workarounds: [.emptyCharacterInsertion, .chromium] ) let config = KeyboardInputPipeline.engineConfiguration(for: settings, rule: rule) - XCTAssertEqual(config.outputEncoding, .unicodeCombining) + XCTAssertEqual(config.outputEncoding, settings.input.encoding) } func testEngineConfigurationTypingOptions() { diff --git a/EasyKeyTests/KeyboardPipelineCoverageTests.swift b/EasyKeyTests/KeyboardPipelineCoverageTests.swift index 6bc132b..19b3e54 100644 --- a/EasyKeyTests/KeyboardPipelineCoverageTests.swift +++ b/EasyKeyTests/KeyboardPipelineCoverageTests.swift @@ -35,18 +35,21 @@ final class KeyboardPipelineCoverageTests: XCTestCase { } func testEngineConfigurationWithCompatibilityRule() { + // Compatibility rules never rewrite the output encoding: typed text + // is normalized to NFC at the synthesis boundary instead, so the + // user's encoding setting is honored in every app. let rules: [(String, Set)] = [ - ("com.apple.Safari", [.unicodeCombiningOutput]), ("com.apple.Spotlight", [.spotlightSelection]), - ("com.google.Chrome", [.unicodeCombiningOutput, .emptyCharacterInsertion, .chromium]), + ("com.google.Chrome", [.emptyCharacterInsertion, .chromium]), + ("com.microsoft.VSCode", [.alternateEmptyCharacter]), ] for (bundleID, workarounds) in rules { let rule = AppCompatibilityRule(bundleIdentifier: bundleID, workarounds: workarounds) - var settings = EasyKeySettings.defaults - settings.input.encoding = .unicode - let config = KeyboardInputPipeline.engineConfiguration(for: settings, rule: rule) - if workarounds.contains(.unicodeCombiningOutput) { - XCTAssertEqual(config.outputEncoding, .unicodeCombining, bundleID) + for encoding in [EncodingTable.unicode, .unicodeCombining, .cp1258] { + var settings = EasyKeySettings.defaults + settings.input.encoding = encoding + let config = KeyboardInputPipeline.engineConfiguration(for: settings, rule: rule) + XCTAssertEqual(config.outputEncoding, encoding, bundleID) } } } diff --git a/EasyKeyTests/KitCoverageGapTests.swift b/EasyKeyTests/KitCoverageGapTests.swift index df48844..e364050 100644 --- a/EasyKeyTests/KitCoverageGapTests.swift +++ b/EasyKeyTests/KitCoverageGapTests.swift @@ -286,12 +286,12 @@ final class KitCoverageGapTests: XCTestCase { XCTAssertFalse(KeyboardInputPipeline.shouldBreakAutocomplete(inChromiumAddressBar: false, isSpotlight: false, deleteCount: 5)) } - func testEngineConfiguration_UnicodeCombiningRuleOverridesEncoding() { - let rule = AppCompatibilityRule(bundleIdentifier: "com.apple.Safari", workarounds: [.unicodeCombiningOutput]) + func testEngineConfiguration_CompatibilityRuleNeverOverridesEncoding() { + let rule = AppCompatibilityRule(bundleIdentifier: "com.google.Chrome", workarounds: [.emptyCharacterInsertion, .chromium]) var settings = EasyKeySettings.defaults settings.input.encoding = .unicode let config = KeyboardInputPipeline.engineConfiguration(for: settings, rule: rule) - XCTAssertEqual(config.outputEncoding, .unicodeCombining) + XCTAssertEqual(config.outputEncoding, .unicode) } func testIsCurrentInputSourceForeign_DoesNotCrash() { diff --git a/EasyKeyTests/VietnameseEngineEdgeCaseTests.swift b/EasyKeyTests/VietnameseEngineEdgeCaseTests.swift index d961638..383f434 100644 --- a/EasyKeyTests/VietnameseEngineEdgeCaseTests.swift +++ b/EasyKeyTests/VietnameseEngineEdgeCaseTests.swift @@ -628,6 +628,22 @@ final class VietnameseEngineEdgeCaseTests: XCTestCase { XCTAssertEqual(units.reduce(0) { $0 + $1.utf16.count }, engine.currentBuffer.utf16.count) } + func testRenderedUnits_CombiningEncoding_PerUnitNFCMatchesWholeTextNFC() { + // The synthesis layer normalizes wire text and per-atom units + // together; that is only sound if per-unit NFC equals whole-text NFC + // for everything the combining encoder renders (no unit ever splits a + // base from its combining marks). + var engine = VietnameseEngine(configuration: EngineConfiguration(outputEncoding: .unicodeCombining)) + typeKeys(&engine, "tuyeenf") + let units = engine.renderedUnits + let text = engine.currentBuffer + XCTAssertNotEqual(text, text.precomposedStringWithCanonicalMapping, "sample must actually be decomposed") + XCTAssertEqual( + units.map { $0.precomposedStringWithCanonicalMapping }.joined(), + text.precomposedStringWithCanonicalMapping + ) + } + func testRenderedUnits_LegacyEncodingConcatMatchesWholeBuffer() { var engine = VietnameseEngine(configuration: EngineConfiguration(outputEncoding: .cp1258)) typeKeys(&engine, "aas") From 91bd374882e2f7ffa0e27a30a14f2312362dff2c Mon Sep 17 00:00:00 2001 From: jonaskahn Date: Fri, 16 Oct 2026 14:25:34 +0700 Subject: [PATCH 2/3] feat: update language vietnamese unicode --- .github/workflows/release.yml | 39 +++- .gitignore | 4 +- CHANGELOG.md | 12 +- EasyKey.xcodeproj/project.pbxproj | 32 ++-- Makefile | 16 +- README.md | 4 +- SECURITY.md | 10 +- Scripts/archive.sh | 6 + Scripts/sparkle-keys.sh | 56 ++++++ Scripts/update-secrets.sh | 303 ++++++++++++++++++++++++++++++ Scripts/verify-release.sh | 6 + docs/architecture.md | 10 +- docs/engineering.md | 16 +- docs/operations.md | 23 ++- docs/product.md | 4 +- docs/reference.md | 4 +- 16 files changed, 488 insertions(+), 57 deletions(-) create mode 100755 Scripts/sparkle-keys.sh create mode 100755 Scripts/update-secrets.sh 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..9ea1f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,10 @@ 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 .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..abf3c98 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 @@ -173,6 +173,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 +219,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..4cd9a55 100755 --- a/Scripts/archive.sh +++ b/Scripts/archive.sh @@ -19,6 +19,12 @@ common_args=( archive ARCHS="$archs" ONLY_ACTIVE_ARCH="$only_active_arch" + # 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. + ENABLE_HARDENED_RUNTIME=YES ) if [[ "$release_local" == "1" ]]; then 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..61002f8 100755 --- a/Scripts/verify-release.sh +++ b/Scripts/verify-release.sh @@ -15,6 +15,12 @@ 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 | From df18016065a2bcfb7f99a1be8a7fee5ceb168baf Mon Sep 17 00:00:00 2001 From: jonaskahn Date: Fri, 16 Oct 2026 15:31:23 +0700 Subject: [PATCH 3/3] chore: fix local build failed --- .env.example | 34 ++++++++++++++++++++++++++ .gitignore | 1 + Makefile | 7 +++--- Scripts/archive.sh | 26 +++++++++++++++----- Scripts/check-release-config.sh | 43 +++++++++++++++++++++++++++++++++ Scripts/release-env.sh | 21 ++++++++++++++++ Scripts/verify-release.sh | 20 +++++++++++++++ 7 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100755 Scripts/check-release-config.sh create mode 100755 Scripts/release-env.sh 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/.gitignore b/.gitignore index 9ea1f1e..8635c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ keys/ *.pem *.p12 *.p12.b64 +.env.local .nodeterm/ diff --git a/Makefile b/Makefile index abf3c98..0cbfec3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Scripts/archive.sh b/Scripts/archive.sh index 4cd9a55..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}" @@ -19,12 +26,6 @@ common_args=( archive ARCHS="$archs" ONLY_ACTIVE_ARCH="$only_active_arch" - # 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. - ENABLE_HARDENED_RUNTIME=YES ) if [[ "$release_local" == "1" ]]; then @@ -38,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" \ @@ -61,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/verify-release.sh b/Scripts/verify-release.sh index 61002f8..ef962aa 100755 --- a/Scripts/verify-release.sh +++ b/Scripts/verify-release.sh @@ -11,6 +11,26 @@ 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