From 7d79d729f85ce5cdbeacdefbf7c8fb686c1036c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:06:38 +0800 Subject: [PATCH 1/5] Release CodexNotes 1.4.69 --- AppBundle/Info.plist | 4 +- Package.swift | 3 + README.md | 5 + Sources/CodexNotesCore/CodexLogMonitor.swift | 507 +++++++- Sources/CodexNotesCore/Localization.swift | 50 + .../Resources/en.lproj/Localizable.strings | 50 + .../zh-Hans.lproj/Localizable.strings | 50 + .../CodexNotesProbe/AboutCodexNotesView.swift | 609 +++++++++ .../CodexNotesProbe/AppUpdateChecker.swift | 452 +++++++ .../CodexNotesProbe/CodexNotesProbeApp.swift | 20 +- Sources/CodexNotesProbe/ContentView.swift | 268 +++- .../GlobalHotKeyController.swift | 1028 ++++++++++++++++ .../GlobalHotKeyRecorderView.swift | 313 +++++ .../MainWindowInitialPlacementPolicy.swift | 121 ++ .../Resources/WeChatRewardCode.jpg | Bin 0 -> 102020 bytes Sources/CodexNotesProbe/SettingsView.swift | 308 +++-- .../StatusItemController.swift | 6 +- .../UpdateCheckCoordinator.swift | 548 +++++++++ .../CodexLogMonitorTests.swift | 554 +++++++++ .../LocalizationTests.swift | 49 + .../AppUpdateCheckerTests.swift | 386 ++++++ .../BottomBarActionPresentationTests.swift | 37 + .../GlobalHotKeyControllerTests.swift | 848 +++++++++++++ .../GlobalHotKeyRecorderViewTests.swift | 158 +++ .../MainWindowFramePersistenceTests.swift | 52 + ...ainWindowInitialPlacementPolicyTests.swift | 126 ++ .../SettingsAboutPresentationTests.swift | 231 ++++ .../SettingsEditorPresentationTests.swift | 36 + .../SettingsWindowActivatorTests.swift | 54 + .../ShortcutReferencePanelTests.swift | 54 +- .../StatusItemControllerTests.swift | 16 +- .../UpdateCheckCoordinatorTests.swift | 1083 +++++++++++++++++ 32 files changed, 7837 insertions(+), 189 deletions(-) create mode 100644 Sources/CodexNotesProbe/AboutCodexNotesView.swift create mode 100644 Sources/CodexNotesProbe/AppUpdateChecker.swift create mode 100644 Sources/CodexNotesProbe/GlobalHotKeyController.swift create mode 100644 Sources/CodexNotesProbe/GlobalHotKeyRecorderView.swift create mode 100644 Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift create mode 100644 Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg create mode 100644 Sources/CodexNotesProbe/UpdateCheckCoordinator.swift create mode 100644 Tests/CodexNotesCoreTests/CodexLogMonitorTests.swift create mode 100644 Tests/CodexNotesProbeTests/AppUpdateCheckerTests.swift create mode 100644 Tests/CodexNotesProbeTests/GlobalHotKeyControllerTests.swift create mode 100644 Tests/CodexNotesProbeTests/GlobalHotKeyRecorderViewTests.swift create mode 100644 Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift create mode 100644 Tests/CodexNotesProbeTests/SettingsAboutPresentationTests.swift create mode 100644 Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift create mode 100644 Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift create mode 100644 Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift diff --git a/AppBundle/Info.plist b/AppBundle/Info.plist index 05b7b02..7036042 100644 --- a/AppBundle/Info.plist +++ b/AppBundle/Info.plist @@ -24,9 +24,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.68 + 1.4.69 CFBundleVersion - 88 + 89 LSUIElement LSMinimumSystemVersion diff --git a/Package.swift b/Package.swift index 6f41a59..096d88e 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,9 @@ let package = Package( dependencies: ["CodexNotesCore"], resources: [ .process("Resources") + ], + linkerSettings: [ + .linkedFramework("Carbon") ] ), .executableTarget( diff --git a/README.md b/README.md index ef8aae1..15fc8bf 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ spctl --assess --type exec --verbose=4 /Applications/CodexNotes.app - 支持粘贴或拖入 PNG、JPEG、HEIC、静态 WebP;导入时清除 EXIF/GPS 元数据 - 支持系统原色及六套自定义主题,并可调整字号与行间距 - 支持跟随系统、简体中文和 English 三种语言选项 +- 支持自定义全局显示/隐藏快捷键;只在 Codex 正在运行且未隐藏时生效 - 可选择登录 Mac 时启动;该功能默认关闭并使用 macOS 原生登录项 - 记住窗口位置和大小;隐藏、重新显示或重启后不会漂移 +- 关于页显示版本、作者与项目链接,并支持手动检查或主动开启每日 GitHub Release 更新检查 ## 数据与隐私 @@ -58,10 +60,13 @@ Markdown 原文是唯一数据源。图形复选框、图片预览、粗体和 CodexNotes 不上传笔记、不提供云同步,也不包含遥测或分析服务。应用只读取 Codex 写在本机的状态与导航日志,用来识别当前任务。 +只有当你手动点击“检查更新”,或主动开启“自动检查更新”后,应用才会访问 GitHub Releases API。成功检查后间隔 24 小时;网络失败时会静默退避重试。应用只检查版本,不会自动下载或安装;请求不包含笔记内容、设备标识或遥测数据。 + ## 快捷键 | 快捷键 | 功能 | | --- | --- | +| `⌃⇧Space`(默认,可更改) | 显示或隐藏 CodexNotes;只在 Codex 正在运行且未隐藏时生效 | | `⌘1` | 切换到任务笔记 | | `⌘2` | 切换到项目笔记 | | `⌘↩` | 切换当前行的待办状态 | diff --git a/Sources/CodexNotesCore/CodexLogMonitor.swift b/Sources/CodexNotesCore/CodexLogMonitor.swift index 2454a09..d26d3a0 100644 --- a/Sources/CodexNotesCore/CodexLogMonitor.swift +++ b/Sources/CodexNotesCore/CodexLogMonitor.swift @@ -24,6 +24,11 @@ public enum CodexProbeError: LocalizedError, Sendable { } } +struct CodexProcessIdentity: Equatable, Sendable { + let pid: pid_t + let launchDate: Date? +} + public enum CodexEnvironment { public static var defaultLogRoot: URL { FileManager.default.homeDirectoryForCurrentUser @@ -43,62 +48,179 @@ public enum CodexEnvironment { } public static func runningCodexPID() -> pid_t? { - NSWorkspace.shared.runningApplications - .first(where: { $0.bundleIdentifier == "com.openai.codex" })? - .processIdentifier + runningCodexProcess()?.pid + } + + static func runningCodexProcess() -> CodexProcessIdentity? { + guard let application = NSWorkspace.shared.runningApplications + .first(where: { + $0.bundleIdentifier == "com.openai.codex" && !$0.isTerminated + }) + else { return nil } + return CodexProcessIdentity( + pid: application.processIdentifier, + launchDate: application.launchDate + ) } } +private struct LogFileIdentity: Equatable, Sendable { + let systemNumber: UInt64? + let fileNumber: UInt64? +} + private struct LogCursor: Sendable { var offset: UInt64 - var remainder: String + var remainder: Data + var anchor: Data + var fileIdentity: LogFileIdentity +} + +private struct LogSeed: Sendable { + let selection: CodexSelection? + let cursor: LogCursor } public actor CodexLogMonitor { + private static let defaultBootstrapChunkSize = 256 * 1_024 + private static let cursorAnchorSize = 64 + private let logRoot: URL + private let runningCodexProcessProvider: @Sendable () -> CodexProcessIdentity? + private let bootstrapChunkSize: Int private var cursors: [URL: LogCursor] = [:] private var latest: CodexSelection? private var pollCounter = 0 - private var selectedPID: pid_t? + private var selectedProcess: CodexProcessIdentity? public init(logRoot: URL = CodexEnvironment.defaultLogRoot) { self.logRoot = logRoot + self.runningCodexProcessProvider = { + CodexEnvironment.runningCodexProcess() + } + self.bootstrapChunkSize = Self.defaultBootstrapChunkSize } - public func bootstrap() throws -> CodexSelection? { - selectedPID = CodexEnvironment.runningCodexPID() - let files = try candidateLogFiles(pid: selectedPID) - guard !files.isEmpty else { throw CodexProbeError.noCodexLog } + init( + logRoot: URL, + runningCodexPIDProvider: @escaping @Sendable () -> pid_t?, + bootstrapChunkSize: Int = CodexLogMonitor.defaultBootstrapChunkSize + ) { + precondition(bootstrapChunkSize > 0) + self.logRoot = logRoot + self.runningCodexProcessProvider = { + runningCodexPIDProvider().map { + CodexProcessIdentity(pid: $0, launchDate: nil) + } + } + self.bootstrapChunkSize = bootstrapChunkSize + } - for file in files.prefix(40) { - try seed(file: file) + public func bootstrap() throws -> CodexSelection? { + guard let runningProcess = runningCodexProcessProvider() else { + selectedProcess = nil + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog + } + selectedProcess = runningProcess + let files = try candidateLogFiles(for: runningProcess) + guard !files.isEmpty else { + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog } + try bootstrap(from: files) return latest } public func poll() throws -> CodexSelection? { pollCounter += 1 - let currentPID = CodexEnvironment.runningCodexPID() + let currentProcess = runningCodexProcessProvider() + + guard let currentProcess else { + selectedProcess = nil + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog + } - if currentPID != selectedPID || pollCounter % 6 == 0 { - if currentPID != selectedPID { + if currentProcess != selectedProcess { + selectedProcess = currentProcess + let files = try candidateLogFiles(for: currentProcess) + guard !files.isEmpty else { cursors.removeAll() latest = nil - selectedPID = currentPID + throw CodexProbeError.noCodexLog } + try bootstrap(from: files) + return latest + } + + let lostCursorContinuity = try drainIncrementalCursors() - for file in try candidateLogFiles(pid: selectedPID).prefix(40) where cursors[file] == nil { - try seed(file: file) + if lostCursorContinuity || cursors.isEmpty { + let files = try candidateLogFiles(for: currentProcess) + guard !files.isEmpty else { + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog + } + // A rotated file can be renamed between polls. Re-bootstrap the + // current launch family so an unread tail selection in that old + // inode is recovered before following the new active log. + try bootstrap(from: files) + } else if pollCounter % 6 == 0 { + // Drain the current file to its snapshot EOF before replacing its + // cursor with a newer rotation target. Reversing this order can + // permanently skip the old file's final selection event. + let files = try candidateLogFiles(for: currentProcess) + guard !files.isEmpty else { + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog + } + if let nextActiveFile = files.first, + cursors[nextActiveFile] == nil { + // A final event can land in the old file after the first drain + // but before rotation becomes visible to enumeration. Once a + // different active URL exists, drain the old cursor again at + // its now-sealed snapshot EOF before switching targets. + let lostDuringFinalDrain = try drainIncrementalCursors() + if lostDuringFinalDrain || cursors.isEmpty { + let refreshedFiles = try candidateLogFiles(for: currentProcess) + guard !refreshedFiles.isEmpty else { + cursors.removeAll() + latest = nil + throw CodexProbeError.noCodexLog + } + try bootstrap(from: refreshedFiles) + return latest + } } + try reconcileIncrementalCursor(with: files) } + return latest + } - for file in cursors.keys { - try readAppendedData(from: file) + private func drainIncrementalCursors() throws -> Bool { + var lostCursorContinuity = false + for file in Array(cursors.keys) { + do { + if try readAppendedData(from: file) { + lostCursorContinuity = true + } + } catch where isMissingFileError(error) { + cursors.removeValue(forKey: file) + lostCursorContinuity = true + } } - return latest + return lostCursorContinuity } - private func candidateLogFiles(pid: pid_t?) throws -> [URL] { + private func candidateLogFiles( + for process: CodexProcessIdentity + ) throws -> [URL] { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: logRoot.path, isDirectory: &isDirectory), isDirectory.boolValue @@ -114,82 +236,347 @@ public actor CodexLogMonitor { throw CodexProbeError.logDirectoryMissing(logRoot.path) } - var matches: [(url: URL, modified: Date)] = [] - let pidMarker = pid.map { "-\($0)-t0-" } + var matches: [(url: URL, modified: Date, family: String)] = [] + let pidMarker = "-\(process.pid)-t0-" + let earliestCurrentLaunchDate = process.launchDate?.addingTimeInterval(-5) for case let url as URL in enumerator { let name = url.lastPathComponent guard name.hasSuffix(".log"), name.contains("-t0-") else { continue } - if let pidMarker, !name.contains(pidMarker) { continue } + guard let pidRange = name.range(of: pidMarker, options: .backwards) else { + continue + } let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) guard values?.isRegularFile == true else { continue } - matches.append((url, values?.contentModificationDate ?? .distantPast)) + let modified = values?.contentModificationDate ?? .distantPast + if let earliestCurrentLaunchDate, + modified < earliestCurrentLaunchDate { + continue + } + matches.append( + ( + url, + modified, + String(name[.. $1.modified } + let sorted = matches.sorted { $0.modified > $1.modified } + guard let currentLaunchFamily = sorted.first?.family else { return [] } + return sorted + .filter { $0.family == currentLaunchFamily } .map(\.url) } - private func seed(file: URL) throws { - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 - let maximumTail: UInt64 = 2_000_000 - let start = size > maximumTail ? size - maximumTail : 0 + private func bootstrap(from files: [URL]) throws { + cursors.removeAll() + latest = nil - guard let handle = try? FileHandle(forReadingFrom: file) else { + var newestSeed: (file: URL, seed: LogSeed)? + for file in files { + guard let seed = try makeSeed(for: file) else { continue } + if newestSeed == nil { + newestSeed = (file, seed) + } + guard let selection = seed.selection else { continue } + latest = selection + break + } + + // Only the newest surviving log can receive normal forward appends. + // Older rotated files are bootstrap history, not 250 ms poll targets. + guard let newestSeed else { throw CodexProbeError.noCodexLog } + cursors[newestSeed.file] = newestSeed.seed.cursor + } + + private func reconcileIncrementalCursor(with files: [URL]) throws { + for activeFile in files { + if let existing = cursors[activeFile] { + cursors = [activeFile: existing] + return + } + guard let seed = try makeSeed(for: activeFile) else { continue } + if let selection = seed.selection, + latest == nil || selection.timestamp >= latest!.timestamp { + latest = selection + } + cursors = [activeFile: seed.cursor] + return + } + + cursors.removeAll() + throw CodexProbeError.noCodexLog + } + + private func makeSeed(for file: URL) throws -> LogSeed? { + let attributes: [FileAttributeKey: Any] + do { + attributes = try FileManager.default.attributesOfItem(atPath: file.path) + } catch where isMissingFileError(error) { + return nil + } + let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + let fileIdentity = identity(from: attributes) + + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: file) + } catch where isMissingFileError(error) { + return nil + } catch { throw CodexProbeError.unreadableLog(file.path) } defer { try? handle.close() } - try handle.seek(toOffset: start) - let data = try handle.readToEnd() ?? Data() - var text = String(decoding: data, as: UTF8.self) + let scan: (selection: CodexSelection?, trailingRemainder: Data) + do { + scan = try scanBackwardForLatestSelection( + in: handle, + fileSize: size + ) + } catch where isMissingFileError(error) { + return nil + } + let anchor = try readAnchor(from: handle, endingAt: size) + + // Bootstrap reads a snapshot ending at `size`. Keeping that exact EOF + // offset and its unterminated trailing line preserves the same + // incremental behavior as forward polling. + return LogSeed( + selection: scan.selection, + cursor: LogCursor( + offset: size, + remainder: scan.trailingRemainder, + anchor: anchor, + fileIdentity: fileIdentity + ) + ) + } + + private func scanBackwardForLatestSelection( + in handle: FileHandle, + fileSize: UInt64 + ) throws -> (selection: CodexSelection?, trailingRemainder: Data) { + var position = fileSize + var lineSuffix = Data() + var trailingRemainder: Data? + + while position > 0 { + let byteCount = Int(min(UInt64(bootstrapChunkSize), position)) + position -= UInt64(byteCount) + try handle.seek(toOffset: position) + let chunk = try readData(from: handle, upToCount: byteCount) + + var segmentEnd = chunk.endIndex + var index = chunk.endIndex + while index > chunk.startIndex { + index = chunk.index(before: index) + guard chunk[index] == 0x0A else { continue } + + var line = Data( + chunk[chunk.index(after: index).. chunk.startIndex { + var combined = Data(chunk[chunk.startIndex.. 0, let firstNewline = text.firstIndex(of: "\n") { - text = String(text[text.index(after: firstNewline)...]) + guard let trailingRemainder else { + // A file without any newline consists entirely of an unfinished + // line, matching `consume`'s remainder behavior. + return (nil, lineSuffix) } - consume(text: text, file: file, offset: size) + // The first line has no preceding newline but is complete whenever the + // file contained at least one newline after it. + return ( + parse(line: lineSuffix), + trailingRemainder + ) } - private func readAppendedData(from file: URL) throws { - guard var cursor = cursors[file] else { return } + private func parse(line data: Data) -> CodexSelection? { + CodexSelectionParser.parse( + line: String(decoding: data, as: UTF8.self) + ) + } + + /// Returns `true` when the stored cursor no longer describes a continuous + /// byte stream and the current launch family must be bootstrapped again. + private func readAppendedData(from file: URL) throws -> Bool { + guard var cursor = cursors[file] else { return false } let attributes = try FileManager.default.attributesOfItem(atPath: file.path) let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + let fileIdentity = identity(from: attributes) + + if fileIdentity != cursor.fileIdentity { + // Log rotation can replace a file at the same path with a new inode + // whose size is already larger than the old cursor. Reseeding is + // the only safe way to avoid silently skipping its beginning. + cursors.removeValue(forKey: file) + return true + } if size < cursor.offset { - cursor = LogCursor(offset: 0, remainder: "") - } - guard size > cursor.offset else { - cursors[file] = cursor - return + cursors.removeValue(forKey: file) + return true } - guard let handle = try? FileHandle(forReadingFrom: file) else { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: file) + } catch where isMissingFileError(error) { + cursors.removeValue(forKey: file) + return true + } catch { throw CodexProbeError.unreadableLog(file.path) } defer { try? handle.close() } + let currentAnchor = try readAnchor( + from: handle, + endingAt: cursor.offset + ) + if currentAnchor != cursor.anchor { + // copytruncate can rewrite the same inode and regrow beyond the old + // offset between polls. Verify a small byte anchor before trusting + // that offset, otherwise reseed the rewritten file. + cursors.removeValue(forKey: file) + return true + } + + guard size > cursor.offset else { + cursors[file] = cursor + return false + } + try handle.seek(toOffset: cursor.offset) - let data = try handle.readToEnd() ?? Data() - let text = cursor.remainder + String(decoding: data, as: UTF8.self) - consume(text: text, file: file, offset: size) + while cursor.offset < size { + let byteCount = Int( + min(UInt64(bootstrapChunkSize), size - cursor.offset) + ) + let data = try readData(from: handle, upToCount: byteCount) + guard !data.isEmpty else { break } + + var bufferedData = cursor.remainder + bufferedData.append(data) + let nextAnchor = updatedAnchor( + previous: cursor.anchor, + appended: data + ) + cursor = consume( + data: bufferedData, + offset: cursor.offset + UInt64(data.count), + anchor: nextAnchor, + fileIdentity: fileIdentity + ) + cursors[file] = cursor + } + return false } - private func consume(text: String, file: URL, offset: UInt64) { - let endsWithNewline = text.last == "\n" - var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let remainder = endsWithNewline ? "" : (lines.popLast() ?? "") - - for line in lines { - guard let selection = CodexSelectionParser.parse(line: line) else { continue } - if latest == nil || selection.timestamp >= latest!.timestamp { - latest = selection + private func consume( + data: Data, + offset: UInt64, + anchor: Data, + fileIdentity: LogFileIdentity + ) -> LogCursor { + var lineStart = data.startIndex + var index = data.startIndex + + while index < data.endIndex { + if data[index] == 0x0A { + let line = Data(data[lineStart..= latest!.timestamp { + latest = selection + } + lineStart = data.index(after: index) } + index = data.index(after: index) } - cursors[file] = LogCursor(offset: offset, remainder: remainder) + return LogCursor( + offset: offset, + remainder: Data(data[lineStart.. Data { + var result = Data() + result.reserveCapacity(byteCount) + + while result.count < byteCount { + let remaining = byteCount - result.count + guard let chunk = try handle.read(upToCount: remaining), + !chunk.isEmpty + else { break } + result.append(chunk) + } + return result + } + + private func readAnchor( + from handle: FileHandle, + endingAt offset: UInt64 + ) throws -> Data { + let byteCount = Int(min(UInt64(Self.cursorAnchorSize), offset)) + guard byteCount > 0 else { return Data() } + try handle.seek(toOffset: offset - UInt64(byteCount)) + return try readData(from: handle, upToCount: byteCount) + } + + private func updatedAnchor(previous: Data, appended: Data) -> Data { + if appended.count >= Self.cursorAnchorSize { + return Data(appended.suffix(Self.cursorAnchorSize)) + } + + var combined = previous + combined.append(appended) + return Data(combined.suffix(Self.cursorAnchorSize)) + } + + private func identity( + from attributes: [FileAttributeKey: Any] + ) -> LogFileIdentity { + LogFileIdentity( + systemNumber: (attributes[.systemNumber] as? NSNumber)?.uint64Value, + fileNumber: (attributes[.systemFileNumber] as? NSNumber)?.uint64Value + ) + } + + private func isMissingFileError(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + return nsError.code == NSFileNoSuchFileError + || nsError.code == NSFileReadNoSuchFileError + } + if let posixError = error as? POSIXError { + return posixError.code == .ENOENT + } + return false } } diff --git a/Sources/CodexNotesCore/Localization.swift b/Sources/CodexNotesCore/Localization.swift index 9884924..bbae073 100644 --- a/Sources/CodexNotesCore/Localization.swift +++ b/Sources/CodexNotesCore/Localization.swift @@ -69,6 +69,17 @@ public enum L10n { case settingsLaunchAtLoginRequiresApproval = "settings.launch_at_login.requires_approval" case settingsLaunchAtLoginOpenSystemSettings = "settings.launch_at_login.open_system_settings" case settingsLaunchAtLoginError = "settings.launch_at_login.error" + case settingsGlobalHotKeyTitle = "settings.global_hot_key.title" + case settingsGlobalHotKeyDescription = "settings.global_hot_key.description" + case settingsGlobalHotKeyRecord = "settings.global_hot_key.record" + case settingsGlobalHotKeyRecording = "settings.global_hot_key.recording" + case settingsGlobalHotKeyClear = "settings.global_hot_key.clear" + case settingsGlobalHotKeyRestoreDefault = "settings.global_hot_key.restore_default" + case settingsGlobalHotKeyConflict = "settings.global_hot_key.conflict" + case settingsGlobalHotKeyRegistrationFailed = "settings.global_hot_key.registration_failed" + case settingsGlobalHotKeyInvalidShortcut = "settings.global_hot_key.invalid_shortcut" + case settingsGlobalHotKeyAccessibilityLabel = "settings.global_hot_key.accessibility_label" + case settingsGlobalHotKeyAccessibilityHint = "settings.global_hot_key.accessibility_hint" case commonActionOK = "common.action.ok" case commonActionRetry = "common.action.retry" @@ -243,11 +254,15 @@ public enum L10n { case shortcutsSectionTodos = "shortcuts.section.todos" case shortcutsSectionNotes = "shortcuts.section.notes" case shortcutsSectionFormatting = "shortcuts.section.formatting" + case shortcutsSectionGlobal = "shortcuts.section.global" + case shortcutsGlobalAvailabilityNote = "shortcuts.global.availability_note" case shortcutsItemToggleCurrentLineTodo = "shortcuts.item.toggle_current_line_todo" case shortcutsItemTaskNote = "shortcuts.item.task_note" case shortcutsItemProjectNote = "shortcuts.item.project_note" case shortcutsItemBoldSelection = "shortcuts.item.bold_selection" case shortcutsItemHighlightSelection = "shortcuts.item.highlight_selection" + case shortcutsItemToggleCodexNotes = "shortcuts.item.toggle_codex_notes" + case globalHotKeyNotSet = "global_hot_key.not_set" case shortcutsItemAccessibilityLabel = "shortcuts.item.accessibility_label" case shortcutsFormattingRequiresSelection = "shortcuts.formatting.requires_selection" @@ -264,11 +279,46 @@ public enum L10n { case settingsEditorLineSpacingAccessibilityHint = "settings.editor.line_spacing.accessibility_hint" case settingsEditorPreviewText = "settings.editor.preview_text" case settingsEditorPreviewAccessibilityLabel = "settings.editor.preview.accessibility_label" + case settingsEditorWindowResizeHint = "settings.editor.window_resize_hint" case settingsEditorRestoreDefaults = "settings.editor.restore_defaults" case settingsEditorRestoreDefaultsAccessibilityLabel = "settings.editor.restore_defaults.accessibility_label" case settingsEditorRestoreDefaultsAccessibilityHint = "settings.editor.restore_defaults.accessibility_hint" case settingsStatusBarIconChoiceAccessibilityLabel = "settings.status_bar_icon.choice.accessibility_label" case settingsThemeChoiceAccessibilityLabel = "settings.theme.choice.accessibility_label" + case settingsAboutTitle = "settings.about.title" + case settingsAboutSubtitle = "settings.about.subtitle" + case settingsAboutVersion = "settings.about.version" + case settingsAboutAuthor = "settings.about.author" + case settingsAboutAuthorName = "settings.about.author_name" + case settingsAboutFeedbackEmail = "settings.about.feedback_email" + case settingsAboutFeedbackEmailAccessibilityHint = "settings.about.feedback_email.accessibility_hint" + case settingsAboutCopyEmail = "settings.about.copy_email" + case settingsAboutCopyEmailAccessibilityHint = "settings.about.copy_email.accessibility_hint" + case settingsAboutEmailCopied = "settings.about.email_copied" + case settingsAboutGitHub = "settings.about.github" + case settingsAboutX = "settings.about.x" + case settingsAboutGitHubAccessibilityHint = "settings.about.github.accessibility_hint" + case settingsAboutXAccessibilityHint = "settings.about.x.accessibility_hint" + case settingsAboutCheckUpdates = "settings.about.check_updates" + case settingsAboutCheckUpdatesAccessibilityHint = "settings.about.check_updates.accessibility_hint" + case settingsAboutAutomaticCheck = "settings.about.automatic_check" + case settingsAboutAutomaticCheckDescription = "settings.about.automatic_check.description" + case settingsAboutChecking = "settings.about.checking" + case settingsAboutUpToDate = "settings.about.up_to_date" + case settingsAboutUpdateAvailable = "settings.about.update_available" + case settingsAboutViewUpdate = "settings.about.view_update" + case settingsAboutViewUpdateAccessibilityHint = "settings.about.view_update.accessibility_hint" + case settingsAboutUpdateFailed = "settings.about.update_failed" + case settingsAboutCoffee = "settings.about.coffee" + case settingsAboutCoffeeAccessibilityHint = "settings.about.coffee.accessibility_hint" + case settingsAboutQRCodeTitle = "settings.about.qr_code.title" + case settingsAboutQRCodeMessage = "settings.about.qr_code.message" + case settingsAboutQRCodeAccessibilityLabel = "settings.about.qr_code.accessibility_label" + case settingsAboutQRCodeUnavailable = "settings.about.qr_code.unavailable" + case settingsAboutClose = "settings.about.close" + case appUpdateBannerAvailable = "app.update.banner.available" + case appUpdateBannerLater = "app.update.banner.later" + case bottomBarSettingsUpdateAvailableHelp = "bottom_bar.settings.update_available.help" case settingsThemeSystemOriginalName = "settings.theme.system_original.name" case settingsThemeMistPaperName = "settings.theme.mist_paper.name" diff --git a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings index 8720c5b..0283cf0 100644 --- a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings @@ -9,6 +9,17 @@ "settings.launch_at_login.requires_approval" = "CodexNotes was added as a login item but isn’t active yet. Allow it in System Settings."; "settings.launch_at_login.open_system_settings" = "Open System Settings"; "settings.launch_at_login.error" = "Couldn’t change the launch-at-login setting. Try again."; +"settings.global_hot_key.title" = "Show/Hide CodexNotes"; +"settings.global_hot_key.description" = "Available from any app while Codex is running and visible. If another app also responds, or nothing happens, choose a different shortcut."; +"settings.global_hot_key.record" = "Set Shortcut"; +"settings.global_hot_key.recording" = "Press a new shortcut…"; +"settings.global_hot_key.clear" = "Clear Shortcut"; +"settings.global_hot_key.restore_default" = "Restore Default"; +"settings.global_hot_key.conflict" = "This shortcut couldn’t be registered. It may be in use by macOS or another app. Choose another."; +"settings.global_hot_key.registration_failed" = "Couldn’t enable this shortcut. Choose another and try again."; +"settings.global_hot_key.invalid_shortcut" = "Use a regular key with at least two modifier keys, and avoid common shortcuts such as ⌘C and ⌘Q."; +"settings.global_hot_key.accessibility_label" = "Global shortcut to show or hide CodexNotes"; +"settings.global_hot_key.accessibility_hint" = "Available from any app while Codex is running and visible. If another app also responds, or nothing happens, choose a different shortcut. Click, then press a new shortcut. Press Escape to cancel or Delete to clear it."; "common.action.ok" = "OK"; "common.action.retry" = "Retry"; @@ -183,11 +194,15 @@ "shortcuts.section.todos" = "To-dos"; "shortcuts.section.notes" = "Notes"; "shortcuts.section.formatting" = "Formatting"; +"shortcuts.section.global" = "Global"; +"shortcuts.global.availability_note" = "Available only while Codex is running and visible"; "shortcuts.item.toggle_current_line_todo" = "Toggle Current Line’s To-do"; "shortcuts.item.task_note" = "Task Note"; "shortcuts.item.project_note" = "Project Note"; "shortcuts.item.bold_selection" = "Bold Selected Text"; "shortcuts.item.highlight_selection" = "Highlight Selected Text"; +"shortcuts.item.toggle_codex_notes" = "Show/Hide CodexNotes"; +"global_hot_key.not_set" = "Not Set"; "shortcuts.item.accessibility_label" = "{title}, {shortcut}"; "shortcuts.formatting.requires_selection" = "Formatting shortcuts require selected text"; @@ -204,11 +219,46 @@ "settings.editor.line_spacing.accessibility_hint" = "0 pt uses the font’s default line spacing. Higher values add more space."; "settings.editor.preview_text" = "This is how your note text will look.\nThe second line changes with line spacing.\nAdjust it until it feels comfortable to read."; "settings.editor.preview.accessibility_label" = "Body Text Preview"; +"settings.editor.window_resize_hint" = "Drag a note window edge to resize. CodexNotes remembers the size."; "settings.editor.restore_defaults" = "Restore Editor Defaults"; "settings.editor.restore_defaults.accessibility_label" = "Restore Editor Defaults"; "settings.editor.restore_defaults.accessibility_hint" = "Restore the 15 pt body text size and 4 pt line spacing."; "settings.status_bar_icon.choice.accessibility_label" = "{name} menu bar icon"; "settings.theme.choice.accessibility_label" = "{name} theme"; +"settings.about.title" = "About CodexNotes"; +"settings.about.subtitle" = "Local Markdown notes that follow Codex"; +"settings.about.version" = "Version"; +"settings.about.author" = "Author"; +"settings.about.author_name" = "江sir爱数码"; +"settings.about.feedback_email" = "Feedback"; +"settings.about.feedback_email.accessibility_hint" = "Use your default email app to send CodexNotes feedback to the author."; +"settings.about.copy_email" = "Copy Email Address"; +"settings.about.copy_email.accessibility_hint" = "Copy the feedback email address to the clipboard."; +"settings.about.email_copied" = "Copied"; +"settings.about.github" = "GitHub Project"; +"settings.about.x" = "X: Follow the Author"; +"settings.about.github.accessibility_hint" = "Open the CodexNotes GitHub project in your browser."; +"settings.about.x.accessibility_hint" = "Open the author’s X profile in your browser."; +"settings.about.check_updates" = "Check for Updates…"; +"settings.about.check_updates.accessibility_hint" = "Connect to GitHub and check for a newer CodexNotes release."; +"settings.about.automatic_check" = "Automatically Check for Updates"; +"settings.about.automatic_check.description" = "Check once a day; retry later after a network failure, and notify only for new versions."; +"settings.about.checking" = "Checking…"; +"settings.about.up_to_date" = "Up to Date"; +"settings.about.update_available" = "Version {version} Available"; +"settings.about.view_update" = "View Update"; +"settings.about.view_update.accessibility_hint" = "Open the latest CodexNotes release page in your browser."; +"settings.about.update_failed" = "Check Failed"; +"settings.about.coffee" = "☕ Buy Me a Coffee"; +"settings.about.coffee.accessibility_hint" = "Show the author’s WeChat support code."; +"settings.about.qr_code.title" = "Support CodexNotes"; +"settings.about.qr_code.message" = "Scan with WeChat. Thank you for supporting development."; +"settings.about.qr_code.accessibility_label" = "Jiang Sir Digital’s WeChat support code"; +"settings.about.qr_code.unavailable" = "The WeChat support code couldn’t be loaded."; +"settings.about.close" = "Close"; +"app.update.banner.available" = "CodexNotes {version} is available"; +"app.update.banner.later" = "Later"; +"bottom_bar.settings.update_available.help" = "Open Settings; CodexNotes {version} is available."; "settings.theme.system_original.name" = "System Default"; "settings.theme.mist_paper.name" = "Silver Mist Tracing Paper"; diff --git a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings index a159fdd..9d5a52e 100644 --- a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings @@ -9,6 +9,17 @@ "settings.launch_at_login.requires_approval" = "CodexNotes 已添加为登录项,但尚未生效;请在系统设置中允许。"; "settings.launch_at_login.open_system_settings" = "打开系统设置"; "settings.launch_at_login.error" = "无法更改登录启动设置,请重试。"; +"settings.global_hot_key.title" = "显示/隐藏 CodexNotes"; +"settings.global_hot_key.description" = "Codex 正在显示时,可在任意应用中使用;若同时触发其他应用或无响应,请换一个"; +"settings.global_hot_key.record" = "设置快捷键"; +"settings.global_hot_key.recording" = "请按下新快捷键…"; +"settings.global_hot_key.clear" = "清除快捷键"; +"settings.global_hot_key.restore_default" = "恢复默认"; +"settings.global_hot_key.conflict" = "无法注册这个快捷键;它可能已被系统或其他应用占用,请更换"; +"settings.global_hot_key.registration_failed" = "无法启用此快捷键,请换一个重试"; +"settings.global_hot_key.invalid_shortcut" = "请使用至少两个修饰键搭配普通按键,并避开 ⌘C、⌘Q 等常用快捷键"; +"settings.global_hot_key.accessibility_label" = "显示或隐藏 CodexNotes 的全局快捷键"; +"settings.global_hot_key.accessibility_hint" = "Codex 正在显示时可在任意应用中使用。若与其他应用同时触发或无响应,请更换。点击后按下新快捷键;按 Escape 取消,按 Delete 清除"; "common.action.ok" = "好"; "common.action.retry" = "重试"; @@ -183,11 +194,15 @@ "shortcuts.section.todos" = "待办"; "shortcuts.section.notes" = "笔记"; "shortcuts.section.formatting" = "格式"; +"shortcuts.section.global" = "全局"; +"shortcuts.global.availability_note" = "仅在 Codex 正在运行且未隐藏时可用"; "shortcuts.item.toggle_current_line_todo" = "切换当前行待办"; "shortcuts.item.task_note" = "任务笔记"; "shortcuts.item.project_note" = "项目笔记"; "shortcuts.item.bold_selection" = "加粗所选文字"; "shortcuts.item.highlight_selection" = "高亮所选文字"; +"shortcuts.item.toggle_codex_notes" = "显示/隐藏 CodexNotes"; +"global_hot_key.not_set" = "未设置"; "shortcuts.item.accessibility_label" = "{title},{shortcut}"; "shortcuts.formatting.requires_selection" = "格式快捷键需要先选中文字"; @@ -204,11 +219,46 @@ "settings.editor.line_spacing.accessibility_hint" = "0 pt 使用字体默认行距,数值越大行距越宽"; "settings.editor.preview_text" = "这是笔记正文的显示效果\n第二行会随行间距变化\n调到阅读舒适的位置即可"; "settings.editor.preview.accessibility_label" = "正文预览"; +"settings.editor.window_resize_hint" = "拖动笔记窗口边缘可调整大小,尺寸会自动记住。"; "settings.editor.restore_defaults" = "恢复编辑器默认"; "settings.editor.restore_defaults.accessibility_label" = "恢复编辑器默认设置"; "settings.editor.restore_defaults.accessibility_hint" = "恢复 15 pt 正文字号和 4 pt 行间距"; "settings.status_bar_icon.choice.accessibility_label" = "{name}状态栏图标"; "settings.theme.choice.accessibility_label" = "{name}主题"; +"settings.about.title" = "关于 CodexNotes"; +"settings.about.subtitle" = "跟随 Codex 的本地 Markdown 笔记"; +"settings.about.version" = "版本"; +"settings.about.author" = "作者"; +"settings.about.author_name" = "江sir爱数码"; +"settings.about.feedback_email" = "反馈邮箱"; +"settings.about.feedback_email.accessibility_hint" = "使用默认邮件应用向作者反馈 CodexNotes 问题"; +"settings.about.copy_email" = "复制邮箱地址"; +"settings.about.copy_email.accessibility_hint" = "将反馈邮箱地址复制到剪贴板"; +"settings.about.email_copied" = "已复制"; +"settings.about.github" = "GitHub 项目"; +"settings.about.x" = "X:关注作者"; +"settings.about.github.accessibility_hint" = "在浏览器中打开 CodexNotes 的 GitHub 项目"; +"settings.about.x.accessibility_hint" = "在浏览器中打开作者的 X 主页"; +"settings.about.check_updates" = "检查更新…"; +"settings.about.check_updates.accessibility_hint" = "连接 GitHub,检查是否有新的 CodexNotes Release"; +"settings.about.automatic_check" = "自动检查更新"; +"settings.about.automatic_check.description" = "每天检查一次;网络失败会稍后重试,仅发现新版本时提示。"; +"settings.about.checking" = "正在检查…"; +"settings.about.up_to_date" = "已是最新版本"; +"settings.about.update_available" = "发现 {version}"; +"settings.about.view_update" = "查看更新"; +"settings.about.view_update.accessibility_hint" = "在浏览器中打开最新的 CodexNotes Release 页面"; +"settings.about.update_failed" = "检查失败"; +"settings.about.coffee" = "☕ 请江sir喝杯咖啡"; +"settings.about.coffee.accessibility_hint" = "显示作者的微信赞赏码"; +"settings.about.qr_code.title" = "支持 CodexNotes"; +"settings.about.qr_code.message" = "打开微信扫一扫,感谢你对开发的支持。"; +"settings.about.qr_code.accessibility_label" = "江sir爱数码的微信赞赏码"; +"settings.about.qr_code.unavailable" = "暂时无法读取微信赞赏码"; +"settings.about.close" = "关闭"; +"app.update.banner.available" = "CodexNotes {version} 现已可用"; +"app.update.banner.later" = "稍后"; +"bottom_bar.settings.update_available.help" = "打开设置;CodexNotes {version} 现已可用"; "settings.theme.system_original.name" = "系统原色"; "settings.theme.mist_paper.name" = "银雾描图纸"; diff --git a/Sources/CodexNotesProbe/AboutCodexNotesView.swift b/Sources/CodexNotesProbe/AboutCodexNotesView.swift new file mode 100644 index 0000000..04ff587 --- /dev/null +++ b/Sources/CodexNotesProbe/AboutCodexNotesView.swift @@ -0,0 +1,609 @@ +import AppKit +import CodexNotesCore +import SwiftUI + +enum SettingsAboutPresentation { + static let appName = "CodexNotes" + static let repositoryURL = URL(string: "https://github.com/jiangsir-tech/CodexNotes")! + static let xProfileURL = URL(string: "https://x.com/YongJiang_Li_")! + static let feedbackEmailAddress = "li-yongjiang@foxmail.com" + static let feedbackEmailURL = URL(string: "mailto:\(feedbackEmailAddress)")! + static let rewardCodeResourceName = "WeChatRewardCode" + static let rewardCodeResourceExtension = "jpg" + static let appIconSize: CGFloat = 64 + static let copyConfirmationDurationNanoseconds: UInt64 = 1_500_000_000 + + static func visibleVersion(_ bundleVersion: AppBundleVersion) -> String { + bundleVersion.version + } + + static func rewardCodeURL(bundle: Bundle = .module) -> URL? { + bundle.url( + forResource: rewardCodeResourceName, + withExtension: rewardCodeResourceExtension + ) + } + + static func rewardCodeImage(bundle: Bundle = .module) -> NSImage? { + guard let url = rewardCodeURL(bundle: bundle) else { return nil } + return NSImage(contentsOf: url) + } + + @MainActor + @discardableResult + static func copyFeedbackEmail(to pasteboard: NSPasteboard = .general) -> Bool { + pasteboard.clearContents() + return pasteboard.setString(feedbackEmailAddress, forType: .string) + } + + static func isAllowedProjectURL(_ url: URL) -> Bool { + url.scheme == "https" + && url.host?.lowercased() == "github.com" + && url.path == "/jiangsir-tech/CodexNotes" + } + + static func isAllowedXProfileURL(_ url: URL) -> Bool { + url.scheme == "https" + && url.host?.lowercased() == "x.com" + && url.path == "/YongJiang_Li_" + } + + static func isAllowedFeedbackEmailURL(_ url: URL) -> Bool { + url.scheme?.lowercased() == "mailto" + && url.absoluteString == "mailto:\(feedbackEmailAddress)" + } +} + +struct AboutCodexNotesView: View { + let palette: NoteThemePalette + let languageRevision: String + + @ObservedObject private var updateCoordinator: UpdateCheckCoordinator + @State private var isShowingRewardCode = false + @State private var emailCopyNoticeID: UUID? + + private let bundleVersion = AppBundleVersion.current + + init( + palette: NoteThemePalette, + languageRevision: String, + updateCoordinator: UpdateCheckCoordinator + ) { + self.palette = palette + self.languageRevision = languageRevision + _updateCoordinator = ObservedObject(wrappedValue: updateCoordinator) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + appIdentity + + Divider() + .overlay(palette.separator.color) + + VStack(alignment: .leading, spacing: 12) { + versionRow + automaticCheckRow + informationRow( + label: L10n.text(.settingsAboutAuthor), + value: L10n.text(.settingsAboutAuthorName) + ) + feedbackEmailRow + } + + Divider() + .overlay(palette.separator.color) + + supportRow + } + .sheet(isPresented: $isShowingRewardCode) { + WeChatRewardCodeSheet( + palette: palette, + image: SettingsAboutPresentation.rewardCodeImage() + ) + } + } + + private var appIdentity: some View { + HStack(spacing: 14) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame( + width: SettingsAboutPresentation.appIconSize, + height: SettingsAboutPresentation.appIconSize + ) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(SettingsAboutPresentation.appName) + .font(.title2.weight(.semibold)) + Text(L10n.text(.settingsAboutSubtitle)) + .font(.subheadline) + .foregroundStyle(palette.secondaryText.color) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + } + + private var versionRow: some View { + HStack(alignment: .center, spacing: 12) { + Text(L10n.text(.settingsAboutVersion)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(palette.secondaryText.color) + .frame(width: 64, alignment: .leading) + + Text(SettingsAboutPresentation.visibleVersion(bundleVersion)) + .font(.subheadline) + .monospacedDigit() + .lineLimit(1) + + Spacer(minLength: 8) + + updateStatus + + Button(updateActionTitle) { + performUpdateAction() + } + .disabled(updateCoordinator.state == .checking) + .controlSize(.regular) + .accessibilityHint(Text(updateActionAccessibilityHint)) + } + } + + private var automaticCheckRow: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text(.settingsAboutAutomaticCheck)) + .font(.subheadline.weight(.medium)) + Text(L10n.text(.settingsAboutAutomaticCheckDescription)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityHidden(true) + + Toggle( + L10n.text(.settingsAboutAutomaticCheck), + isOn: automaticCheckPreference + ) + .labelsHidden() + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Text(L10n.text(.settingsAboutAutomaticCheck))) + .accessibilityValue(Text(L10n.text( + updateCoordinator.isAutomaticCheckEnabled + ? .accessibilityValueOn + : .accessibilityValueOff + ))) + .accessibilityHint(Text(L10n.text(.settingsAboutAutomaticCheckDescription))) + } + .frame(maxWidth: .infinity) + } + + private var automaticCheckPreference: Binding { + Binding( + get: { updateCoordinator.isAutomaticCheckEnabled }, + set: { updateCoordinator.setAutomaticChecksEnabled($0) } + ) + } + + private func informationRow(label: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(palette.secondaryText.color) + .frame(width: 64, alignment: .leading) + Text(value) + .font(.subheadline) + Spacer(minLength: 0) + } + } + + private var feedbackEmailRow: some View { + HStack(alignment: .center, spacing: 4) { + Text(L10n.text(.settingsAboutFeedbackEmail)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(palette.secondaryText.color) + .frame(width: 64, alignment: .leading) + + HStack(spacing: 4) { + AboutLinkButton( + title: SettingsAboutPresentation.feedbackEmailAddress, + url: SettingsAboutPresentation.feedbackEmailURL, + accessibilityHint: L10n.text( + .settingsAboutFeedbackEmailAccessibilityHint + ), + trailingSystemImage: nil, + palette: palette + ) + + CopyFeedbackEmailButton( + isCopied: isShowingEmailCopied, + palette: palette, + action: copyFeedbackEmail + ) + + Text(L10n.text(.settingsAboutEmailCopied)) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.accent.color) + .lineLimit(1) + .frame(minWidth: 50, alignment: .leading) + .fixedSize(horizontal: true, vertical: false) + .opacity(isShowingEmailCopied ? 1 : 0) + .accessibilityHidden(true) + } + + Spacer(minLength: 0) + } + .task(id: emailCopyNoticeID) { @MainActor in + guard let noticeID = emailCopyNoticeID else { return } + + do { + try await Task.sleep( + nanoseconds: SettingsAboutPresentation + .copyConfirmationDurationNanoseconds + ) + } catch { + return + } + guard !Task.isCancelled, emailCopyNoticeID == noticeID else { return } + emailCopyNoticeID = nil + } + } + + private var isShowingEmailCopied: Bool { + emailCopyNoticeID != nil + } + + private func copyFeedbackEmail() { + guard SettingsAboutPresentation.copyFeedbackEmail() else { + emailCopyNoticeID = nil + return + } + emailCopyNoticeID = UUID() + } + + @ViewBuilder + private var updateStatus: some View { + switch updateCoordinator.state { + case .idle: + EmptyView() + case .checking: + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(L10n.text(.settingsAboutChecking)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + } + .accessibilityElement(children: .combine) + case .upToDate: + Text(L10n.text(.settingsAboutUpToDate)) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.accent.color) + case let .updateAvailable(version, _): + Text(L10n.text( + .settingsAboutUpdateAvailable, + replacements: ["version": version] + )) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.accent.color) + case .failed: + Text(L10n.text(.settingsAboutUpdateFailed)) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.error.color) + } + } + + private var updateActionTitle: String { + switch updateCoordinator.state { + case .checking: + return L10n.text(.settingsAboutChecking) + case .updateAvailable: + return L10n.text(.settingsAboutViewUpdate) + case .failed: + return L10n.text(.commonActionRetry) + case .idle, .upToDate: + return L10n.text(.settingsAboutCheckUpdates) + } + } + + private var updateActionAccessibilityHint: String { + switch updateCoordinator.state { + case .updateAvailable: + return L10n.text(.settingsAboutViewUpdateAccessibilityHint) + case .idle, .checking, .upToDate, .failed: + return L10n.text(.settingsAboutCheckUpdatesAccessibilityHint) + } + } + + private func performUpdateAction() { + if case let .updateAvailable(_, url) = updateCoordinator.state { + updateCoordinator.dismissBanner() + NSWorkspace.shared.open(url) + return + } + + Task { @MainActor [updateCoordinator] in + await updateCoordinator.manualCheck() + } + } + + private var supportRow: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 12) { + supportLinks + Spacer(minLength: 8) + coffeeButton + } + + VStack(alignment: .leading, spacing: 10) { + supportLinks + coffeeButton + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame(maxWidth: .infinity) + } + + private var supportLinks: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 4) { + gitHubLink + xProfileLink + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .leading, spacing: 2) { + gitHubLink + xProfileLink + } + } + } + + private var gitHubLink: some View { + AboutLinkButton( + title: L10n.text(.settingsAboutGitHub), + url: SettingsAboutPresentation.repositoryURL, + accessibilityHint: L10n.text(.settingsAboutGitHubAccessibilityHint), + trailingSystemImage: "arrow.up.right", + palette: palette + ) + } + + private var xProfileLink: some View { + AboutLinkButton( + title: L10n.text(.settingsAboutX), + url: SettingsAboutPresentation.xProfileURL, + accessibilityHint: L10n.text(.settingsAboutXAccessibilityHint), + trailingSystemImage: "arrow.up.right", + palette: palette + ) + } + + private var coffeeButton: some View { + Button(L10n.text(.settingsAboutCoffee)) { + isShowingRewardCode = true + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 0.84, green: 0.57, blue: 0.14)) + .foregroundStyle(Color.black.opacity(0.82)) + .fixedSize(horizontal: true, vertical: false) + .accessibilityHint(Text(L10n.text(.settingsAboutCoffeeAccessibilityHint))) + } +} + +private struct AboutLinkButton: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @FocusState private var isFocused: Bool + @State private var isHovered = false + + let title: String + let url: URL + let accessibilityHint: String + let trailingSystemImage: String? + let palette: NoteThemePalette + + private var isHighlighted: Bool { + isHovered || isFocused + } + + var body: some View { + Button { + NSWorkspace.shared.open(url) + } label: { + HStack(spacing: 5) { + Text(title) + .lineLimit(1) + + if let trailingSystemImage { + Image(systemName: trailingSystemImage) + .font(.system(size: 10, weight: .semibold)) + .offset( + x: isHighlighted && !reduceMotion ? 1 : 0, + y: isHighlighted && !reduceMotion ? -1 : 0 + ) + .accessibilityHidden(true) + } + } + .foregroundStyle( + isHighlighted ? palette.accent.color : palette.primaryText.color + ) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minHeight: 30) + .background { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(palette.accent.color.opacity(isHighlighted ? 0.09 : 0)) + } + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke( + palette.accent.color.opacity(isHighlighted ? 0.30 : 0), + lineWidth: 1 + ) + } + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .fixedSize(horizontal: true, vertical: false) + .focused($isFocused) + .overlay { + PointingHandCursorRegion() + .allowsHitTesting(false) + } + .onHover { hovering in + isHovered = hovering + } + .onDisappear { + isHovered = false + } + .animation( + reduceMotion ? nil : .easeOut(duration: 0.13), + value: isHighlighted + ) + .help(accessibilityHint) + .accessibilityLabel(Text(title)) + .accessibilityHint(Text(accessibilityHint)) + } +} + +private struct CopyFeedbackEmailButton: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @FocusState private var isFocused: Bool + @State private var isHovered = false + + let isCopied: Bool + let palette: NoteThemePalette + let action: () -> Void + + private var isHighlighted: Bool { + isHovered || isFocused || isCopied + } + + private var accessibilityLabel: String { + L10n.text(isCopied ? .settingsAboutEmailCopied : .settingsAboutCopyEmail) + } + + var body: some View { + Button(action: action) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle( + isHighlighted ? palette.accent.color : palette.secondaryText.color + ) + .frame(width: 30, height: 30) + .background { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(palette.accent.color.opacity(isHighlighted ? 0.09 : 0)) + } + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke( + palette.accent.color.opacity(isHighlighted ? 0.30 : 0), + lineWidth: 1 + ) + } + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .focused($isFocused) + .overlay { + PointingHandCursorRegion() + .allowsHitTesting(false) + } + .onHover { hovering in + isHovered = hovering + } + .onDisappear { + isHovered = false + } + .animation( + reduceMotion ? nil : .easeOut(duration: 0.13), + value: isHighlighted + ) + .help(accessibilityLabel) + .accessibilityLabel(Text(accessibilityLabel)) + .accessibilityHint(Text(L10n.text(.settingsAboutCopyEmailAccessibilityHint))) + } +} + +private struct PointingHandCursorRegion: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + PointingHandCursorView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.window?.invalidateCursorRects(for: nsView) + } +} + +private final class PointingHandCursorView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .pointingHand) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.invalidateCursorRects(for: self) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + } +} + +private struct WeChatRewardCodeSheet: View { + @Environment(\.dismiss) private var dismiss + + let palette: NoteThemePalette + let image: NSImage? + + var body: some View { + VStack(spacing: 16) { + VStack(spacing: 5) { + Text(L10n.text(.settingsAboutQRCodeTitle)) + .font(.title2.weight(.semibold)) + Text(L10n.text(.settingsAboutQRCodeMessage)) + .font(.subheadline) + .foregroundStyle(palette.secondaryText.color) + .multilineTextAlignment(.center) + } + + if let image { + Image(nsImage: image) + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 360, height: 360) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityLabel(Text(L10n.text(.settingsAboutQRCodeAccessibilityLabel))) + } else { + ContentUnavailableView( + L10n.text(.settingsAboutQRCodeUnavailable), + systemImage: "qrcode" + ) + .frame(width: 360, height: 360) + } + + Button(L10n.text(.settingsAboutClose)) { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding(24) + .frame(width: 420) + .foregroundStyle(palette.primaryText.color) + .background(palette.windowBackground.color) + } +} diff --git a/Sources/CodexNotesProbe/AppUpdateChecker.swift b/Sources/CodexNotesProbe/AppUpdateChecker.swift new file mode 100644 index 0000000..4ce1fcb --- /dev/null +++ b/Sources/CodexNotesProbe/AppUpdateChecker.swift @@ -0,0 +1,452 @@ +import Combine +import Foundation + +enum AppUpdateState: Equatable, Sendable { + case idle + case checking + case upToDate + case updateAvailable(version: String, url: URL) + case failed +} + +struct AvailableAppUpdate: Equatable, Sendable { + let version: String + let url: URL +} + +struct AppBundleVersion: Equatable, Sendable { + static let missingValue = "—" + + let version: String + let build: String + + static var current: AppBundleVersion { + AppBundleVersion(bundle: .main) + } + + init(bundle: Bundle) { + self.init( + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + build: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ) + } + + init(version: String?, build: String?) { + self.version = Self.presentableValue(version) + self.build = Self.presentableValue(build) + } + + var displayVersion: String { + "\(version) (\(build))" + } + + private static func presentableValue(_ value: String?) -> String { + guard let value else { + return missingValue + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? missingValue : trimmed + } +} + +enum AppUpdateValidation { + static func availableUpdate( + version rawVersion: String, + url rawURL: String, + installedVersion rawInstalledVersion: String + ) -> AvailableAppUpdate? { + guard + let installedVersion = SemanticVersion(rawInstalledVersion), + let availableVersion = SemanticVersion(rawVersion), + availableVersion > installedVersion, + let releaseURL = validatedReleaseURL( + rawURL, + matchingVersion: availableVersion.displayString + ) + else { + return nil + } + + return AvailableAppUpdate( + version: availableVersion.displayString, + url: releaseURL + ) + } + + static func validatedReleaseURL( + _ rawValue: String, + matchingVersion rawVersion: String + ) -> URL? { + let releaseTagPathPrefix = "/jiangsir-tech/CodexNotes/releases/tag/" + guard + let expectedVersion = SemanticVersion(rawVersion), + let components = URLComponents(string: rawValue), + components.scheme?.lowercased() == "https", + components.host?.lowercased() == "github.com", + components.user == nil, + components.password == nil, + components.port == nil, + components.query == nil, + components.fragment == nil, + components.percentEncodedPath == components.path, + components.path.hasPrefix(releaseTagPathPrefix), + let urlVersion = SemanticVersion( + String(components.path.dropFirst(releaseTagPathPrefix.count)) + ), + urlVersion.hasSameIdentity(as: expectedVersion), + let url = components.url + else { + return nil + } + + return url + } +} + +@MainActor +final class AppUpdateChecker: ObservableObject { + typealias Fetcher = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + static let latestReleaseAPIURL = URL( + string: "https://api.github.com/repos/jiangsir-tech/CodexNotes/releases/latest" + )! + static let requestTimeout: TimeInterval = 10 + static let maximumResponseSize = 1_048_576 + + @Published private(set) var state: AppUpdateState = .idle + + private let currentVersion: String + private let fetcher: Fetcher + private var checkTask: Task? + private var requestGeneration = 0 + + convenience init(currentVersion: String = AppBundleVersion.current.version) { + self.init(currentVersion: currentVersion) { request in + try await URLSession.shared.data(for: request) + } + } + + init( + currentVersion: String, + fetcher: @escaping Fetcher + ) { + self.currentVersion = currentVersion + self.fetcher = fetcher + } + + /// Runs a user-requested update check. A concurrent call returns immediately. + @discardableResult + func checkForUpdates() async -> AppUpdateState? { + guard checkTask == nil else { + return nil + } + + requestGeneration &+= 1 + let generation = requestGeneration + let request = Self.makeRequest() + let currentVersion = currentVersion + let fetcher = fetcher + + state = .checking + let task = Task { () -> AppUpdateState? in + do { + let (data, response) = try await fetcher(request) + guard !Task.isCancelled else { + return nil + } + return Self.evaluate( + data: data, + response: response, + currentVersion: currentVersion + ) + } catch { + guard !Task.isCancelled else { + return nil + } + return .failed + } + } + checkTask = task + + guard let nextState = await task.value else { + return nil + } + guard finish(nextState, generation: generation) else { + return nil + } + return nextState + } + + func cancel() { + guard let checkTask else { + return + } + + requestGeneration &+= 1 + self.checkTask = nil + checkTask.cancel() + if state == .checking { + state = .idle + } + } + + private func finish(_ nextState: AppUpdateState, generation: Int) -> Bool { + guard generation == requestGeneration else { + return false + } + + checkTask = nil + state = nextState + return true + } + + private static func makeRequest() -> URLRequest { + var request = URLRequest( + url: latestReleaseAPIURL, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: requestTimeout + ) + request.httpMethod = "GET" + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + request.setValue("CodexNotes", forHTTPHeaderField: "User-Agent") + return request + } + + private static func evaluate( + data: Data, + response: URLResponse, + currentVersion: String + ) -> AppUpdateState { + guard + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + data.count <= maximumResponseSize, + let release = try? JSONDecoder().decode(GitHubRelease.self, from: data), + release.draft == false, + release.prerelease == false, + let installedVersion = SemanticVersion(currentVersion), + let latestVersion = SemanticVersion(release.tagName), + let releaseURL = AppUpdateValidation.validatedReleaseURL( + release.htmlURL, + matchingVersion: latestVersion.displayString + ) + else { + return .failed + } + + guard latestVersion > installedVersion else { + return .upToDate + } + + return .updateAvailable( + version: latestVersion.displayString, + url: releaseURL + ) + } + +} + +private struct GitHubRelease: Decodable { + let tagName: String + let htmlURL: String + let draft: Bool + let prerelease: Bool + + private enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlURL = "html_url" + case draft + case prerelease + } +} + +private struct SemanticVersion: Comparable, Sendable { + private enum PrereleaseIdentifier: Equatable, Sendable { + case numeric(UInt) + case textual(String) + + var description: String { + switch self { + case let .numeric(value): + return String(value) + case let .textual(value): + return value + } + } + } + + let major: UInt + let minor: UInt + let patch: UInt + private let prerelease: [PrereleaseIdentifier] + private let buildMetadata: [String] + + var displayString: String { + var value = "\(major).\(minor).\(patch)" + if !prerelease.isEmpty { + value += "-" + prerelease.map(\.description).joined(separator: ".") + } + if !buildMetadata.isEmpty { + value += "+" + buildMetadata.joined(separator: ".") + } + return value + } + + init?(_ rawValue: String) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed == rawValue, !trimmed.isEmpty else { + return nil + } + + let versionText: Substring + if trimmed.first == "v" || trimmed.first == "V" { + versionText = trimmed.dropFirst() + } else { + versionText = Substring(trimmed) + } + + let buildParts = versionText.split(separator: "+", omittingEmptySubsequences: false) + guard buildParts.count <= 2 else { + return nil + } + + let precedenceText = buildParts[0] + let coreText: Substring + let prereleaseText: Substring? + if let separatorIndex = precedenceText.firstIndex(of: "-") { + coreText = precedenceText[.. Bool { + if lhs.major != rhs.major { + return lhs.major < rhs.major + } + if lhs.minor != rhs.minor { + return lhs.minor < rhs.minor + } + if lhs.patch != rhs.patch { + return lhs.patch < rhs.patch + } + + switch (lhs.prerelease.isEmpty, rhs.prerelease.isEmpty) { + case (true, true): + return false + case (true, false): + return false + case (false, true): + return true + case (false, false): + break + } + + for (left, right) in zip(lhs.prerelease, rhs.prerelease) { + if left == right { + continue + } + + switch (left, right) { + case let (.numeric(leftValue), .numeric(rightValue)): + return leftValue < rightValue + case (.numeric, .textual): + return true + case (.textual, .numeric): + return false + case let (.textual(leftValue), .textual(rightValue)): + return leftValue < rightValue + } + } + + return lhs.prerelease.count < rhs.prerelease.count + } + + static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + lhs.major == rhs.major + && lhs.minor == rhs.minor + && lhs.patch == rhs.patch + && lhs.prerelease == rhs.prerelease + } + + func hasSameIdentity(as other: SemanticVersion) -> Bool { + self == other && buildMetadata == other.buildMetadata + } + + private static func parseCoreNumber(_ value: Substring) -> UInt? { + guard + !value.isEmpty, + value.allSatisfy(\.isNumber), + value.count == 1 || value.first != "0" + else { + return nil + } + return UInt(value) + } + + private static func isValidIdentifier(_ value: Substring) -> Bool { + !value.isEmpty && value.allSatisfy { + $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") + } + } +} diff --git a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift index 78e7b11..6d76678 100644 --- a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift +++ b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift @@ -5,6 +5,7 @@ import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { weak var model: ProbeViewModel? + let globalHotKeyController = GlobalHotKeyController() private var statusItemController: StatusItemController? func applicationDidFinishLaunching(_ notification: Notification) { @@ -19,6 +20,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if statusItemController == nil { statusItemController = StatusItemController() } + globalHotKeyController.start() + } + + func applicationWillTerminate(_ notification: Notification) { + globalHotKeyController.stop() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -48,6 +54,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { struct CodexNotesProbeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var model = ProbeViewModel() + @StateObject private var updateCoordinator = UpdateCheckCoordinator() @AppStorage(AppLanguagePreference.key) private var storedLanguagePreference = AppLanguagePreference.defaultValue.rawValue @@ -67,14 +74,20 @@ struct CodexNotesProbeApp: App { WindowGroup { ContentView( model: model, + updateCoordinator: updateCoordinator, + globalHotKeyController: appDelegate.globalHotKeyController, languagePreference: languagePreference ) .environment(\.locale, resolvedLanguage.locale) .onAppear { appDelegate.model = model + updateCoordinator.start() } } - .defaultSize(width: 400, height: 660) + .defaultSize( + width: MainWindowInitialPlacementPolicy.fallbackSize.width, + height: MainWindowInitialPlacementPolicy.fallbackSize.height + ) .windowResizability(.contentMinSize) .commands { CommandGroup(replacing: .newItem) {} @@ -87,7 +100,10 @@ struct CodexNotesProbeApp: App { } Settings { - SettingsView() + SettingsView( + updateCoordinator: updateCoordinator, + globalHotKeyController: appDelegate.globalHotKeyController + ) .environment(\.locale, resolvedLanguage.locale) } } diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift index 3c5aab1..cf232bf 100644 --- a/Sources/CodexNotesProbe/ContentView.swift +++ b/Sources/CodexNotesProbe/ContentView.swift @@ -4,6 +4,8 @@ import SwiftUI struct ContentView: View { @ObservedObject var model: ProbeViewModel + @ObservedObject var updateCoordinator: UpdateCheckCoordinator + @ObservedObject var globalHotKeyController: GlobalHotKeyController let languagePreference: AppLanguagePreference @StateObject private var editorController = MarkdownEditorController() @Environment(\.colorScheme) private var inheritedColorScheme @@ -32,10 +34,18 @@ struct ContentView: View { "\(languagePreference.rawValue):\(resolvedLanguage.rawValue)" } + private var globalHotKeyDisplayName: String? { + guard globalHotKeyController.currentShortcut != nil else { return nil } + return globalHotKeyController.displayName + } + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { scopeSelector + if let update = updateCoordinator.bannerUpdate { + appUpdateBanner(update) + } if case let .unavailable(message) = model.state { connectionIssueBanner(message) } @@ -156,6 +166,70 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + private func appUpdateBanner(_ update: AvailableAppUpdate) -> some View { + let presentation = AppUpdateBannerPresentation(update) + + return ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 8) { + appUpdateBannerTitle(presentation) + Spacer(minLength: 4) + appUpdateBannerActions(update, presentation: presentation) + } + + VStack(alignment: .leading, spacing: 8) { + appUpdateBannerTitle(presentation) + appUpdateBannerActions(update, presentation: presentation) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + palette.panelBackground.color.opacity(0.97), + in: RoundedRectangle(cornerRadius: 9) + ) + .overlay( + RoundedRectangle(cornerRadius: 9) + .stroke(palette.accent.color.opacity(0.45), lineWidth: 0.8) + ) + .accessibilityElement(children: .contain) + } + + private func appUpdateBannerTitle( + _ presentation: AppUpdateBannerPresentation + ) -> some View { + HStack(spacing: 7) { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(palette.accent.color) + .accessibilityHidden(true) + Text(presentation.title) + .fontWeight(.medium) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func appUpdateBannerActions( + _ update: AvailableAppUpdate, + presentation: AppUpdateBannerPresentation + ) -> some View { + HStack(spacing: 10) { + Button(presentation.viewTitle) { + updateCoordinator.dismissBanner() + NSWorkspace.shared.open(update.url) + } + .buttonStyle(.link) + .fixedSize(horizontal: true, vertical: false) + .accessibilityHint(Text(presentation.viewAccessibilityHint)) + + Button(presentation.laterTitle) { + updateCoordinator.dismissBanner() + } + .buttonStyle(.link) + .fixedSize(horizontal: true, vertical: false) + } + } + private var scopeSelector: some View { let usesIndependentCards = activeTheme.usesIndependentScopeCards @@ -691,10 +765,22 @@ struct ContentView: View { } private var settingsButton: some View { - let presentation = BottomBarActionPresentation.settings + let availableVersion = updateCoordinator.availableUpdate?.version + let presentation = BottomBarActionPresentation.settings( + updateVersion: availableVersion + ) return SettingsLink { - BottomBarActionLabel(presentation: presentation) + ZStack(alignment: .topTrailing) { + BottomBarActionLabel(presentation: presentation) + if availableVersion != nil { + Circle() + .fill(palette.accent.color) + .frame(width: 6, height: 6) + .padding(4) + .accessibilityHidden(true) + } + } } .buttonStyle(.borderless) .foregroundStyle(palette.secondaryText.color) @@ -793,14 +879,15 @@ struct ContentView: View { ) .help(presentation.helpText) .accessibilityLabel(Text(presentation.accessibilityLabel)) - .focusable(false) .popover( isPresented: $isShortcutReferencePresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom ) { ShortcutReferencePanel( - presentation: .standard, + presentation: .standard( + globalHotKeyDisplayName: globalHotKeyDisplayName + ), palette: palette, languageRevision: languageRevision ) @@ -1374,7 +1461,19 @@ struct BottomBarActionPresentation: Equatable { } static var settings: BottomBarActionPresentation { - let label = L10n.text(.bottomBarSettingsHelp) + settings(updateVersion: nil) + } + + static func settings(updateVersion: String?) -> BottomBarActionPresentation { + let label: String + if let updateVersion { + label = L10n.text( + .bottomBarSettingsUpdateAvailableHelp, + replacements: ["version": updateVersion] + ) + } else { + label = L10n.text(.bottomBarSettingsHelp) + } return BottomBarActionPresentation( systemImage: "gearshape", helpText: label, @@ -1383,8 +1482,26 @@ struct BottomBarActionPresentation: Equatable { } } +struct AppUpdateBannerPresentation: Equatable { + let title: String + let viewTitle: String + let laterTitle: String + let viewAccessibilityHint: String + + init(_ update: AvailableAppUpdate) { + title = L10n.text( + .appUpdateBannerAvailable, + replacements: ["version": update.version] + ) + viewTitle = L10n.text(.settingsAboutViewUpdate) + laterTitle = L10n.text(.appUpdateBannerLater) + viewAccessibilityHint = L10n.text(.settingsAboutViewUpdateAccessibilityHint) + } +} + struct ShortcutReferenceItem: Equatable, Identifiable { enum ID: String { + case toggleCodexNotes case cycleTodo case taskNote case projectNote @@ -1400,6 +1517,7 @@ struct ShortcutReferenceItem: Equatable, Identifiable { struct ShortcutReferenceSection: Equatable, Identifiable { let title: String let items: [ShortcutReferenceItem] + var note: String? = nil var id: String { title } } @@ -1415,9 +1533,32 @@ struct ShortcutReferencePresentation: Equatable { var isInteractive: Bool { false } static var standard: ShortcutReferencePresentation { - ShortcutReferencePresentation( + standard(globalHotKeyDisplayName: nil) + } + + static func standard( + globalHotKeyDisplayName: String? + ) -> ShortcutReferencePresentation { + let normalizedGlobalHotKeyDisplayName = globalHotKeyDisplayName? + .trimmingCharacters(in: .whitespacesAndNewlines) + let displayedGlobalHotKey = normalizedGlobalHotKeyDisplayName.flatMap { + $0.isEmpty ? nil : $0 + } ?? L10n.text(.globalHotKeyNotSet) + + return ShortcutReferencePresentation( title: L10n.text(.shortcutsTitle), sections: [ + ShortcutReferenceSection( + title: L10n.text(.shortcutsSectionGlobal), + items: [ + ShortcutReferenceItem( + id: .toggleCodexNotes, + title: L10n.text(.shortcutsItemToggleCodexNotes), + shortcut: displayedGlobalHotKey + ) + ], + note: L10n.text(.shortcutsGlobalAvailabilityNote) + ), ShortcutReferenceSection( title: L10n.text(.shortcutsSectionTodos), items: [ @@ -1621,6 +1762,14 @@ struct ShortcutReferencePanel: View { ) ) } + + if let note = section.note { + Text(note) + .font(.system(size: 10)) + .foregroundStyle(palette.tertiaryText.color) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } } Text(L10n.text(.shortcutsFormattingRequiresSelection)) @@ -1969,10 +2118,12 @@ struct WindowConfigurator: NSViewRepresentable { private func toggleFromStatusItem() { guard let window else { return } + let settingsVisible = visibleSettingsWindow != nil let action = MainWindowTogglePolicy.action( isApplicationHidden: NSApp.isHidden, isWindowVisible: window.isVisible, - isWindowMiniaturized: window.isMiniaturized + isWindowMiniaturized: window.isMiniaturized, + isSettingsVisible: settingsVisible ) switch action { case .show: @@ -2132,13 +2283,17 @@ struct WindowConfigurator: NSViewRepresentable { } private func closeSettingsWindowIfNeeded() { - guard isSettingsVisible else { return } - NSApp.windows - .first(where: { $0.identifier == CodexNotesWindowIdentifier.settings })? - .performClose(nil) + visibleSettingsWindow?.performClose(nil) isSettingsVisible = false } + private var visibleSettingsWindow: NSWindow? { + NSApp.windows.first { + $0.identifier == CodexNotesWindowIdentifier.settings + && $0.isVisible + } + } + private func settingsVisibilityDidChange(_ isVisible: Bool) { isSettingsVisible = isVisible if isVisible { @@ -2175,6 +2330,7 @@ struct WindowConfigurator: NSViewRepresentable { private func updateVisibility(frontmostApplication: NSRunningApplication?) { guard let window else { return } + isSettingsVisible = visibleSettingsWindow != nil let frontmostBundleIdentifier = frontmostApplication?.bundleIdentifier let companionBundleIdentifier = Bundle.main.bundleIdentifier let automaticVisibilityAllowed = CompanionVisibilityPolicy.shouldShow( @@ -2271,35 +2427,67 @@ struct WindowConfigurator: NSViewRepresentable { } private func placeBesideCodex(window: NSWindow) { - guard let info = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) - as? [[String: Any]], - let codexWindow = info.first(where: { item in - guard let owner = item[kCGWindowOwnerName as String] as? String, - let layer = item[kCGWindowLayer as String] as? Int - else { return false } - return layer == 0 && (owner == "ChatGPT" || owner == "Codex") - }), - let boundsDictionary = codexWindow[kCGWindowBounds as String] as? NSDictionary, - let bounds = CGRect(dictionaryRepresentation: boundsDictionary) - else { return } - - let screenFrame = NSScreen.screens - .first(where: { $0.frame.intersects(bounds) })? - .visibleFrame ?? NSScreen.main?.visibleFrame - guard let screenFrame else { return } - - let gap: CGFloat = 8 - let rightX = bounds.maxX + gap - let leftX = bounds.minX - window.frame.width - gap - let x: CGFloat - if rightX + window.frame.width <= screenFrame.maxX { - x = rightX - } else if leftX >= screenFrame.minX { - x = leftX - } else { - x = max(screenFrame.minX, bounds.maxX - window.frame.width - gap) + let displays = NSScreen.screens.compactMap(displayGeometry(for:)) + let quartzBounds = frontmostCodexWindowBounds() + let codexDisplay = quartzBounds.flatMap { + MainWindowInitialPlacementPolicy.display( + containingQuartzBounds: $0, + among: displays + ) } - let y = min(max(bounds.maxY - window.frame.height, screenFrame.minY), screenFrame.maxY - window.frame.height) - window.setFrameOrigin(NSPoint(x: x, y: y)) + let targetDisplay = codexDisplay + ?? window.screen.flatMap(displayGeometry(for:)) + ?? NSScreen.main.flatMap(displayGeometry(for:)) + ?? displays.first + guard let targetDisplay else { return } + + let codexFrame: NSRect? = quartzBounds.flatMap { bounds -> NSRect? in + guard codexDisplay == targetDisplay else { return nil } + return MainWindowInitialPlacementPolicy.appKitFrame( + forQuartzBounds: bounds, + on: targetDisplay + ) + } + window.setFrame( + MainWindowInitialPlacementPolicy.initialFrame( + in: targetDisplay.visibleFrame, + codexFrame: codexFrame + ), + display: false + ) + } + + private func frontmostCodexWindowBounds() -> CGRect? { + let codexProcessIdentifiers = Set( + NSRunningApplication.runningApplications( + withBundleIdentifier: CompanionVisibilityPolicy.codexBundleIdentifier + ).map(\.processIdentifier) + ) + guard !codexProcessIdentifiers.isEmpty else { return nil } + guard let info = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { return nil } + return info.lazy.compactMap { item -> CGRect? in + guard let ownerPID = item[kCGWindowOwnerPID as String] as? NSNumber, + codexProcessIdentifiers.contains(ownerPID.int32Value), + let layer = item[kCGWindowLayer as String] as? Int, + layer == 0, + let dictionary = item[kCGWindowBounds as String] as? NSDictionary + else { return nil } + return CGRect(dictionaryRepresentation: dictionary) + }.first + } + + private func displayGeometry(for screen: NSScreen) -> MainWindowDisplayGeometry? { + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + guard let screenNumber = screen.deviceDescription[screenNumberKey] + as? NSNumber else { return nil } + let displayID = CGDirectDisplayID(screenNumber.uint32Value) + return MainWindowDisplayGeometry( + appKitFrame: screen.frame, + visibleFrame: screen.visibleFrame, + quartzFrame: CGDisplayBounds(displayID) + ) } } diff --git a/Sources/CodexNotesProbe/GlobalHotKeyController.swift b/Sources/CodexNotesProbe/GlobalHotKeyController.swift new file mode 100644 index 0000000..2b075b7 --- /dev/null +++ b/Sources/CodexNotesProbe/GlobalHotKeyController.swift @@ -0,0 +1,1028 @@ +import AppKit +import Carbon.HIToolbox +import CodexNotesCore +import Combine +import Foundation + +struct GlobalHotKeyModifiers: OptionSet, Codable, Hashable, Sendable { + let rawValue: UInt32 + + static let command = GlobalHotKeyModifiers(rawValue: 1 << 0) + static let control = GlobalHotKeyModifiers(rawValue: 1 << 1) + static let option = GlobalHotKeyModifiers(rawValue: 1 << 2) + static let shift = GlobalHotKeyModifiers(rawValue: 1 << 3) + + static let supported: GlobalHotKeyModifiers = [ + .command, + .control, + .option, + .shift, + ] + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(nseventFlags: NSEvent.ModifierFlags) { + var result: GlobalHotKeyModifiers = [] + if nseventFlags.contains(.command) { result.insert(.command) } + if nseventFlags.contains(.control) { result.insert(.control) } + if nseventFlags.contains(.option) { result.insert(.option) } + if nseventFlags.contains(.shift) { result.insert(.shift) } + self = result + } + + var carbonFlags: UInt32 { + var result: UInt32 = 0 + if contains(.command) { result |= UInt32(cmdKey) } + if contains(.control) { result |= UInt32(controlKey) } + if contains(.option) { result |= UInt32(optionKey) } + if contains(.shift) { result |= UInt32(shiftKey) } + return result + } + + var displayName: String { + var result = "" + if contains(.control) { result += "⌃" } + if contains(.option) { result += "⌥" } + if contains(.shift) { result += "⇧" } + if contains(.command) { result += "⌘" } + return result + } + + var count: Int { + rawValue.nonzeroBitCount + } +} + +enum GlobalHotKeyValidationIssue: Equatable, Sendable { + case noModifier + case shiftOnly + case insufficientModifiers + case unsupportedKey +} + +struct GlobalHotKeyShortcut: Codable, Equatable, Hashable, Sendable { + let keyCode: UInt32 + let modifiers: GlobalHotKeyModifiers + + static let defaultValue = GlobalHotKeyShortcut( + keyCode: UInt32(kVK_Space), + modifiers: [.control, .shift] + ) + + var validationIssue: GlobalHotKeyValidationIssue? { + guard modifiers.subtracting(.supported).isEmpty else { + return .unsupportedKey + } + guard Self.keyLabels[keyCode] != nil else { return .unsupportedKey } + guard !modifiers.isEmpty else { return .noModifier } + guard modifiers != [.shift] else { return .shiftOnly } + if modifiers.count < 2 { + return .insufficientModifiers + } + return nil + } + + var displayName: String { + modifiers.displayName + (Self.keyLabels[keyCode] ?? "?") + } + + private static let keyLabels: [UInt32: String] = [ + UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", + UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D", + UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", + UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", + UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J", + UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L", + UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", + UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P", + UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", + UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", + UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V", + UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X", + UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z", + UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1", + UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3", + UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", + UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7", + UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9", + UInt32(kVK_ANSI_Equal): "=", UInt32(kVK_ANSI_Minus): "-", + UInt32(kVK_ANSI_RightBracket): "]", UInt32(kVK_ANSI_LeftBracket): "[", + UInt32(kVK_ANSI_Quote): "'", UInt32(kVK_ANSI_Semicolon): ";", + UInt32(kVK_ANSI_Backslash): "\\", UInt32(kVK_ANSI_Comma): ",", + UInt32(kVK_ANSI_Slash): "/", UInt32(kVK_ANSI_Period): ".", + UInt32(kVK_ANSI_Grave): "`", UInt32(kVK_Space): "Space", + UInt32(kVK_Return): "↩", UInt32(kVK_Tab): "⇥", + UInt32(kVK_Home): "↖", UInt32(kVK_End): "↘", + UInt32(kVK_PageUp): "⇞", UInt32(kVK_PageDown): "⇟", + UInt32(kVK_LeftArrow): "←", UInt32(kVK_RightArrow): "→", + UInt32(kVK_DownArrow): "↓", UInt32(kVK_UpArrow): "↑", + UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", + UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4", + UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", + UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8", + UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", + UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12", + UInt32(kVK_F13): "F13", UInt32(kVK_F14): "F14", + UInt32(kVK_F15): "F15", UInt32(kVK_F16): "F16", + UInt32(kVK_F17): "F17", UInt32(kVK_F18): "F18", + UInt32(kVK_F19): "F19", UInt32(kVK_F20): "F20", + ] +} + +enum GlobalHotKeyRegistrationActivity: Equatable, Sendable { + case stopped + case codexUnavailable + case disabled + case suspendedForRecording + case registered +} + +enum GlobalHotKeyRegistrationIssue: Equatable, Sendable { + case invalidShortcut(GlobalHotKeyValidationIssue) + case conflict(attempted: GlobalHotKeyShortcut) + case registrationFailed(attempted: GlobalHotKeyShortcut, status: Int32) + case backendFailed(status: Int32) +} + +struct GlobalHotKeyRegistrationState: Equatable, Sendable { + let activity: GlobalHotKeyRegistrationActivity + let issue: GlobalHotKeyRegistrationIssue? +} + +enum GlobalHotKeyUpdateResult: Equatable, Sendable { + case updated + case rejected(GlobalHotKeyValidationIssue) + case conflict(GlobalHotKeyShortcut) + case failed(shortcut: GlobalHotKeyShortcut, status: Int32) +} + +enum GlobalHotKeySystemEvent: Equatable { + case pressed + case released +} + +struct GlobalHotKeyUnregisterResult: Equatable { + let activeRegistrationReleased: Bool + let status: Int32 +} + +enum CarbonGlobalHotKeyRegistrationPolicy { + static let options = OptionBits(0) +} + +@MainActor +protocol GlobalHotKeyRegistering: AnyObject { + var eventHandler: ((GlobalHotKeySystemEvent) -> Void)? { get set } + func register(_ shortcut: GlobalHotKeyShortcut) -> Int32 + @discardableResult func unregister() -> GlobalHotKeyUnregisterResult + func invalidate() +} + +@MainActor +protocol CodexApplicationAvailabilityObserving: AnyObject { + var isCodexAvailable: Bool { get } + func start(onChange: @escaping (Bool) -> Void) + func stop() +} + +enum CodexApplicationAvailabilityPolicy { + static func isAvailable( + bundleIdentifier: String?, + isTerminated: Bool, + isHidden: Bool + ) -> Bool { + bundleIdentifier == CompanionVisibilityPolicy.codexBundleIdentifier + && !isTerminated + && !isHidden + } + + static func shouldRefresh(for bundleIdentifier: String?) -> Bool { + bundleIdentifier == CompanionVisibilityPolicy.codexBundleIdentifier + } + +} + +@MainActor +final class CodexApplicationAvailabilityMonitor: + CodexApplicationAvailabilityObserving +{ + private let workspace: NSWorkspace + private var observerTokens: [NSObjectProtocol] = [] + private var onChange: ((Bool) -> Void)? + + init(workspace: NSWorkspace = .shared) { + self.workspace = workspace + } + + var isCodexAvailable: Bool { + NSRunningApplication.runningApplications( + withBundleIdentifier: CompanionVisibilityPolicy.codexBundleIdentifier + ).contains { + CodexApplicationAvailabilityPolicy.isAvailable( + bundleIdentifier: $0.bundleIdentifier, + isTerminated: $0.isTerminated, + isHidden: $0.isHidden + ) + } + } + + func start(onChange: @escaping (Bool) -> Void) { + self.onChange = onChange + guard observerTokens.isEmpty else { return } + let names: [Notification.Name] = [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + ] + observerTokens = names.map { name in + workspace.notificationCenter.addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + self?.handle(notification) + } + } + } + } + + func stop() { + observerTokens.forEach(workspace.notificationCenter.removeObserver) + observerTokens.removeAll() + onChange = nil + } + + private func handle(_ notification: Notification) { + guard let application = notification.userInfo?[ + NSWorkspace.applicationUserInfoKey + ] as? NSRunningApplication, + CodexApplicationAvailabilityPolicy.shouldRefresh( + for: application.bundleIdentifier + ) else { return } + onChange?(isCodexAvailable) + } + + deinit { + MainActor.assumeIsolated { + stop() + } + } +} + +@MainActor +final class CarbonGlobalHotKeyBackend: GlobalHotKeyRegistering { + typealias CleanupScheduledAction = @MainActor () -> Void + typealias CleanupCancellation = @MainActor () -> Void + typealias CleanupScheduler = @MainActor ( + _ delay: TimeInterval, + _ action: @escaping CleanupScheduledAction + ) -> CleanupCancellation + typealias RegistrationOperation = @MainActor ( + _ shortcut: GlobalHotKeyShortcut, + _ identifier: EventHotKeyID + ) -> (status: Int32, reference: EventHotKeyRef?) + typealias UnregistrationOperation = @MainActor ( + _ reference: EventHotKeyRef + ) -> Int32 + + nonisolated static let defaultCleanupRetryDelays: [TimeInterval] = [1, 5, 30] + + var eventHandler: ((GlobalHotKeySystemEvent) -> Void)? + + private var handlerRef: EventHandlerRef? + private var hotKeyRef: EventHotKeyRef? + private var activeID: EventHotKeyID? + private var pendingCleanupRefs: [EventHotKeyRef] = [] + private var nextID: UInt32 = 1 + private let cleanupRetryDelays: [TimeInterval] + private let cleanupScheduler: CleanupScheduler + private let registrationOperation: RegistrationOperation? + private let unregistrationOperation: UnregistrationOperation + private var cleanupRetryIndex = 0 + private var cleanupRetryCancellation: CleanupCancellation? + private var cleanupRetryGeneration = 0 + private var isInvalidated = false + + init( + cleanupRetryDelays: [TimeInterval] = CarbonGlobalHotKeyBackend + .defaultCleanupRetryDelays, + cleanupScheduler: @escaping CleanupScheduler = CarbonGlobalHotKeyBackend + .mainQueueCleanupScheduler, + registrationOperation: RegistrationOperation? = nil, + unregistrationOperation: @escaping UnregistrationOperation = { + UnregisterEventHotKey($0) + } + ) { + self.cleanupRetryDelays = cleanupRetryDelays.map { max(0, $0) } + self.cleanupScheduler = cleanupScheduler + self.registrationOperation = registrationOperation + self.unregistrationOperation = unregistrationOperation + } + + func register(_ shortcut: GlobalHotKeyShortcut) -> Int32 { + guard !isInvalidated else { return Int32(eventNotHandledErr) } + let pendingCleanupStatus = cleanupPendingRefs() + guard pendingCleanupStatus == noErr else { + schedulePendingCleanupRetryIfNeeded() + return pendingCleanupStatus + } + if registrationOperation == nil { + let handlerStatus = installHandlerIfNeeded() + guard handlerStatus == noErr else { return handlerStatus } + } + + let newID = EventHotKeyID(signature: Self.signature, id: nextID) + nextID &+= 1 + if nextID == 0 { nextID = 1 } + let registration = performRegistration( + shortcut: shortcut, + identifier: newID + ) + let status = registration.status + let newRef = registration.reference + guard status == noErr, let newRef else { + return status == noErr ? Int32(paramErr) : status + } + + if let oldRef = hotKeyRef { + let cleanupStatus = unregistrationOperation(oldRef) + guard cleanupStatus == noErr else { + let rollbackStatus = unregistrationOperation(newRef) + if rollbackStatus != noErr { + enqueuePendingCleanup(newRef) + } + return cleanupStatus + } + } + hotKeyRef = newRef + activeID = newID + return noErr + } + + @discardableResult + func unregister() -> GlobalHotKeyUnregisterResult { + var firstFailure = Int32(noErr) + if let hotKeyRef { + let status = unregistrationOperation(hotKeyRef) + if status == noErr { + self.hotKeyRef = nil + } else { + firstFailure = status + } + } + let pendingCleanupStatus = cleanupPendingRefs() + if firstFailure == noErr, pendingCleanupStatus != noErr { + firstFailure = pendingCleanupStatus + } + if !pendingCleanupRefs.isEmpty { + schedulePendingCleanupRetryIfNeeded() + } + if hotKeyRef == nil, pendingCleanupRefs.isEmpty { + activeID = nil + } + return GlobalHotKeyUnregisterResult( + activeRegistrationReleased: hotKeyRef == nil, + status: firstFailure + ) + } + + func invalidate() { + guard !isInvalidated else { return } + isInvalidated = true + cancelPendingCleanupRetry(resetIndex: false) + _ = unregister() + cancelPendingCleanupRetry(resetIndex: false) + if let handlerRef { + _ = RemoveEventHandler(handlerRef) + } + handlerRef = nil + eventHandler = nil + } + + private func performRegistration( + shortcut: GlobalHotKeyShortcut, + identifier: EventHotKeyID + ) -> (status: Int32, reference: EventHotKeyRef?) { + if let registrationOperation { + return registrationOperation(shortcut, identifier) + } + var reference: EventHotKeyRef? + let status = RegisterEventHotKey( + shortcut.keyCode, + shortcut.modifiers.carbonFlags, + identifier, + GetApplicationEventTarget(), + CarbonGlobalHotKeyRegistrationPolicy.options, + &reference + ) + return (status, reference) + } + + private func installHandlerIfNeeded() -> Int32 { + guard handlerRef == nil else { return noErr } + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ), + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyReleased) + ), + ] + return eventTypes.withUnsafeMutableBufferPointer { buffer in + InstallEventHandler( + GetApplicationEventTarget(), + Self.carbonEventHandler, + buffer.count, + buffer.baseAddress, + Unmanaged.passUnretained(self).toOpaque(), + &handlerRef + ) + } + } + + private func cleanupPendingRefs() -> Int32 { + guard !pendingCleanupRefs.isEmpty else { return noErr } + var firstFailure = Int32(noErr) + var remainingRefs: [EventHotKeyRef] = [] + for reference in pendingCleanupRefs { + let status = unregistrationOperation(reference) + if status != noErr { + if firstFailure == noErr { + firstFailure = status + } + remainingRefs.append(reference) + } + } + pendingCleanupRefs = remainingRefs + if pendingCleanupRefs.isEmpty { + cancelPendingCleanupRetry(resetIndex: true) + } + if hotKeyRef == nil, pendingCleanupRefs.isEmpty { + activeID = nil + } + return firstFailure + } + + private func enqueuePendingCleanup(_ reference: EventHotKeyRef) { + guard !pendingCleanupRefs.contains(reference) else { return } + pendingCleanupRefs.append(reference) + cleanupRetryIndex = 0 + schedulePendingCleanupRetryIfNeeded() + } + + private func schedulePendingCleanupRetryIfNeeded() { + guard !isInvalidated, + !pendingCleanupRefs.isEmpty, + cleanupRetryCancellation == nil, + cleanupRetryIndex < cleanupRetryDelays.count else { return } + let delay = cleanupRetryDelays[cleanupRetryIndex] + cleanupRetryIndex += 1 + cleanupRetryGeneration &+= 1 + let generation = cleanupRetryGeneration + cleanupRetryCancellation = cleanupScheduler(delay) { [weak self] in + guard let self, + !self.isInvalidated, + generation == self.cleanupRetryGeneration else { return } + self.cleanupRetryCancellation = nil + _ = self.cleanupPendingRefs() + self.schedulePendingCleanupRetryIfNeeded() + } + } + + private func cancelPendingCleanupRetry(resetIndex: Bool) { + cleanupRetryGeneration &+= 1 + cleanupRetryCancellation?() + cleanupRetryCancellation = nil + if resetIndex { + cleanupRetryIndex = 0 + } + } + + private static func mainQueueCleanupScheduler( + delay: TimeInterval, + action: @escaping CleanupScheduledAction + ) -> CleanupCancellation { + let workItem = DispatchWorkItem { + MainActor.assumeIsolated { + action() + } + } + DispatchQueue.main.asyncAfter( + deadline: .now() + max(0, delay), + execute: workItem + ) + return { + workItem.cancel() + } + } + + private func handle(_ event: EventRef?) -> OSStatus { + guard let event, let activeID else { return OSStatus(eventNotHandledErr) } + var receivedID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &receivedID + ) + guard status == noErr, + receivedID.signature == activeID.signature, + receivedID.id == activeID.id else { + return OSStatus(eventNotHandledErr) + } + + switch GetEventKind(event) { + case UInt32(kEventHotKeyPressed): + eventHandler?(.pressed) + case UInt32(kEventHotKeyReleased): + eventHandler?(.released) + default: + return OSStatus(eventNotHandledErr) + } + return noErr + } + + private static let signature: OSType = 0x434E484B // CNHK + private static let carbonEventHandler: EventHandlerUPP = { + _, event, userData in + guard let userData else { return OSStatus(eventNotHandledErr) } + return MainActor.assumeIsolated { + let backend = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + return backend.handle(event) + } + } + + deinit { + MainActor.assumeIsolated { + invalidate() + } + } +} + +@MainActor +final class GlobalHotKeyController: ObservableObject { + static let preferenceKey = "globalHotKeyPreference" + + @Published private(set) var currentShortcut: GlobalHotKeyShortcut? + @Published private(set) var registrationState = GlobalHotKeyRegistrationState( + activity: .stopped, + issue: nil + ) + @Published private(set) var isRecording = false + + var displayName: String { + currentShortcut?.displayName ?? "" + } + + var isDefault: Bool { + currentShortcut == .defaultValue + } + + private struct StoredPreference: Codable { + let shortcut: GlobalHotKeyShortcut? + } + + private let defaults: UserDefaults + private let backend: GlobalHotKeyRegistering + private let availabilityMonitor: CodexApplicationAvailabilityObserving + private let toggleAction: () -> Void + private let retryDelays: [TimeInterval] + private var isStarted = false + private var isCodexAvailable = false + private var registeredShortcut: GlobalHotKeyShortcut? + private var backendCleanupPending = false + private var keyIsPressed = false + private var reconciliationRetryWorkItem: DispatchWorkItem? + private var reconciliationRetryIndex = 0 + private var reconciliationRetryGeneration: UInt = 0 + + init( + defaults: UserDefaults = .standard, + backend: GlobalHotKeyRegistering? = nil, + availabilityMonitor: CodexApplicationAvailabilityObserving? = nil, + retryDelays: [TimeInterval] = [1, 5, 30], + toggleAction: @escaping () -> Void = { + NotificationCenter.default.post( + name: MainWindowCommandNotification.toggle, + object: nil + ) + } + ) { + self.defaults = defaults + self.backend = backend ?? CarbonGlobalHotKeyBackend() + self.availabilityMonitor = availabilityMonitor + ?? CodexApplicationAvailabilityMonitor() + self.retryDelays = retryDelays.map { max(0, $0) } + self.toggleAction = toggleAction + currentShortcut = Self.loadPreference(from: defaults) + self.backend.eventHandler = { [weak self] event in + self?.handle(event) + } + } + + func start() { + guard !isStarted else { return } + cancelReconciliationRetry() + isStarted = true + availabilityMonitor.start { [weak self] isAvailable in + self?.availabilityDidChange(isAvailable) + } + isCodexAvailable = availabilityMonitor.isCodexAvailable + reconcileRegistration(clearingIssue: true) + } + + func stop() { + guard isStarted + || registrationState.activity != .stopped + || registeredShortcut != nil else { return } + cancelReconciliationRetry() + if isStarted { + availabilityMonitor.stop() + } + isStarted = false + isCodexAvailable = false + isRecording = false + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .stopped, + issue: unregisterStatus == noErr + ? nil + : .backendFailed(status: unregisterStatus) + ) + } + + func beginRecording() { + guard !isRecording else { return } + cancelReconciliationRetry() + isRecording = true + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .suspendedForRecording, + issue: unregisterStatus == noErr + ? nil + : .backendFailed(status: unregisterStatus) + ) + } + + func cancelRecording() { + guard isRecording else { return } + cancelReconciliationRetry() + isRecording = false + reconcileRegistration(clearingIssue: true) + } + + @discardableResult + func set(_ shortcut: GlobalHotKeyShortcut) -> GlobalHotKeyUpdateResult { + if isRecording { + return commitRecordedShortcut(shortcut) + } + cancelReconciliationRetry() + return apply(shortcut, restorePreviousOnFailure: false) + } + + @discardableResult + func commitRecordedShortcut( + _ shortcut: GlobalHotKeyShortcut + ) -> GlobalHotKeyUpdateResult { + cancelReconciliationRetry() + guard let issue = shortcut.validationIssue else { + let restorePreviousOnFailure = registeredShortcut == nil + isRecording = false + return apply( + shortcut, + restorePreviousOnFailure: restorePreviousOnFailure + ) + } + registrationState = GlobalHotKeyRegistrationState( + activity: .suspendedForRecording, + issue: .invalidShortcut(issue) + ) + return .rejected(issue) + } + + func clear() { + cancelReconciliationRetry() + isRecording = false + let unregisterStatus = unregisterActiveShortcut() + currentShortcut = nil + persistCurrentPreference() + registrationState = GlobalHotKeyRegistrationState( + activity: .disabled, + issue: unregisterStatus == noErr + ? nil + : .backendFailed(status: unregisterStatus) + ) + } + + @discardableResult + func restore() -> GlobalHotKeyUpdateResult { + set(.defaultValue) + } + + private func apply( + _ shortcut: GlobalHotKeyShortcut, + restorePreviousOnFailure: Bool + ) -> GlobalHotKeyUpdateResult { + if let issue = shortcut.validationIssue { + registrationState = GlobalHotKeyRegistrationState( + activity: activityForCurrentRegistration(), + issue: .invalidShortcut(issue) + ) + return .rejected(issue) + } + + let oldShortcut = currentShortcut + if oldShortcut == shortcut { + reconcileRegistration(clearingIssue: true) + return updateResultAfterReconcile(for: shortcut) + } + + guard isStarted, isCodexAvailable else { + currentShortcut = shortcut + persistCurrentPreference() + reconcileRegistration(clearingIssue: true) + return .updated + } + + let status = backend.register(shortcut) + if status == noErr { + backendCleanupPending = false + registeredShortcut = shortcut + keyIsPressed = false + currentShortcut = shortcut + persistCurrentPreference() + registrationState = GlobalHotKeyRegistrationState( + activity: .registered, + issue: nil + ) + return .updated + } + + if restorePreviousOnFailure, + registeredShortcut == nil, + let oldShortcut { + let restoreStatus = backend.register(oldShortcut) + if restoreStatus == noErr { + backendCleanupPending = false + registeredShortcut = oldShortcut + } else { + registeredShortcut = nil + registrationState = GlobalHotKeyRegistrationState( + activity: .disabled, + issue: .backendFailed(status: restoreStatus) + ) + scheduleReconciliationRetryIfNeeded() + return .failed(shortcut: oldShortcut, status: restoreStatus) + } + } + + let activity = activityForCurrentRegistration() + if status == eventHotKeyExistsErr { + registrationState = GlobalHotKeyRegistrationState( + activity: activity, + issue: .conflict(attempted: shortcut) + ) + return .conflict(shortcut) + } + registrationState = GlobalHotKeyRegistrationState( + activity: activity, + issue: .registrationFailed(attempted: shortcut, status: status) + ) + return .failed(shortcut: shortcut, status: status) + } + + private func availabilityDidChange(_ isAvailable: Bool) { + guard isStarted else { return } + cancelReconciliationRetry() + isCodexAvailable = isAvailable + reconcileRegistration(clearingIssue: true) + } + + private func reconcileRegistration(clearingIssue: Bool) { + if !isStarted { + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .stopped, + issue: inactiveIssue( + unregisterStatus: unregisterStatus, + clearingIssue: clearingIssue + ) + ) + return + } + if isRecording { + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .suspendedForRecording, + issue: inactiveIssue( + unregisterStatus: unregisterStatus, + clearingIssue: clearingIssue + ) + ) + return + } + guard let currentShortcut else { + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .disabled, + issue: inactiveIssue( + unregisterStatus: unregisterStatus, + clearingIssue: clearingIssue + ) + ) + return + } + guard isCodexAvailable else { + let unregisterStatus = unregisterActiveShortcut() + registrationState = GlobalHotKeyRegistrationState( + activity: .codexUnavailable, + issue: inactiveIssue( + unregisterStatus: unregisterStatus, + clearingIssue: clearingIssue + ) + ) + return + } + if registeredShortcut == currentShortcut { + cancelReconciliationRetry() + registrationState = GlobalHotKeyRegistrationState( + activity: .registered, + issue: clearingIssue ? nil : registrationState.issue + ) + return + } + + let previouslyRegisteredShortcut = registeredShortcut + let status = backend.register(currentShortcut) + if status == noErr { + cancelReconciliationRetry() + backendCleanupPending = false + registeredShortcut = currentShortcut + keyIsPressed = false + registrationState = GlobalHotKeyRegistrationState( + activity: .registered, + issue: nil + ) + } else if status == eventHotKeyExistsErr { + registeredShortcut = previouslyRegisteredShortcut + registrationState = GlobalHotKeyRegistrationState( + activity: activityForCurrentRegistration(), + issue: .conflict(attempted: currentShortcut) + ) + scheduleReconciliationRetryIfNeeded() + } else { + registeredShortcut = previouslyRegisteredShortcut + registrationState = GlobalHotKeyRegistrationState( + activity: activityForCurrentRegistration(), + issue: .registrationFailed( + attempted: currentShortcut, + status: status + ) + ) + scheduleReconciliationRetryIfNeeded() + } + } + + @discardableResult + private func unregisterActiveShortcut() -> Int32 { + keyIsPressed = false + guard registeredShortcut != nil || backendCleanupPending else { + cancelReconciliationRetry() + return noErr + } + let result = backend.unregister() + if result.activeRegistrationReleased { + registeredShortcut = nil + } + backendCleanupPending = result.status != noErr + && result.activeRegistrationReleased + if result.status == noErr { + backendCleanupPending = false + cancelReconciliationRetry() + } else { + scheduleReconciliationRetryIfNeeded() + } + return result.status + } + + private func inactiveIssue( + unregisterStatus: Int32, + clearingIssue: Bool + ) -> GlobalHotKeyRegistrationIssue? { + if unregisterStatus != noErr { + return .backendFailed(status: unregisterStatus) + } + if case .backendFailed = registrationState.issue { + return nil + } + return clearingIssue ? nil : registrationState.issue + } + + private func updateResultAfterReconcile( + for shortcut: GlobalHotKeyShortcut + ) -> GlobalHotKeyUpdateResult { + switch registrationState.issue { + case nil: + return .updated + case let .invalidShortcut(issue): + return .rejected(issue) + case let .conflict(attempted): + return .conflict(attempted) + case let .registrationFailed(attempted, status): + return .failed(shortcut: attempted, status: status) + case let .backendFailed(status): + return .failed(shortcut: shortcut, status: status) + } + } + + private func scheduleReconciliationRetryIfNeeded() { + guard reconciliationRetryWorkItem == nil, + reconciliationRetryIndex < retryDelays.count else { return } + let delay = retryDelays[reconciliationRetryIndex] + reconciliationRetryIndex += 1 + let generation = reconciliationRetryGeneration + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + guard let self, + self.reconciliationRetryGeneration == generation else { + return + } + self.reconciliationRetryWorkItem = nil + self.reconcileRegistration(clearingIssue: false) + } + } + reconciliationRetryWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + delay, + execute: workItem + ) + } + + private func cancelReconciliationRetry() { + reconciliationRetryWorkItem?.cancel() + reconciliationRetryWorkItem = nil + reconciliationRetryIndex = 0 + reconciliationRetryGeneration &+= 1 + } + + private func activityForCurrentRegistration() -> GlobalHotKeyRegistrationActivity { + registeredShortcut == nil ? .disabled : .registered + } + + private func handle(_ event: GlobalHotKeySystemEvent) { + guard isStarted, + isCodexAvailable, + !isRecording, + registeredShortcut == currentShortcut else { return } + switch event { + case .pressed: + guard !keyIsPressed else { return } + keyIsPressed = true + toggleAction() + case .released: + keyIsPressed = false + } + } + + private func persistCurrentPreference() { + let preference = StoredPreference(shortcut: currentShortcut) + guard let data = try? PropertyListEncoder().encode(preference) else { + return + } + defaults.set(data, forKey: Self.preferenceKey) + } + + private static func loadPreference( + from defaults: UserDefaults + ) -> GlobalHotKeyShortcut? { + guard let data = defaults.data(forKey: preferenceKey) else { + return .defaultValue + } + guard let stored = try? PropertyListDecoder().decode( + StoredPreference.self, + from: data + ) else { + return .defaultValue + } + guard let shortcut = stored.shortcut else { return nil } + return shortcut.validationIssue == nil ? shortcut : .defaultValue + } + + deinit { + MainActor.assumeIsolated { + stop() + backend.invalidate() + } + } +} diff --git a/Sources/CodexNotesProbe/GlobalHotKeyRecorderView.swift b/Sources/CodexNotesProbe/GlobalHotKeyRecorderView.swift new file mode 100644 index 0000000..4ab903c --- /dev/null +++ b/Sources/CodexNotesProbe/GlobalHotKeyRecorderView.swift @@ -0,0 +1,313 @@ +import AppKit +import CodexNotesCore +import SwiftUI + +enum GlobalHotKeyRecorderKeyAction: Equatable { + case ignore + case cancel + case clear + case record(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) +} + +enum GlobalHotKeyRecorderKeyPolicy { + static func action( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags, + isRepeat: Bool + ) -> GlobalHotKeyRecorderKeyAction { + guard !isRepeat else { return .ignore } + switch keyCode { + case 53: + return .cancel + case 51, 117: + return .clear + default: + return .record( + keyCode: keyCode, + modifiers: modifierFlags.intersection([ + .command, + .control, + .option, + .shift, + ]) + ) + } + } +} + +struct GlobalHotKeyRecorderView: View { + let shortcutDisplayName: String? + let isRecording: Bool + let isDefault: Bool + let errorMessage: String? + let palette: NoteThemePalette + let beginRecording: () -> Void + let cancelRecording: () -> Void + let recordShortcut: (_ keyCode: UInt16, _ modifiers: NSEvent.ModifierFlags) -> Void + let clearShortcut: () -> Void + let restoreDefault: () -> Void + @AccessibilityFocusState private var recorderHasAccessibilityFocus: Bool + + private var displayedShortcut: String { + let normalized = shortcutDisplayName? + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.flatMap { $0.isEmpty ? nil : $0 } + ?? L10n.text(.globalHotKeyNotSet) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text(.settingsGlobalHotKeyTitle)) + .font(.subheadline.weight(.medium)) + Text(L10n.text(.settingsGlobalHotKeyDescription)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityHidden(true) + + Spacer(minLength: 20) + + HStack(spacing: 6) { + Button { + if isRecording { + cancelRecording() + } else { + beginRecording() + } + } label: { + Text( + isRecording + ? L10n.text(.settingsGlobalHotKeyRecording) + : displayedShortcut + ) + .monospaced() + .lineLimit(1) + .frame(minWidth: 112) + } + .buttonStyle(.bordered) + .accessibilityLabel( + Text(L10n.text(.settingsGlobalHotKeyAccessibilityLabel)) + ) + .accessibilityValue( + Text( + isRecording + ? L10n.text(.settingsGlobalHotKeyRecording) + : displayedShortcut + ) + ) + .accessibilityHint( + Text(L10n.text(.settingsGlobalHotKeyAccessibilityHint)) + ) + .accessibilityFocused($recorderHasAccessibilityFocus) + + if shortcutDisplayName != nil && !isRecording { + Button(action: clearShortcut) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(palette.secondaryText.color) + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .help(L10n.text(.settingsGlobalHotKeyClear)) + .accessibilityLabel( + Text(L10n.text(.settingsGlobalHotKeyClear)) + ) + } + } + } + + if !isDefault && !isRecording { + HStack { + Spacer() + Button(L10n.text(.settingsGlobalHotKeyRestoreDefault)) { + restoreDefault() + } + .buttonStyle(.link) + .controlSize(.small) + } + } + + if let errorMessage, !errorMessage.isEmpty { + HStack(alignment: .top, spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(palette.warning.color) + .accessibilityHidden(true) + Text(errorMessage) + .font(.caption) + .foregroundStyle(palette.warning.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + + GlobalHotKeyCaptureView( + isActive: isRecording, + recordShortcut: recordShortcut, + cancelRecording: cancelRecording, + clearShortcut: clearShortcut + ) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity) + .onDisappear { + if isRecording { + cancelRecording() + } + } + .onChange(of: isRecording) { _, isRecording in + DispatchQueue.main.async { + recorderHasAccessibilityFocus = true + } + if isRecording { + announceForAccessibility( + L10n.text(.settingsGlobalHotKeyRecording) + ) + } + } + .onChange(of: errorMessage) { _, errorMessage in + guard let errorMessage, !errorMessage.isEmpty else { return } + announceForAccessibility(errorMessage) + } + } + + private func announceForAccessibility(_ message: String) { + NSAccessibility.post( + element: NSApplication.shared, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + ) + } +} + +private struct GlobalHotKeyCaptureView: NSViewRepresentable { + let isActive: Bool + let recordShortcut: (_ keyCode: UInt16, _ modifiers: NSEvent.ModifierFlags) -> Void + let cancelRecording: () -> Void + let clearShortcut: () -> Void + + func makeNSView(context: Context) -> GlobalHotKeyCaptureNSView { + let view = GlobalHotKeyCaptureNSView() + update(view) + return view + } + + func updateNSView(_ nsView: GlobalHotKeyCaptureNSView, context: Context) { + update(nsView) + guard isActive else { + if nsView.window?.firstResponder === nsView { + _ = nsView.window?.makeFirstResponder(nil) + } + return + } + + DispatchQueue.main.async { [weak nsView] in + guard let nsView, nsView.isActive, + nsView.window?.firstResponder !== nsView else { return } + _ = nsView.window?.makeFirstResponder(nsView) + } + } + + static func dismantleNSView( + _ nsView: GlobalHotKeyCaptureNSView, + coordinator: Void + ) { + guard nsView.isActive else { return } + nsView.isActive = false + let cancelRecording = nsView.cancelRecording + DispatchQueue.main.async { + cancelRecording() + } + } + + private func update(_ view: GlobalHotKeyCaptureNSView) { + view.recordShortcut = recordShortcut + view.cancelRecording = cancelRecording + view.clearShortcut = clearShortcut + view.isActive = isActive + } +} + +final class GlobalHotKeyCaptureNSView: NSView { + var isActive = false + var recordShortcut: (UInt16, NSEvent.ModifierFlags) -> Void = { _, _ in } + var cancelRecording: () -> Void = {} + var clearShortcut: () -> Void = {} + private var windowResignObserver: NSObjectProtocol? + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + guard isActive else { return } + handleKeyDown(event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard isActive, event.type == .keyDown else { + return super.performKeyEquivalent(with: event) + } + handleKeyDown(event) + return true + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installWindowResignObserver() + } + + private func handleKeyDown(_ event: NSEvent) { + switch GlobalHotKeyRecorderKeyPolicy.action( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags, + isRepeat: event.isARepeat + ) { + case .ignore: + return + case .cancel: + cancelRecording() + case .clear: + clearShortcut() + case let .record(keyCode, modifiers): + recordShortcut(keyCode, modifiers) + } + } + + private func installWindowResignObserver() { + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + self.windowResignObserver = nil + } + guard let window else { return } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isActive else { return } + self.cancelRecording() + } + } + } + + override func resignFirstResponder() -> Bool { + let didResign = super.resignFirstResponder() + if didResign && isActive { + cancelRecording() + } + return didResign + } + + deinit { + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + } + } +} diff --git a/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift b/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift new file mode 100644 index 0000000..f168ef9 --- /dev/null +++ b/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift @@ -0,0 +1,121 @@ +import AppKit +import CoreGraphics + +struct MainWindowDisplayGeometry: Equatable { + let appKitFrame: NSRect + let visibleFrame: NSRect + let quartzFrame: CGRect +} + +enum MainWindowInitialPlacementPolicy { + static let fallbackSize = NSSize(width: 440, height: 680) + static let widthRatio: CGFloat = 0.29 + static let heightRatio: CGFloat = 0.72 + static let minimumWidth: CGFloat = 400 + static let maximumWidth: CGFloat = 548 + static let minimumHeight: CGFloat = 640 + static let maximumHeight: CGFloat = 725 + static let companionGap: CGFloat = 8 + + static func preferredSize(forVisibleFrameSize visibleSize: CGSize?) -> NSSize { + guard let visibleSize, + visibleSize.width.isFinite, + visibleSize.height.isFinite, + visibleSize.width > 0, + visibleSize.height > 0 else { + return fallbackSize + } + + let proportionalWidth = (visibleSize.width * widthRatio).rounded() + let proportionalHeight = (visibleSize.height * heightRatio).rounded() + let preferredWidth = min( + max(proportionalWidth, minimumWidth), + maximumWidth + ) + let preferredHeight = min( + max(proportionalHeight, minimumHeight), + maximumHeight + ) + + // Real macOS displays are larger than the preferred minima, but cap to + // the available area so unusual scaled or virtual displays never place + // the first window partly off screen. + return NSSize( + width: min(preferredWidth, visibleSize.width), + height: min(preferredHeight, visibleSize.height) + ) + } + + static func initialFrame( + in visibleFrame: NSRect, + codexFrame: NSRect? + ) -> NSRect { + let size = preferredSize(forVisibleFrameSize: visibleFrame.size) + let maximumX = max(visibleFrame.minX, visibleFrame.maxX - size.width) + let maximumY = max(visibleFrame.minY, visibleFrame.maxY - size.height) + + guard let codexFrame else { + return NSRect( + x: ((visibleFrame.midX - size.width / 2).rounded()) + .clamped(to: visibleFrame.minX...maximumX), + y: ((visibleFrame.midY - size.height / 2).rounded()) + .clamped(to: visibleFrame.minY...maximumY), + width: size.width, + height: size.height + ) + } + + let rightX = codexFrame.maxX + companionGap + let leftX = codexFrame.minX - size.width - companionGap + let x: CGFloat + if rightX >= visibleFrame.minX, + rightX + size.width <= visibleFrame.maxX { + x = rightX + } else if leftX >= visibleFrame.minX, + leftX + size.width <= visibleFrame.maxX { + x = leftX + } else { + x = (codexFrame.maxX - size.width - companionGap) + .clamped(to: visibleFrame.minX...maximumX) + } + let y = (codexFrame.maxY - size.height) + .clamped(to: visibleFrame.minY...maximumY) + return NSRect(x: x, y: y, width: size.width, height: size.height) + } + + static func display( + containingQuartzBounds bounds: CGRect, + among displays: [MainWindowDisplayGeometry] + ) -> MainWindowDisplayGeometry? { + displays + .compactMap { display -> (MainWindowDisplayGeometry, CGFloat)? in + let intersection = display.quartzFrame.intersection(bounds) + guard !intersection.isNull, !intersection.isEmpty else { + return nil + } + return (display, intersection.width * intersection.height) + } + .max { lhs, rhs in lhs.1 < rhs.1 }? + .0 + } + + static func appKitFrame( + forQuartzBounds bounds: CGRect, + on display: MainWindowDisplayGeometry + ) -> NSRect { + let localX = bounds.minX - display.quartzFrame.minX + let localTop = bounds.minY - display.quartzFrame.minY + return NSRect( + x: display.appKitFrame.minX + localX, + y: display.appKitFrame.maxY - localTop - bounds.height, + width: bounds.width, + height: bounds.height + ) + } +} + +private extension CGFloat { + func clamped(to range: ClosedRange) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg b/Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4505b3070bde4a0c767c549cc7e3476ae4954716 GIT binary patch literal 102020 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 literal 0 HcmV?d00001 diff --git a/Sources/CodexNotesProbe/SettingsView.swift b/Sources/CodexNotesProbe/SettingsView.swift index 3e23c1d..80f9593 100644 --- a/Sources/CodexNotesProbe/SettingsView.swift +++ b/Sources/CodexNotesProbe/SettingsView.swift @@ -39,8 +39,11 @@ enum SettingsGeneralPresentation { } struct SettingsView: View { + @ObservedObject private var updateCoordinator: UpdateCheckCoordinator + @ObservedObject private var globalHotKeyController: GlobalHotKeyController @Environment(\.colorScheme) private var inheritedColorScheme @StateObject private var loginItemService = LoginItemService() + @State private var globalHotKeyErrorKey: L10n.Key? @AppStorage(AppLanguagePreference.key) private var storedLanguage = AppLanguagePreference.defaultValue.rawValue @AppStorage(EditorFontSizePreference.key) @@ -52,6 +55,16 @@ struct SettingsView: View { @AppStorage(StatusBarIconPreference.key) private var storedStatusBarIconID = StatusBarIconPreference.defaultValue.rawValue + init( + updateCoordinator: UpdateCheckCoordinator, + globalHotKeyController: GlobalHotKeyController + ) { + _updateCoordinator = ObservedObject(wrappedValue: updateCoordinator) + _globalHotKeyController = ObservedObject( + wrappedValue: globalHotKeyController + ) + } + private var activeTheme: NoteThemeID { NoteThemePreference.normalized(storedThemeID) } @@ -145,72 +158,14 @@ struct SettingsView: View { Divider() .overlay(palette.separator.color) - launchAtLoginControls - } - .frame(maxWidth: .infinity) - } - - settingsPanel(title: L10n.text(.settingsStatusBarIconTitle)) { - Text(L10n.text(.settingsStatusBarIconDescription)) - .font(.caption) - .foregroundStyle(palette.secondaryText.color) + globalHotKeyControls - HStack(spacing: 12) { - ForEach(StatusBarIconID.allCases, id: \.self) { icon in - StatusBarIconChoiceCard( - icon: icon, - isSelected: icon == activeStatusBarIcon, - palette: palette, - languageRevision: languageRevision - ) { - storedStatusBarIconID = icon.rawValue - } - } - } - } - - settingsPanel(title: L10n.text(.settingsAppearanceTitle)) { - Text(L10n.text(.settingsAppearanceDescription)) - .font(.caption) - .foregroundStyle(palette.secondaryText.color) - - VStack(spacing: 12) { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 12 - ) { - ForEach( - NoteThemeID.allCases.filter { $0 != .systemOriginal }, - id: \.self - ) { theme in - ThemeChoiceCard( - theme: theme, - isSelected: theme == activeTheme, - languageRevision: languageRevision - ) { - storedThemeID = theme.rawValue - } - } - } - - Text(L10n.text(.settingsAppearanceOriginal)) - .font(.caption.weight(.semibold)) - .foregroundStyle(palette.secondaryText.color) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) + Divider() + .overlay(palette.separator.color) - ThemeChoiceCard( - theme: .systemOriginal, - isSelected: activeTheme == .systemOriginal, - languageRevision: languageRevision - ) { - storedThemeID = NoteThemeID.systemOriginal.rawValue - } + launchAtLoginControls } - .accessibilityLabel(Text(L10n.text(.settingsAppearanceThemesAccessibilityLabel))) + .frame(maxWidth: .infinity) } settingsPanel(title: L10n.text(.settingsEditorTitle)) { @@ -270,23 +225,79 @@ struct SettingsView: View { ) .accessibilityLabel(Text(L10n.text(.settingsEditorPreviewAccessibilityLabel))) - HStack { - Spacer() - Button(L10n.text(.settingsEditorRestoreDefaults)) { - storedEditorFontSize = EditorFontSizePreference.defaultValue - storedEditorLineSpacing = EditorLineSpacingPreference.defaultValue + editorFooter + } + + settingsPanel(title: L10n.text(.settingsAppearanceTitle)) { + Text(L10n.text(.settingsAppearanceDescription)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + + VStack(spacing: 12) { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], + spacing: 12 + ) { + ForEach( + NoteThemeID.allCases.filter { $0 != .systemOriginal }, + id: \.self + ) { theme in + ThemeChoiceCard( + theme: theme, + isSelected: theme == activeTheme, + languageRevision: languageRevision + ) { + storedThemeID = theme.rawValue + } + } + } + + Text(L10n.text(.settingsAppearanceOriginal)) + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.secondaryText.color) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + + ThemeChoiceCard( + theme: .systemOriginal, + isSelected: activeTheme == .systemOriginal, + languageRevision: languageRevision + ) { + storedThemeID = NoteThemeID.systemOriginal.rawValue + } + } + .accessibilityLabel(Text(L10n.text(.settingsAppearanceThemesAccessibilityLabel))) + } + + settingsPanel(title: L10n.text(.settingsStatusBarIconTitle)) { + Text(L10n.text(.settingsStatusBarIconDescription)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + + HStack(spacing: 12) { + ForEach(StatusBarIconID.allCases, id: \.self) { icon in + StatusBarIconChoiceCard( + icon: icon, + isSelected: icon == activeStatusBarIcon, + palette: palette, + languageRevision: languageRevision + ) { + storedStatusBarIconID = icon.rawValue + } } - .disabled( - EditorFontSizePreference.normalized(storedEditorFontSize) - == EditorFontSizePreference.defaultValue - && EditorLineSpacingPreference.normalized( - storedEditorLineSpacing - ) == EditorLineSpacingPreference.defaultValue - ) - .accessibilityLabel(Text(L10n.text(.settingsEditorRestoreDefaultsAccessibilityLabel))) - .accessibilityHint(Text(L10n.text(.settingsEditorRestoreDefaultsAccessibilityHint))) } } + + settingsPanel(title: L10n.text(.settingsAboutTitle)) { + AboutCodexNotesView( + palette: palette, + languageRevision: languageRevision, + updateCoordinator: updateCoordinator + ) + } } .padding(20) } @@ -326,6 +337,139 @@ struct SettingsView: View { } } + @ViewBuilder + private var editorFooter: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + editorWindowResizeHint + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + + Spacer(minLength: 12) + + editorRestoreDefaultsButton + } + + VStack(alignment: .leading, spacing: 8) { + editorWindowResizeHint + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + editorRestoreDefaultsButton + } + } + } + } + + private var editorWindowResizeHint: some View { + HStack(spacing: 6) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .accessibilityHidden(true) + + Text(L10n.text(.settingsEditorWindowResizeHint)) + } + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + .accessibilityElement(children: .combine) + } + + private var editorRestoreDefaultsButton: some View { + Button(L10n.text(.settingsEditorRestoreDefaults)) { + storedEditorFontSize = EditorFontSizePreference.defaultValue + storedEditorLineSpacing = EditorLineSpacingPreference.defaultValue + } + .disabled( + EditorFontSizePreference.normalized(storedEditorFontSize) + == EditorFontSizePreference.defaultValue + && EditorLineSpacingPreference.normalized( + storedEditorLineSpacing + ) == EditorLineSpacingPreference.defaultValue + ) + .accessibilityLabel(Text(L10n.text(.settingsEditorRestoreDefaultsAccessibilityLabel))) + .accessibilityHint(Text(L10n.text(.settingsEditorRestoreDefaultsAccessibilityHint))) + } + + private var globalHotKeyControls: some View { + GlobalHotKeyRecorderView( + shortcutDisplayName: globalHotKeyController.currentShortcut == nil + ? nil + : globalHotKeyController.displayName, + isRecording: globalHotKeyController.isRecording, + isDefault: globalHotKeyController.isDefault, + errorMessage: globalHotKeyErrorMessage, + palette: palette, + beginRecording: { + globalHotKeyErrorKey = nil + globalHotKeyController.beginRecording() + }, + cancelRecording: { + globalHotKeyErrorKey = nil + globalHotKeyController.cancelRecording() + }, + recordShortcut: { keyCode, modifierFlags in + let shortcut = GlobalHotKeyShortcut( + keyCode: UInt32(keyCode), + modifiers: GlobalHotKeyModifiers( + nseventFlags: modifierFlags + ) + ) + guard shortcut.validationIssue == nil else { + globalHotKeyErrorKey = .settingsGlobalHotKeyInvalidShortcut + return + } + + handleGlobalHotKeyUpdateResult( + globalHotKeyController.commitRecordedShortcut(shortcut) + ) + }, + clearShortcut: { + globalHotKeyErrorKey = nil + globalHotKeyController.clear() + }, + restoreDefault: { + globalHotKeyErrorKey = nil + handleGlobalHotKeyUpdateResult(globalHotKeyController.restore()) + } + ) + } + + private var globalHotKeyErrorMessage: String? { + if let globalHotKeyErrorKey { + return L10n.text(globalHotKeyErrorKey) + } + guard let issue = globalHotKeyController.registrationState.issue else { + return nil + } + return L10n.text(globalHotKeyErrorKey(for: issue)) + } + + private func globalHotKeyErrorKey( + for issue: GlobalHotKeyRegistrationIssue + ) -> L10n.Key { + switch issue { + case .invalidShortcut: + return .settingsGlobalHotKeyInvalidShortcut + case .conflict: + return .settingsGlobalHotKeyConflict + case .registrationFailed, .backendFailed: + return .settingsGlobalHotKeyRegistrationFailed + } + } + + private func handleGlobalHotKeyUpdateResult( + _ result: GlobalHotKeyUpdateResult + ) { + switch result { + case .updated: + globalHotKeyErrorKey = nil + case .rejected: + globalHotKeyErrorKey = .settingsGlobalHotKeyInvalidShortcut + case .conflict, .failed: + globalHotKeyErrorKey = nil + } + } + private var launchAtLoginControls: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 12) { @@ -578,7 +722,7 @@ private struct ThemeChoiceCard: View { } } -private struct SettingsWindowActivator: NSViewRepresentable { +struct SettingsWindowActivator: NSViewRepresentable { let appearanceName: NSAppearance.Name? let backgroundColor: NSColor @@ -602,7 +746,13 @@ private struct SettingsWindowActivator: NSViewRepresentable { appearanceName: appearanceName, backgroundColor: backgroundColor ) - guard self.window !== window else { return } + if self.window === window { + if window.isVisible { + isWindowClosing = false + setVisible(true) + } + return + } detach() self.window = window diff --git a/Sources/CodexNotesProbe/StatusItemController.swift b/Sources/CodexNotesProbe/StatusItemController.swift index 5bf8361..952e51d 100644 --- a/Sources/CodexNotesProbe/StatusItemController.swift +++ b/Sources/CodexNotesProbe/StatusItemController.swift @@ -20,8 +20,12 @@ enum MainWindowTogglePolicy { static func action( isApplicationHidden: Bool, isWindowVisible: Bool, - isWindowMiniaturized: Bool + isWindowMiniaturized: Bool, + isSettingsVisible: Bool = false ) -> MainWindowToggleAction { + if isSettingsVisible { + return .show + } if isApplicationHidden || !isWindowVisible || isWindowMiniaturized { return .show } diff --git a/Sources/CodexNotesProbe/UpdateCheckCoordinator.swift b/Sources/CodexNotesProbe/UpdateCheckCoordinator.swift new file mode 100644 index 0000000..56dce46 --- /dev/null +++ b/Sources/CodexNotesProbe/UpdateCheckCoordinator.swift @@ -0,0 +1,548 @@ +import AppKit +import Combine +import Foundation + +enum UpdateCheckPreferenceKey { + static let automaticChecksEnabled = "updateCheck.automaticChecksEnabled" + static let nextEligibleAt = "updateCheck.nextEligibleAt" + static let lastAttemptAt = "updateCheck.lastAttemptAt" + static let lastSuccessAt = "updateCheck.lastSuccessAt" + static let consecutiveFailureCount = "updateCheck.consecutiveFailureCount" + static let availableVersion = "updateCheck.availableVersion" + static let availableReleaseURL = "updateCheck.availableReleaseURL" + static let dismissedBannerVersion = "updateCheck.dismissedBannerVersion" +} + +@MainActor +final class UpdateCheckCoordinator: ObservableObject { + typealias ScheduledAction = @MainActor () -> Void + typealias Cancellation = @MainActor () -> Void + typealias Scheduler = @MainActor ( + _ delay: TimeInterval, + _ action: @escaping ScheduledAction + ) -> Cancellation + typealias Clock = @MainActor () -> Date + typealias MonotonicClock = @MainActor () -> TimeInterval + typealias ResultDeliveryBarrier = @MainActor ( + _ initiatedRequest: Bool + ) async -> Void + + static let successfulCheckInterval: TimeInterval = 24 * 60 * 60 + static let launchDelay: TimeInterval = 10 + static let failureRetryIntervals: [TimeInterval] = [ + 60 * 60, + 3 * 60 * 60, + 6 * 60 * 60, + 24 * 60 * 60 + ] + + @Published private(set) var state: AppUpdateState + @Published private(set) var isAutomaticCheckEnabled: Bool + @Published private(set) var availableUpdate: AvailableAppUpdate? + @Published private(set) var bannerUpdate: AvailableAppUpdate? + + private enum CheckSource: Equatable { + case automatic + case manual + } + + private struct ActiveCheck { + let generation: Int + let source: CheckSource + let task: Task + var hasManualWaiter: Bool + } + + private struct SharedCheckResult { + let state: AppUpdateState? + let initiatedRequest: Bool + } + + private let defaults: UserDefaults + private let checker: AppUpdateChecker + private let installedVersion: String + private let clock: Clock + private let monotonicClock: MonotonicClock + private let scheduler: Scheduler + private let applicationNotificationCenter: NotificationCenter + private let workspaceNotificationCenter: NotificationCenter + private let resultDeliveryBarrier: ResultDeliveryBarrier + + private var isStarted = false + private var scheduledCheckCancellation: Cancellation? + private var applicationActivationObserver: NSObjectProtocol? + private var workspaceWakeObserver: NSObjectProtocol? + private var automaticStartupGraceDeadline: TimeInterval? + private var activeCheck: ActiveCheck? + private var checkGeneration = 0 + + convenience init() { + self.init( + defaults: .standard, + checker: AppUpdateChecker(), + installedVersion: AppBundleVersion.current.version, + clock: Date.init, + monotonicClock: { ProcessInfo.processInfo.systemUptime }, + scheduler: Self.mainQueueScheduler, + applicationNotificationCenter: .default, + workspaceNotificationCenter: NSWorkspace.shared.notificationCenter, + resultDeliveryBarrier: { _ in } + ) + } + + init( + defaults: UserDefaults, + checker: AppUpdateChecker, + installedVersion: String, + clock: @escaping Clock, + monotonicClock: @escaping MonotonicClock = { + ProcessInfo.processInfo.systemUptime + }, + scheduler: @escaping Scheduler, + applicationNotificationCenter: NotificationCenter = .default, + workspaceNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter, + resultDeliveryBarrier: @escaping ResultDeliveryBarrier = { _ in } + ) { + self.defaults = defaults + self.checker = checker + self.installedVersion = installedVersion + self.clock = clock + self.monotonicClock = monotonicClock + self.scheduler = scheduler + self.applicationNotificationCenter = applicationNotificationCenter + self.workspaceNotificationCenter = workspaceNotificationCenter + self.resultDeliveryBarrier = resultDeliveryBarrier + + isAutomaticCheckEnabled = defaults.bool( + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + + let restoredUpdate = Self.restoreAvailableUpdate( + from: defaults, + installedVersion: installedVersion + ) + availableUpdate = restoredUpdate + if let restoredUpdate { + state = .updateAvailable( + version: restoredUpdate.version, + url: restoredUpdate.url + ) + let dismissedVersion = defaults.string( + forKey: UpdateCheckPreferenceKey.dismissedBannerVersion + ) + bannerUpdate = dismissedVersion == restoredUpdate.version + ? nil + : restoredUpdate + } else { + state = .idle + bannerUpdate = nil + } + } + + func start() { + guard !isStarted else { + return + } + + isStarted = true + applicationActivationObserver = applicationNotificationCenter.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.automaticCheckOpportunity() + } + } + workspaceWakeObserver = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.automaticCheckOpportunity() + } + } + + if isAutomaticCheckEnabled { + automaticStartupGraceDeadline = monotonicClock() + Self.launchDelay + scheduleNextAutomaticCheck(minimumDelay: 0) + } + } + + func setAutomaticChecksEnabled(_ isEnabled: Bool) { + guard isAutomaticCheckEnabled != isEnabled else { + return + } + + isAutomaticCheckEnabled = isEnabled + defaults.set( + isEnabled, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + + if isEnabled { + automaticStartupGraceDeadline = nil + if isStarted { + scheduleNextAutomaticCheck(minimumDelay: 0) + } + return + } + + automaticStartupGraceDeadline = nil + cancelScheduledCheck() + if let activeCheck, + activeCheck.source == .automatic, + !activeCheck.hasManualWaiter { + cancelActiveCheck() + } + } + + func manualCheck() async { + let previousState = state + state = .checking + + let result = await performSharedCheck(source: .manual) + guard let resultState = result.state else { + if state == .checking { + state = previousState + } + return + } + + if result.initiatedRequest { + applyCompletedCheck(resultState, source: .manual) + } else { + publishManualResult(resultState) + } + } + + func checkIfDue() async { + guard isStarted, isAutomaticCheckEnabled else { + return + } + + let currentMonotonicTime = monotonicClock() + if let graceDeadline = automaticStartupGraceDeadline, + currentMonotonicTime < graceDeadline { + scheduleNextAutomaticCheck(minimumDelay: 0) + return + } + automaticStartupGraceDeadline = nil + let currentDate = clock() + guard isDue(at: currentDate) else { + if isStarted { + scheduleNextAutomaticCheck(minimumDelay: 0) + } + return + } + + let result = await performSharedCheck(source: .automatic) + guard result.initiatedRequest, let resultState = result.state else { + return + } + applyCompletedCheck(resultState, source: .automatic) + } + + func dismissBanner() { + guard let update = bannerUpdate ?? availableUpdate else { + return + } + + defaults.set( + update.version, + forKey: UpdateCheckPreferenceKey.dismissedBannerVersion + ) + bannerUpdate = nil + } + + func stop() { + isStarted = false + automaticStartupGraceDeadline = nil + cancelScheduledCheck() + cancelActiveCheck() + + if let applicationActivationObserver { + applicationNotificationCenter.removeObserver(applicationActivationObserver) + self.applicationActivationObserver = nil + } + if let workspaceWakeObserver { + workspaceNotificationCenter.removeObserver(workspaceWakeObserver) + self.workspaceWakeObserver = nil + } + } + + private func automaticCheckOpportunity() { + guard isStarted, isAutomaticCheckEnabled else { + return + } + scheduleNextAutomaticCheck(minimumDelay: 0) + } + + private func performSharedCheck(source: CheckSource) async -> SharedCheckResult { + if var activeCheck { + if source == .manual, !activeCheck.hasManualWaiter { + activeCheck.hasManualWaiter = true + self.activeCheck = activeCheck + } + let generation = activeCheck.generation + let result = await activeCheck.task.value + await resultDeliveryBarrier(false) + guard generation == checkGeneration else { + return SharedCheckResult(state: nil, initiatedRequest: false) + } + return SharedCheckResult(state: result, initiatedRequest: false) + } + + checkGeneration &+= 1 + let generation = checkGeneration + defaults.set(clock(), forKey: UpdateCheckPreferenceKey.lastAttemptAt) + + let checker = checker + let task = Task { @MainActor in + await checker.checkForUpdates() + } + activeCheck = ActiveCheck( + generation: generation, + source: source, + task: task, + hasManualWaiter: source == .manual + ) + + let result = await task.value + await resultDeliveryBarrier(true) + guard generation == checkGeneration else { + return SharedCheckResult(state: nil, initiatedRequest: true) + } + clearActiveCheck(ifGenerationMatches: generation) + return SharedCheckResult(state: result, initiatedRequest: true) + } + + private func clearActiveCheck(ifGenerationMatches generation: Int) { + guard activeCheck?.generation == generation else { + return + } + activeCheck = nil + } + + private func cancelActiveCheck() { + guard let activeCheck else { + return + } + + checkGeneration &+= 1 + self.activeCheck = nil + activeCheck.task.cancel() + checker.cancel() + } + + private func applyCompletedCheck(_ result: AppUpdateState, source: CheckSource) { + let completionDate = clock() + + switch result { + case .upToDate: + recordSuccessfulCheck(at: completionDate) + clearAvailableUpdate() + state = .upToDate + case let .updateAvailable(version, url): + guard let update = AppUpdateValidation.availableUpdate( + version: version, + url: url.absoluteString, + installedVersion: installedVersion + ) else { + recordFailedCheck(at: completionDate) + if source == .manual { + publishManualResult(.failed) + } + scheduleAfterCompletionIfNeeded() + return + } + + recordSuccessfulCheck(at: completionDate) + persistAvailableUpdate(update) + state = .updateAvailable(version: update.version, url: update.url) + case .failed: + recordFailedCheck(at: completionDate) + if source == .manual { + publishManualResult(.failed) + } + case .idle, .checking: + return + } + + scheduleAfterCompletionIfNeeded() + } + + private func publishManualResult(_ result: AppUpdateState) { + switch result { + case let .updateAvailable(version, url): + state = .updateAvailable(version: version, url: url) + case .upToDate: + state = .upToDate + case .failed: + state = .failed + case .idle, .checking: + break + } + } + + private func recordSuccessfulCheck(at date: Date) { + defaults.set(date, forKey: UpdateCheckPreferenceKey.lastSuccessAt) + defaults.set(0, forKey: UpdateCheckPreferenceKey.consecutiveFailureCount) + defaults.set( + date.addingTimeInterval(Self.successfulCheckInterval), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + } + + private func recordFailedCheck(at date: Date) { + let previousFailureCount = max( + 0, + defaults.integer(forKey: UpdateCheckPreferenceKey.consecutiveFailureCount) + ) + let failureCount = previousFailureCount == Int.max + ? Int.max + : previousFailureCount + 1 + let intervalIndex = min( + failureCount - 1, + Self.failureRetryIntervals.count - 1 + ) + defaults.set( + failureCount, + forKey: UpdateCheckPreferenceKey.consecutiveFailureCount + ) + defaults.set( + date.addingTimeInterval(Self.failureRetryIntervals[intervalIndex]), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + } + + private func persistAvailableUpdate(_ update: AvailableAppUpdate) { + defaults.set( + update.version, + forKey: UpdateCheckPreferenceKey.availableVersion + ) + defaults.set( + update.url.absoluteString, + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + availableUpdate = update + + let dismissedVersion = defaults.string( + forKey: UpdateCheckPreferenceKey.dismissedBannerVersion + ) + bannerUpdate = dismissedVersion == update.version ? nil : update + } + + private func clearAvailableUpdate() { + Self.clearPersistedAvailableUpdate(in: defaults) + availableUpdate = nil + bannerUpdate = nil + } + + private func scheduleAfterCompletionIfNeeded() { + guard isStarted, isAutomaticCheckEnabled else { + return + } + scheduleNextAutomaticCheck(minimumDelay: 0) + } + + private func isDue(at date: Date) -> Bool { + guard let nextEligibleDate = storedNextEligibleDate(relativeTo: date) else { + return true + } + return nextEligibleDate <= date + } + + private func storedNextEligibleDate(relativeTo date: Date) -> Date? { + guard let storedDate = defaults.object( + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) as? Date else { + return nil + } + + let latestReasonableDate = date.addingTimeInterval(Self.successfulCheckInterval) + if storedDate > latestReasonableDate { + defaults.set( + latestReasonableDate, + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + return latestReasonableDate + } + return storedDate + } + + private func scheduleNextAutomaticCheck(minimumDelay: TimeInterval) { + cancelScheduledCheck() + guard isStarted, isAutomaticCheckEnabled else { + return + } + + let currentDate = clock() + let dueDate = storedNextEligibleDate(relativeTo: currentDate) ?? currentDate + let graceDelay = max( + (automaticStartupGraceDeadline ?? 0) - monotonicClock(), + 0 + ) + let delay = max( + minimumDelay, + max(max(dueDate.timeIntervalSince(currentDate), graceDelay), 0) + ) + scheduledCheckCancellation = scheduler(delay) { [weak self] in + guard let self else { + return + } + self.scheduledCheckCancellation = nil + Task { @MainActor [weak self] in + await self?.checkIfDue() + } + } + } + + private func cancelScheduledCheck() { + scheduledCheckCancellation?() + scheduledCheckCancellation = nil + } + + private static func restoreAvailableUpdate( + from defaults: UserDefaults, + installedVersion: String + ) -> AvailableAppUpdate? { + guard + let version = defaults.string(forKey: UpdateCheckPreferenceKey.availableVersion), + let url = defaults.string(forKey: UpdateCheckPreferenceKey.availableReleaseURL), + let update = AppUpdateValidation.availableUpdate( + version: version, + url: url, + installedVersion: installedVersion + ) + else { + clearPersistedAvailableUpdate(in: defaults) + return nil + } + return update + } + + private static func clearPersistedAvailableUpdate(in defaults: UserDefaults) { + defaults.removeObject(forKey: UpdateCheckPreferenceKey.availableVersion) + defaults.removeObject(forKey: UpdateCheckPreferenceKey.availableReleaseURL) + } + + private static func mainQueueScheduler( + delay: TimeInterval, + action: @escaping ScheduledAction + ) -> Cancellation { + let workItem = DispatchWorkItem { + MainActor.assumeIsolated { + action() + } + } + DispatchQueue.main.asyncAfter( + deadline: .now() + max(0, delay), + execute: workItem + ) + return { + workItem.cancel() + } + } +} diff --git a/Tests/CodexNotesCoreTests/CodexLogMonitorTests.swift b/Tests/CodexNotesCoreTests/CodexLogMonitorTests.swift new file mode 100644 index 0000000..b7c4cf9 --- /dev/null +++ b/Tests/CodexNotesCoreTests/CodexLogMonitorTests.swift @@ -0,0 +1,554 @@ +import Foundation +import XCTest +@testable import CodexNotesCore + +final class CodexLogMonitorTests: XCTestCase { + private static let fixturePID: pid_t = 42_424 + + private final class PIDState: @unchecked Sendable { + var value: pid_t? + + init(_ value: pid_t?) { + self.value = value + } + } + + private var temporaryRoot: URL! + private var logURL: URL! + + override func setUpWithError() throws { + temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent( + "CodexLogMonitorTests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: temporaryRoot, + withIntermediateDirectories: true + ) + logURL = temporaryRoot.appendingPathComponent( + "codex-desktop-fixture-\(Self.fixturePID)-t0-i1-000001-0.log" + ) + } + + override func tearDownWithError() throws { + if let temporaryRoot { + try? FileManager.default.removeItem(at: temporaryRoot) + } + logURL = nil + temporaryRoot = nil + } + + func testBootstrapFindsMarkerBehindMoreThanTwoMegabytesOfNoise() async throws { + let threadID = "11111111-2222-7333-8444-555555555555" + let marker = selectionLine( + timestamp: "2030-01-02T03:04:05.678Z", + threadID: threadID + ) + let noiseLine = String(repeating: "n", count: 8_191) + "\n" + let trailingNoise = String(repeating: noiseLine, count: 257) + XCTAssertGreaterThan(trailingNoise.utf8.count, 2_000_000) + try write(Data((marker + "\n" + trailingNoise).utf8)) + + let selection = try await makeMonitor().bootstrap() + + XCTAssertEqual(selection?.threadID, threadID) + XCTAssertEqual(selection?.timestamp, "2030-01-02T03:04:05.678Z") + } + + func testBootstrapSearchesPastFortyRotatedFiles() async throws { + let threadID = "10101010-2020-7333-8444-505050505050" + for index in 0..<41 { + let file = fixtureLogURL(index: index) + let contents: String + if index == 40 { + contents = selectionLine( + timestamp: "2030-01-02T03:05:06.789Z", + threadID: threadID + ) + "\n" + } else { + contents = "rotated noise \(index)\n" + } + try write(Data(contents.utf8), to: file) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: Double(100 - index))], + ofItemAtPath: file.path + ) + } + + let selection = try await makeMonitor().bootstrap() + + XCTAssertEqual(selection?.threadID, threadID) + } + + func testBootstrapDoesNotFallBackToPreviousLaunchUUIDWithReusedPID() async throws { + let oldLaunchURL = fixtureLogURL(family: "old-launch", index: 1) + try write( + Data( + (selectionLine( + timestamp: "2030-01-02T03:06:07.890Z", + threadID: "stale-reused-pid-thread" + ) + "\n").utf8 + ), + to: oldLaunchURL + ) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 100)], + ofItemAtPath: oldLaunchURL.path + ) + + let currentLaunchURL = fixtureLogURL(family: "current-launch", index: 1) + try write(Data("current launch noise without a selection\n".utf8), to: currentLaunchURL) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 200)], + ofItemAtPath: currentLaunchURL.path + ) + + let selection = try await makeMonitor().bootstrap() + + XCTAssertNil(selection) + } + + func testBootstrapParsesMarkerSplitAcrossBackwardReadChunks() async throws { + let chunkSize = 512 + let threadID = "22222222-3333-7444-8555-666666666666" + let marker = Data( + selectionLine( + timestamp: "2030-02-03T04:05:06.789Z", + threadID: threadID + ).utf8 + ) + let suffixCount = chunkSize - marker.count / 2 - 1 + XCTAssertGreaterThan(suffixCount, 0) + + var contents = Data("older noise\n".utf8) + contents.append(marker) + contents.append(0x0A) + contents.append(Data(repeating: 0x78, count: suffixCount)) + try write(contents) + + let selection = try await makeMonitor( + bootstrapChunkSize: chunkSize + ).bootstrap() + + XCTAssertEqual(selection?.threadID, threadID) + } + + func testNoMarkerKeepsCursorAndPollFindsAppendedSelection() async throws { + try write(Data("ordinary log line\nunfinished UTF-8: \u{732b}".utf8)) + let monitor = makeMonitor() + + let initial = try await monitor.bootstrap() + XCTAssertNil(initial) + + let threadID = "33333333-4444-7555-8666-777777777777" + try append( + Data( + ("\n" + selectionLine( + timestamp: "2030-03-04T05:06:07.890Z", + threadID: threadID + ) + "\n").utf8 + ) + ) + + let selection = try await monitor.poll() + XCTAssertEqual(selection?.threadID, threadID) + } + + func testUTF8SplitAcrossBootstrapAndPollPreservesSelectionLine() async throws { + let threadID = "thread-\u{732b}" + let line = Data( + selectionLine( + timestamp: "2030-03-04T05:07:08.901Z", + threadID: threadID + ).utf8 + ) + let characterBytes = Data("\u{732b}".utf8) + let characterRange = try XCTUnwrap(line.range(of: characterBytes)) + let splitIndex = line.index(after: characterRange.lowerBound) + try write(Data(line[.. CodexLogMonitor { + CodexLogMonitor( + logRoot: temporaryRoot, + runningCodexPIDProvider: { Self.fixturePID }, + bootstrapChunkSize: bootstrapChunkSize + ) + } + + private func write(_ data: Data) throws { + try write(data, to: logURL) + } + + private func write(_ data: Data, to url: URL) throws { + try data.write(to: url) + } + + private func append(_ data: Data) throws { + let handle = try FileHandle(forWritingTo: logURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + } + + private func selectionLine(timestamp: String, threadID: String) -> String { + "\(timestamp) info [electron-message-handler] " + + "IAB_LIFECYCLE received browser sidebar owner sync " + + "browserTabId=null conversationId=client-new-thread:fixture " + + "originWebContentsId=1 ownerRoutePath=/local/\(threadID) windowId=1" + } + + private func fixtureLogURL(index: Int) -> URL { + fixtureLogURL(family: "fixture", index: index) + } + + private func fixtureLogURL(family: String, index: Int) -> URL { + temporaryRoot.appendingPathComponent( + "codex-desktop-\(family)-\(Self.fixturePID)-t0-i1-\(index).log" + ) + } + + private func fileNumber(at url: URL) -> UInt64? { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.systemFileNumber] as? NSNumber)?.uint64Value + } +} diff --git a/Tests/CodexNotesCoreTests/LocalizationTests.swift b/Tests/CodexNotesCoreTests/LocalizationTests.swift index a26ba9b..7f74959 100644 --- a/Tests/CodexNotesCoreTests/LocalizationTests.swift +++ b/Tests/CodexNotesCoreTests/LocalizationTests.swift @@ -149,12 +149,61 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(english.text(.imageMarkdownAltText), "Image") XCTAssertEqual(chinese.text(.settingsEditorTitle), "编辑器") XCTAssertEqual(english.text(.settingsEditorTitle), "Editor") + XCTAssertEqual( + chinese.text(.settingsEditorWindowResizeHint), + "拖动笔记窗口边缘可调整大小,尺寸会自动记住。" + ) + XCTAssertEqual( + english.text(.settingsEditorWindowResizeHint), + "Drag a note window edge to resize. CodexNotes remembers the size." + ) XCTAssertEqual(chinese.text(.settingsGeneralTitle), "通用") XCTAssertEqual(english.text(.settingsGeneralTitle), "General") XCTAssertEqual(chinese.text(.settingsLanguageTitle), "语言") XCTAssertEqual(english.text(.settingsLanguageTitle), "Language") XCTAssertEqual(chinese.text(.settingsLaunchAtLoginTitle), "登录时启动") XCTAssertEqual(english.text(.settingsLaunchAtLoginTitle), "Launch at Login") + XCTAssertEqual(chinese.text(.settingsAboutTitle), "关于 CodexNotes") + XCTAssertEqual(english.text(.settingsAboutTitle), "About CodexNotes") + XCTAssertEqual(chinese.text(.settingsAboutGitHub), "GitHub 项目") + XCTAssertEqual(english.text(.settingsAboutGitHub), "GitHub Project") + XCTAssertEqual(chinese.text(.settingsAboutX), "X:关注作者") + XCTAssertEqual(english.text(.settingsAboutX), "X: Follow the Author") + XCTAssertEqual(chinese.text(.settingsAboutCoffee), "☕ 请江sir喝杯咖啡") + XCTAssertEqual(english.text(.settingsAboutCoffee), "☕ Buy Me a Coffee") + XCTAssertEqual(chinese.text(.settingsAboutFeedbackEmail), "反馈邮箱") + XCTAssertEqual(english.text(.settingsAboutFeedbackEmail), "Feedback") + XCTAssertEqual(chinese.text(.settingsAboutCopyEmail), "复制邮箱地址") + XCTAssertEqual(english.text(.settingsAboutCopyEmail), "Copy Email Address") + XCTAssertEqual(chinese.text(.settingsAboutEmailCopied), "已复制") + XCTAssertEqual(english.text(.settingsAboutEmailCopied), "Copied") + XCTAssertEqual(chinese.text(.settingsAboutAutomaticCheck), "自动检查更新") + XCTAssertEqual( + english.text(.settingsAboutAutomaticCheck), + "Automatically Check for Updates" + ) + XCTAssertEqual( + chinese.text(.settingsAboutAutomaticCheckDescription), + "每天检查一次;网络失败会稍后重试,仅发现新版本时提示。" + ) + XCTAssertEqual( + english.text(.settingsAboutAutomaticCheckDescription), + "Check once a day; retry later after a network failure, and notify only for new versions." + ) + XCTAssertEqual( + chinese.text( + .appUpdateBannerAvailable, + replacements: ["version": "1.5.0"] + ), + "CodexNotes 1.5.0 现已可用" + ) + XCTAssertEqual( + english.text( + .appUpdateBannerAvailable, + replacements: ["version": "1.5.0"] + ), + "CodexNotes 1.5.0 is available" + ) XCTAssertEqual( chinese.text(.settingsLaunchAtLoginError), "无法更改登录启动设置,请重试。" diff --git a/Tests/CodexNotesProbeTests/AppUpdateCheckerTests.swift b/Tests/CodexNotesProbeTests/AppUpdateCheckerTests.swift new file mode 100644 index 0000000..e9621eb --- /dev/null +++ b/Tests/CodexNotesProbeTests/AppUpdateCheckerTests.swift @@ -0,0 +1,386 @@ +import Foundation +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class AppUpdateCheckerTests: XCTestCase { + func testUpdateAvailableUsesExpectedRequestAndValidatedReleaseURL() async { + let recorder = RequestRecorder() + let data = releaseData( + tag: "v1.5.0", + url: "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0" + ) + let response = httpResponse(statusCode: 200) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + await recorder.record(request) + return (data, response) + } + + await checker.checkForUpdates() + + XCTAssertEqual( + checker.state, + .updateAvailable( + version: "1.5.0", + url: URL( + string: "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0" + )! + ) + ) + let request = await recorder.request + XCTAssertEqual(request?.url, AppUpdateChecker.latestReleaseAPIURL) + XCTAssertEqual(request?.httpMethod, "GET") + XCTAssertEqual(request?.timeoutInterval, 10) + XCTAssertEqual( + request?.value(forHTTPHeaderField: "Accept"), + "application/vnd.github+json" + ) + XCTAssertEqual( + request?.value(forHTTPHeaderField: "X-GitHub-Api-Version"), + "2022-11-28" + ) + XCTAssertEqual(request?.value(forHTTPHeaderField: "User-Agent"), "CodexNotes") + } + + func testEqualAndOlderLatestVersionsAreUpToDate() async { + for latestVersion in ["v1.4.68", "1.4.67"] { + let state = await checkedState( + currentVersion: "1.4.68", + data: releaseData(tag: latestVersion) + ) + XCTAssertEqual(state, .upToDate, "Unexpected result for \(latestVersion)") + } + } + + func testSemanticVersionComparisonHandlesNumericComponentsAndPrereleaseIdentifiers() async { + let numericComponentState = await checkedState( + currentVersion: "1.9.9", + data: releaseData(tag: "v1.10.0") + ) + XCTAssertEqual( + numericComponentState, + .updateAvailable( + version: "1.10.0", + url: releaseURL(tag: "v1.10.0") + ) + ) + + let prereleaseState = await checkedState( + currentVersion: "2.0.0-alpha-beta.1", + data: releaseData(tag: "v2.0.0-alpha-beta.2+build.7") + ) + XCTAssertEqual( + prereleaseState, + .updateAvailable( + version: "2.0.0-alpha-beta.2+build.7", + url: releaseURL(tag: "v2.0.0-alpha-beta.2+build.7") + ) + ) + + let stableState = await checkedState( + currentVersion: "2.0.0-alpha.9", + data: releaseData(tag: "2.0.0") + ) + XCTAssertEqual( + stableState, + .updateAvailable(version: "2.0.0", url: releaseURL(tag: "2.0.0")) + ) + } + + func testRejectsDraftAndPrereleaseGitHubReleases() async { + let draftState = await checkedState( + data: releaseData(tag: "1.5.0", draft: true) + ) + let prereleaseState = await checkedState( + data: releaseData(tag: "1.5.0", prerelease: true) + ) + + XCTAssertEqual(draftState, .failed) + XCTAssertEqual(prereleaseState, .failed) + } + + func testRejectsNonSuccessHTTPResponsesAndResponsesLargerThanOneMiB() async { + let nonSuccessState = await checkedState( + data: releaseData(tag: "1.5.0"), + statusCode: 404 + ) + let oversizedState = await checkedState( + data: Data(count: AppUpdateChecker.maximumResponseSize + 1) + ) + + XCTAssertEqual(nonSuccessState, .failed) + XCTAssertEqual(oversizedState, .failed) + } + + func testRejectsMalformedPayloadsAndInvalidSemanticVersions() async { + let malformedState = await checkedState(data: Data("{}".utf8)) + XCTAssertEqual(malformedState, .failed) + + let invalidVersions = [ + "1.4", + "01.4.0", + "1.04.0", + "1.4.00", + "1.4.0-01", + "1.4.0+", + " 1.4.0", + "1.4.0_1" + ] + for version in invalidVersions { + let state = await checkedState(data: releaseData(tag: version)) + XCTAssertEqual(state, .failed, "Unexpectedly accepted \(version)") + } + + let invalidCurrentVersionState = await checkedState( + currentVersion: AppBundleVersion.missingValue, + data: releaseData(tag: "1.5.0") + ) + XCTAssertEqual(invalidCurrentVersionState, .failed) + } + + func testRejectsReleaseURLsOutsideTheExpectedHTTPSGitHubPath() async { + let invalidURLs = [ + "http://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0", + "https://github.com.evil.example/jiangsir-tech/CodexNotes/releases/tag/v1.5.0", + "https://github.com/another-owner/CodexNotes/releases/tag/v1.5.0", + "https://github.com/jiangsir-tech/CodexNotes/issues/1", + "https://github.com/jiangsir-tech/CodexNotes/releases/download/v1.5.0/CodexNotes.zip", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v9.9.9", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0/extra", + "https://user@github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0", + "https://github.com:443/jiangsir-tech/CodexNotes/releases/tag/v1.5.0", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0?download=1", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0#notes" + ] + + for url in invalidURLs { + let state = await checkedState(data: releaseData(tag: "1.5.0", url: url)) + XCTAssertEqual(state, .failed, "Unexpectedly accepted \(url)") + } + } + + func testConcurrentCheckDoesNotStartAnotherRequest() async { + let fetcher = SuspendedFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + + let firstCheck = Task { await checker.checkForUpdates() } + await waitUntil { await fetcher.callCount == 1 } + XCTAssertEqual(checker.state, .checking) + + await checker.checkForUpdates() + let callCount = await fetcher.callCount + XCTAssertEqual(callCount, 1) + + await fetcher.resume() + _ = await firstCheck.value + XCTAssertEqual( + checker.state, + .updateAvailable(version: "1.5.0", url: releaseURL(tag: "1.5.0")) + ) + } + + func testCancellationReturnsToIdleAndDoesNotPublishFailure() async { + let fetcher = SuspendedFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + + let check = Task { await checker.checkForUpdates() } + await waitUntil { await fetcher.callCount == 1 } + checker.cancel() + + XCTAssertEqual(checker.state, .idle) + await fetcher.resume() + _ = await check.value + XCTAssertEqual(checker.state, .idle) + } + + func testCancellationAfterFetchResumesBeforePublicationReturnsNilAndStaysIdle() async { + let fetcher = SynchronouslyResumableFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + + let check = Task { await checker.checkForUpdates() } + await waitUntil { fetcher.callCount == 1 } + + fetcher.resume() + checker.cancel() + + let result = await check.value + XCTAssertNil(result) + XCTAssertEqual(checker.state, .idle) + } + + func testThrownFetcherErrorPublishesFailure() async { + let checker = AppUpdateChecker(currentVersion: "1.4.68") { _ in + throw StubError.network + } + + await checker.checkForUpdates() + + XCTAssertEqual(checker.state, .failed) + } + + func testBundleVersionTrimsValuesAndUsesDashForMissingFields() { + let populatedVersion = AppBundleVersion( + version: " 1.4.68\n", + build: " 88 " + ) + XCTAssertEqual(populatedVersion.version, "1.4.68") + XCTAssertEqual(populatedVersion.build, "88") + XCTAssertEqual(populatedVersion.displayVersion, "1.4.68 (88)") + XCTAssertEqual( + AppBundleVersion(version: nil, build: " \n "), + AppBundleVersion(version: "—", build: "—") + ) + XCTAssertEqual( + AppBundleVersion(version: nil, build: nil).displayVersion, + "— (—)" + ) + } + + private var defaultReleaseURL: URL { + releaseURL(tag: "v1.5.0") + } + + private func releaseURL(tag: String) -> URL { + URL(string: "https://github.com/jiangsir-tech/CodexNotes/releases/tag/\(tag)")! + } + + private func checkedState( + currentVersion: String = "1.4.68", + data: Data, + statusCode: Int = 200 + ) async -> AppUpdateState { + let response = httpResponse(statusCode: statusCode) + let checker = AppUpdateChecker(currentVersion: currentVersion) { _ in + (data, response) + } + await checker.checkForUpdates() + return checker.state + } + + private func releaseData( + tag: String, + url: String? = nil, + draft: Bool = false, + prerelease: Bool = false + ) -> Data { + let encodedTag = tag.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! + let inferredURL = "https://github.com/jiangsir-tech/CodexNotes/releases/tag/\(encodedTag)" + return try! JSONSerialization.data(withJSONObject: [ + "tag_name": tag, + "html_url": url ?? inferredURL, + "draft": draft, + "prerelease": prerelease + ]) + } + + private func httpResponse(statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: AppUpdateChecker.latestReleaseAPIURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 1_000_000_000, + _ predicate: @escaping @Sendable () async -> Bool + ) async { + let startedAt = DispatchTime.now().uptimeNanoseconds + while !(await predicate()) { + if DispatchTime.now().uptimeNanoseconds - startedAt >= timeoutNanoseconds { + XCTFail("Timed out waiting for asynchronous state") + return + } + try? await Task.sleep(nanoseconds: 1_000_000) + } + } +} + +private actor RequestRecorder { + private(set) var request: URLRequest? + + func record(_ request: URLRequest) { + self.request = request + } +} + +private actor SuspendedFetcher { + private let result: (Data, URLResponse) + private var continuation: CheckedContinuation<(Data, URLResponse), Error>? + private(set) var callCount = 0 + + init(result: (Data, URLResponse)) { + self.result = result + } + + func fetch(_ request: URLRequest) async throws -> (Data, URLResponse) { + callCount += 1 + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + continuation?.resume(returning: result) + continuation = nil + } +} + +private final class SynchronouslyResumableFetcher: @unchecked Sendable { + private let lock = NSLock() + private let result: (Data, URLResponse) + private var continuation: CheckedContinuation<(Data, URLResponse), Error>? + private var storedCallCount = 0 + + var callCount: Int { + lock.withLock { storedCallCount } + } + + init(result: (Data, URLResponse)) { + self.result = result + } + + func fetch(_ request: URLRequest) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { + storedCallCount += 1 + self.continuation = continuation + } + } + } + + func resume() { + let continuation = lock.withLock { + let continuation = self.continuation + self.continuation = nil + return continuation + } + continuation?.resume(returning: result) + } +} + +private enum StubError: Error { + case network +} diff --git a/Tests/CodexNotesProbeTests/BottomBarActionPresentationTests.swift b/Tests/CodexNotesProbeTests/BottomBarActionPresentationTests.swift index 8e2bdbe..589313d 100644 --- a/Tests/CodexNotesProbeTests/BottomBarActionPresentationTests.swift +++ b/Tests/CodexNotesProbeTests/BottomBarActionPresentationTests.swift @@ -1,4 +1,5 @@ import CodexNotesCore +import Foundation @testable import CodexNotesProbe import XCTest @@ -38,6 +39,42 @@ final class BottomBarActionPresentationTests: XCTestCase { ) } + func testSettingsActionAnnouncesAnAvailableUpdate() { + let presentation = BottomBarActionPresentation.settings(updateVersion: "1.5.0") + let expected = L10n.text( + .bottomBarSettingsUpdateAvailableHelp, + replacements: ["version": "1.5.0"] + ) + + XCTAssertEqual(presentation.systemImage, "gearshape") + XCTAssertEqual(presentation.helpText, expected) + XCTAssertEqual(presentation.accessibilityLabel, expected) + } + + func testUpdateBannerPresentationKeepsActionsExplicit() { + let update = AvailableAppUpdate( + version: "1.5.0", + url: URL( + string: "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0" + )! + ) + let presentation = AppUpdateBannerPresentation(update) + + XCTAssertEqual( + presentation.title, + L10n.text( + .appUpdateBannerAvailable, + replacements: ["version": "1.5.0"] + ) + ) + XCTAssertEqual(presentation.viewTitle, L10n.text(.settingsAboutViewUpdate)) + XCTAssertEqual(presentation.laterTitle, L10n.text(.appUpdateBannerLater)) + XCTAssertEqual( + presentation.viewAccessibilityHint, + L10n.text(.settingsAboutViewUpdateAccessibilityHint) + ) + } + func testActionTargetsRemainComfortablyClickableInResponsiveLayouts() { XCTAssertEqual(BottomBarActionMetrics.minimumHitSize, 28) XCTAssertGreaterThan(BottomBarActionMetrics.minimumHitSize, 18) diff --git a/Tests/CodexNotesProbeTests/GlobalHotKeyControllerTests.swift b/Tests/CodexNotesProbeTests/GlobalHotKeyControllerTests.swift new file mode 100644 index 0000000..483000d --- /dev/null +++ b/Tests/CodexNotesProbeTests/GlobalHotKeyControllerTests.swift @@ -0,0 +1,848 @@ +import AppKit +import Carbon.HIToolbox +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class GlobalHotKeyControllerTests: XCTestCase { + func testDefaultShortcutIsControlShiftSpace() { + XCTAssertEqual(GlobalHotKeyShortcut.defaultValue.keyCode, UInt32(kVK_Space)) + XCTAssertEqual( + GlobalHotKeyShortcut.defaultValue.modifiers, + [.control, .shift] + ) + XCTAssertEqual(GlobalHotKeyShortcut.defaultValue.displayName, "⌃⇧Space") + XCTAssertNil(GlobalHotKeyShortcut.defaultValue.validationIssue) + XCTAssertEqual( + CarbonGlobalHotKeyRegistrationPolicy.options, + OptionBits(0) + ) + } + + func testValidationRejectsUnsafeAndUnsupportedShortcuts() { + XCTAssertEqual( + shortcut(keyCode: kVK_ANSI_N, modifiers: []).validationIssue, + .noModifier + ) + XCTAssertEqual( + shortcut(keyCode: kVK_ANSI_N, modifiers: [.shift]).validationIssue, + .shiftOnly + ) + XCTAssertEqual( + shortcut(keyCode: kVK_ANSI_N, modifiers: [.command]).validationIssue, + .insufficientModifiers + ) + XCTAssertEqual( + GlobalHotKeyShortcut( + keyCode: 9_999, + modifiers: [.control, .shift] + ).validationIssue, + .unsupportedKey + ) + XCTAssertNil( + shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ).validationIssue + ) + XCTAssertEqual( + GlobalHotKeyShortcut( + keyCode: UInt32(kVK_Space), + modifiers: GlobalHotKeyModifiers(rawValue: 0x30) + ).validationIssue, + .unsupportedKey + ) + } + + func testCarbonBackendRetriesOnlyTheRolledBackNewReference() { + let scheduler = TestCarbonCleanupScheduler() + let oldReference = OpaquePointer(bitPattern: 0x101)! + let newReference = OpaquePointer(bitPattern: 0x202)! + var registrationReferences = [oldReference, newReference] + var unregistrationCalls: [EventHotKeyRef] = [] + var oldReferenceAttempts = 0 + var newReferenceAttempts = 0 + let backend = CarbonGlobalHotKeyBackend( + cleanupRetryDelays: [1, 5, 30], + cleanupScheduler: scheduler.schedule, + registrationOperation: { _, _ in + (Int32(noErr), registrationReferences.removeFirst()) + }, + unregistrationOperation: { reference in + unregistrationCalls.append(reference) + if reference == oldReference { + oldReferenceAttempts += 1 + return oldReferenceAttempts == 1 ? -71 : Int32(noErr) + } + XCTAssertEqual(reference, newReference) + newReferenceAttempts += 1 + return newReferenceAttempts == 1 ? -72 : Int32(noErr) + } + ) + defer { backend.invalidate() } + + XCTAssertEqual(backend.register(.defaultValue), noErr) + let replacement = shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + + XCTAssertEqual(backend.register(replacement), -71) + XCTAssertEqual(unregistrationCalls, [oldReference, newReference]) + XCTAssertEqual(scheduler.scheduledDelays, [1]) + + scheduler.runNext() + + XCTAssertEqual( + unregistrationCalls, + [oldReference, newReference, newReference] + ) + XCTAssertEqual(scheduler.activeCount, 0) + + let releaseResult = backend.unregister() + XCTAssertEqual( + unregistrationCalls, + [oldReference, newReference, newReference, oldReference] + ) + XCTAssertTrue(releaseResult.activeRegistrationReleased) + XCTAssertEqual(releaseResult.status, noErr) + } + + func testCarbonBackendPendingCleanupRetriesAreBounded() { + XCTAssertEqual( + CarbonGlobalHotKeyBackend.defaultCleanupRetryDelays, + [1, 5, 30] + ) + let scheduler = TestCarbonCleanupScheduler() + let oldReference = OpaquePointer(bitPattern: 0x303)! + let newReference = OpaquePointer(bitPattern: 0x404)! + var registrationReferences = [oldReference, newReference] + var unregistrationCalls: [EventHotKeyRef] = [] + let backend = CarbonGlobalHotKeyBackend( + cleanupScheduler: scheduler.schedule, + registrationOperation: { _, _ in + (Int32(noErr), registrationReferences.removeFirst()) + }, + unregistrationOperation: { reference in + unregistrationCalls.append(reference) + return reference == oldReference ? -81 : -82 + } + ) + + XCTAssertEqual(backend.register(.defaultValue), noErr) + XCTAssertEqual( + backend.register( + shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + ), + -81 + ) + + scheduler.runNext() + scheduler.runNext() + scheduler.runNext() + + XCTAssertEqual(scheduler.scheduledDelays, [1, 5, 30]) + XCTAssertEqual(scheduler.activeCount, 0) + XCTAssertEqual( + unregistrationCalls.filter { $0 == oldReference }.count, + 1 + ) + XCTAssertEqual( + unregistrationCalls.filter { $0 == newReference }.count, + 4 + ) + + backend.invalidate() + } + + func testCarbonBackendInvalidateCancelsPendingCleanupRetry() { + let scheduler = TestCarbonCleanupScheduler() + let oldReference = OpaquePointer(bitPattern: 0x505)! + let newReference = OpaquePointer(bitPattern: 0x606)! + var registrationReferences = [oldReference, newReference] + var unregistrationCalls: [EventHotKeyRef] = [] + var initialRollbackIsFailing = true + let backend = CarbonGlobalHotKeyBackend( + cleanupRetryDelays: [1, 5, 30], + cleanupScheduler: scheduler.schedule, + registrationOperation: { _, _ in + (Int32(noErr), registrationReferences.removeFirst()) + }, + unregistrationOperation: { reference in + unregistrationCalls.append(reference) + if initialRollbackIsFailing { + return reference == oldReference ? -91 : -92 + } + return noErr + } + ) + + XCTAssertEqual(backend.register(.defaultValue), noErr) + XCTAssertEqual( + backend.register( + shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + ), + -91 + ) + XCTAssertEqual(scheduler.scheduledDelays, [1]) + XCTAssertEqual(scheduler.activeCount, 1) + + initialRollbackIsFailing = false + backend.invalidate() + + XCTAssertEqual(scheduler.activeCount, 0) + XCTAssertEqual( + unregistrationCalls, + [oldReference, newReference, oldReference, newReference] + ) + let callCountAfterInvalidation = unregistrationCalls.count + scheduler.runNext(ignoringCancellation: true) + backend.invalidate() + XCTAssertEqual(unregistrationCalls.count, callCountAfterInvalidation) + } + + func testCarbonBackendCancelledRetryCannotCleanANewerBatch() { + let scheduler = TestCarbonCleanupScheduler() + let oldReference = OpaquePointer(bitPattern: 0x701)! + let firstRollbackReference = OpaquePointer(bitPattern: 0x702)! + let newerOldReference = OpaquePointer(bitPattern: 0x703)! + let secondRollbackReference = OpaquePointer(bitPattern: 0x704)! + var registrationReferences = [ + oldReference, + firstRollbackReference, + newerOldReference, + secondRollbackReference, + ] + var attemptCounts: [EventHotKeyRef: Int] = [:] + var unregistrationCalls: [EventHotKeyRef] = [] + let backend = CarbonGlobalHotKeyBackend( + cleanupRetryDelays: [1, 5, 30], + cleanupScheduler: scheduler.schedule, + registrationOperation: { _, _ in + (Int32(noErr), registrationReferences.removeFirst()) + }, + unregistrationOperation: { reference in + unregistrationCalls.append(reference) + attemptCounts[reference, default: 0] += 1 + return attemptCounts[reference] == 1 ? -101 : Int32(noErr) + } + ) + defer { backend.invalidate() } + + XCTAssertEqual(backend.register(.defaultValue), noErr) + XCTAssertEqual( + backend.register( + shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + ), + -101 + ) + XCTAssertEqual(scheduler.scheduledDelays, [1]) + + XCTAssertEqual(backend.unregister().status, noErr) + XCTAssertEqual(scheduler.activeCount, 0) + + XCTAssertEqual(backend.register(.defaultValue), noErr) + XCTAssertEqual( + backend.register( + shortcut( + keyCode: kVK_ANSI_M, + modifiers: [.control, .option] + ) + ), + -101 + ) + XCTAssertEqual(scheduler.scheduledDelays, [1, 1]) + let callCountBeforeStaleAction = unregistrationCalls.count + + scheduler.runNext(ignoringCancellation: true) + + XCTAssertEqual(unregistrationCalls.count, callCountBeforeStaleAction) + XCTAssertEqual(attemptCounts[secondRollbackReference], 1) + + scheduler.runNext() + + XCTAssertEqual(attemptCounts[secondRollbackReference], 2) + XCTAssertEqual(attemptCounts[newerOldReference], 1) + XCTAssertEqual(scheduler.activeCount, 0) + } + + func testStartWhileCodexHiddenDoesNotRegister() { + let fixture = makeFixture(isCodexAvailable: false) + + fixture.controller.start() + + XCTAssertTrue(fixture.backend.registeredShortcuts.isEmpty) + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + XCTAssertNil(fixture.controller.registrationState.issue) + } + + func testCodexAvailabilityRequiresTheRightVisibleRunningApplication() { + let codexBundleID = "com.openai.codex" + + XCTAssertTrue( + CodexApplicationAvailabilityPolicy.isAvailable( + bundleIdentifier: codexBundleID, + isTerminated: false, + isHidden: false + ) + ) + XCTAssertFalse( + CodexApplicationAvailabilityPolicy.isAvailable( + bundleIdentifier: codexBundleID, + isTerminated: false, + isHidden: true + ) + ) + XCTAssertFalse( + CodexApplicationAvailabilityPolicy.isAvailable( + bundleIdentifier: codexBundleID, + isTerminated: true, + isHidden: false + ) + ) + XCTAssertFalse( + CodexApplicationAvailabilityPolicy.isAvailable( + bundleIdentifier: "com.example.other", + isTerminated: false, + isHidden: false + ) + ) + XCTAssertTrue( + CodexApplicationAvailabilityPolicy.shouldRefresh( + for: codexBundleID + ) + ) + XCTAssertFalse( + CodexApplicationAvailabilityPolicy.shouldRefresh( + for: "com.example.other" + ) + ) + } + + func testStartWhileCodexVisibleRegistersDefault() { + let fixture = makeFixture(isCodexAvailable: true) + + fixture.controller.start() + + XCTAssertEqual( + fixture.backend.registeredShortcuts, + [.defaultValue] + ) + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + } + + func testCodexHideReleasesShortcutAndUnhideRestoresIt() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + + fixture.availability.update(false) + + XCTAssertEqual(fixture.backend.unregisterCount, 1) + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + + fixture.availability.update(true) + + XCTAssertEqual(fixture.backend.registeredShortcuts.count, 2) + XCTAssertEqual(fixture.backend.registeredShortcuts.last, .defaultValue) + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + } + + func testRepeatedPressedEventTogglesOnlyAfterRelease() { + var toggleCount = 0 + let fixture = makeFixture( + isCodexAvailable: true, + toggleAction: { toggleCount += 1 } + ) + fixture.controller.start() + + fixture.backend.send(.pressed) + fixture.backend.send(.pressed) + XCTAssertEqual(toggleCount, 1) + + fixture.backend.send(.released) + fixture.backend.send(.pressed) + XCTAssertEqual(toggleCount, 2) + } + + func testRecordingSuspendsShortcutAndCancelRestoresIt() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + + fixture.controller.beginRecording() + + XCTAssertTrue(fixture.controller.isRecording) + XCTAssertEqual(fixture.backend.unregisterCount, 1) + XCTAssertEqual( + fixture.controller.registrationState.activity, + .suspendedForRecording + ) + + fixture.controller.cancelRecording() + + XCTAssertFalse(fixture.controller.isRecording) + XCTAssertEqual(fixture.backend.registeredShortcuts.count, 2) + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + } + + func testCodexBecomingVisibleDuringRecordingDoesNotRegister() { + let fixture = makeFixture(isCodexAvailable: false) + fixture.controller.start() + fixture.controller.beginRecording() + + fixture.availability.update(true) + + XCTAssertTrue(fixture.backend.registeredShortcuts.isEmpty) + XCTAssertEqual( + fixture.controller.registrationState.activity, + .suspendedForRecording + ) + + fixture.controller.cancelRecording() + XCTAssertEqual(fixture.backend.registeredShortcuts, [.defaultValue]) + } + + func testChangingShortcutWhileCodexHiddenPersistsWithoutRegistering() { + let fixture = makeFixture(isCodexAvailable: false) + fixture.controller.start() + let custom = shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + + XCTAssertEqual(fixture.controller.set(custom), .updated) + XCTAssertEqual(fixture.controller.currentShortcut, custom) + XCTAssertTrue(fixture.backend.registeredShortcuts.isEmpty) + + fixture.availability.update(true) + XCTAssertEqual(fixture.backend.registeredShortcuts, [custom]) + } + + func testConflictKeepsOldShortcutRegisteredAndReportsAttempt() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + let custom = shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + fixture.backend.nextStatuses = [Int32(eventHotKeyExistsErr)] + + XCTAssertEqual(fixture.controller.set(custom), .conflict(custom)) + XCTAssertEqual(fixture.controller.currentShortcut, .defaultValue) + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .conflict(attempted: custom) + ) + XCTAssertEqual(fixture.backend.unregisterCount, 0) + } + + func testRecordingConflictRestoresOldShortcut() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + let custom = shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + fixture.controller.beginRecording() + fixture.backend.nextStatuses = [ + Int32(eventHotKeyExistsErr), + Int32(noErr), + ] + + XCTAssertEqual( + fixture.controller.commitRecordedShortcut(custom), + .conflict(custom) + ) + XCTAssertFalse(fixture.controller.isRecording) + XCTAssertEqual(fixture.controller.currentShortcut, .defaultValue) + XCTAssertEqual(fixture.backend.registeredShortcuts.last, .defaultValue) + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + } + + func testRecordingTheSameShortcutReportsReregistrationConflict() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.controller.beginRecording() + fixture.backend.nextStatuses = [Int32(eventHotKeyExistsErr)] + + XCTAssertEqual( + fixture.controller.commitRecordedShortcut(.defaultValue), + .conflict(.defaultValue) + ) + XCTAssertFalse(fixture.controller.isRecording) + XCTAssertEqual(fixture.controller.registrationState.activity, .disabled) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .conflict(attempted: .defaultValue) + ) + } + + func testFailedOldShortcutRestoreRetriesAutomatically() async { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.controller.beginRecording() + let custom = shortcut( + keyCode: kVK_ANSI_N, + modifiers: [.control, .option] + ) + fixture.backend.nextStatuses = [ + Int32(eventHotKeyExistsErr), + -9, + Int32(noErr), + ] + + XCTAssertEqual( + fixture.controller.commitRecordedShortcut(custom), + .failed(shortcut: .defaultValue, status: -9) + ) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .backendFailed(status: -9) + ) + + await drainMainQueue(times: 3) + + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + XCTAssertNil(fixture.controller.registrationState.issue) + XCTAssertEqual(fixture.backend.registeredShortcuts.last, .defaultValue) + } + + func testConflictRetriesAutomaticallyAfterTheBlockerReleases() async { + let fixture = makeFixture(isCodexAvailable: true) + fixture.backend.nextStatuses = [ + Int32(eventHotKeyExistsErr), + Int32(noErr), + ] + + fixture.controller.start() + + XCTAssertEqual(fixture.controller.registrationState.activity, .disabled) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .conflict(attempted: .defaultValue) + ) + + await drainMainQueue(times: 3) + + XCTAssertEqual(fixture.controller.registrationState.activity, .registered) + XCTAssertNil(fixture.controller.registrationState.issue) + XCTAssertEqual(fixture.backend.registeredShortcuts.last, .defaultValue) + } + + func testFailedUnregisterIsReportedAndRetriedWithoutToggling() async { + var toggleCount = 0 + let fixture = makeFixture( + isCodexAvailable: true, + toggleAction: { toggleCount += 1 } + ) + fixture.controller.start() + fixture.backend.nextUnregisterStatuses = [-7, Int32(noErr)] + + fixture.availability.update(false) + + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .backendFailed(status: -7) + ) + fixture.backend.send(.pressed) + XCTAssertEqual(toggleCount, 0) + + await drainMainQueue(times: 3) + + XCTAssertEqual(fixture.backend.unregisterCount, 2) + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + XCTAssertNil(fixture.controller.registrationState.issue) + } + + func testUnregisterRetriesAreBounded() async { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.backend.nextUnregisterStatuses = [-1, -2, -3, -4, -5] + + fixture.availability.update(false) + await drainMainQueue(times: 7) + + XCTAssertEqual(fixture.backend.unregisterCount, 4) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .backendFailed(status: -4) + ) + } + + func testPendingBackendCleanupDoesNotPretendTheShortcutIsRegistered() async { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.backend.nextUnregisterResults = [ + GlobalHotKeyUnregisterResult( + activeRegistrationReleased: true, + status: -8 + ), + GlobalHotKeyUnregisterResult( + activeRegistrationReleased: true, + status: Int32(noErr) + ), + ] + + fixture.availability.update(false) + + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .backendFailed(status: -8) + ) + + await drainMainQueue(times: 3) + + XCTAssertEqual(fixture.backend.unregisterCount, 2) + XCTAssertEqual(fixture.controller.registrationState.activity, .codexUnavailable) + XCTAssertNil(fixture.controller.registrationState.issue) + } + + func testInvalidRecordedShortcutKeepsRecorderActive() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.controller.beginRecording() + let unsafe = shortcut( + keyCode: kVK_ANSI_C, + modifiers: [.command] + ) + + XCTAssertEqual( + fixture.controller.commitRecordedShortcut(unsafe), + .rejected(.insufficientModifiers) + ) + XCTAssertTrue(fixture.controller.isRecording) + XCTAssertEqual( + fixture.controller.registrationState.issue, + .invalidShortcut(.insufficientModifiers) + ) + } + + func testClearPersistsDisabledAndRestoreReenablesDefault() { + let suiteName = "GlobalHotKeyControllerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let backend = FakeGlobalHotKeyBackend() + let availability = FakeCodexAvailability(isAvailable: true) + let controller = GlobalHotKeyController( + defaults: defaults, + backend: backend, + availabilityMonitor: availability, + toggleAction: {} + ) + controller.start() + + controller.clear() + + XCTAssertNil(controller.currentShortcut) + XCTAssertEqual(controller.registrationState.activity, .disabled) + + let restoredFromDefaults = GlobalHotKeyController( + defaults: defaults, + backend: FakeGlobalHotKeyBackend(), + availabilityMonitor: FakeCodexAvailability(isAvailable: false), + toggleAction: {} + ) + XCTAssertNil(restoredFromDefaults.currentShortcut) + + XCTAssertEqual(controller.restore(), .updated) + XCTAssertEqual(controller.currentShortcut, .defaultValue) + XCTAssertTrue(controller.isDefault) + } + + func testStopRemovesObserverAndRegistrationIdempotently() { + let fixture = makeFixture(isCodexAvailable: true) + fixture.controller.start() + fixture.controller.start() + + fixture.controller.stop() + fixture.controller.stop() + + XCTAssertEqual(fixture.availability.startCount, 1) + XCTAssertEqual(fixture.availability.stopCount, 1) + XCTAssertEqual(fixture.backend.unregisterCount, 1) + XCTAssertEqual(fixture.controller.registrationState.activity, .stopped) + } + + private func shortcut( + keyCode: Int, + modifiers: GlobalHotKeyModifiers + ) -> GlobalHotKeyShortcut { + GlobalHotKeyShortcut( + keyCode: UInt32(keyCode), + modifiers: modifiers + ) + } + + private func makeFixture( + isCodexAvailable: Bool, + toggleAction: @escaping () -> Void = {} + ) -> Fixture { + let suiteName = "GlobalHotKeyControllerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let backend = FakeGlobalHotKeyBackend() + let availability = FakeCodexAvailability( + isAvailable: isCodexAvailable + ) + let controller = GlobalHotKeyController( + defaults: defaults, + backend: backend, + availabilityMonitor: availability, + retryDelays: [0, 0, 0], + toggleAction: toggleAction + ) + addTeardownBlock { + await MainActor.run { + controller.stop() + } + UserDefaults(suiteName: suiteName)? + .removePersistentDomain(forName: suiteName) + } + return Fixture( + controller: controller, + backend: backend, + availability: availability + ) + } + + private func drainMainQueue(times: Int) async { + for _ in 0.. Void)? + var nextStatuses: [Int32] = [] + var nextUnregisterStatuses: [Int32] = [] + var nextUnregisterResults: [GlobalHotKeyUnregisterResult] = [] + private(set) var registeredShortcuts: [GlobalHotKeyShortcut] = [] + private(set) var unregisterCount = 0 + private(set) var invalidateCount = 0 + + func register(_ shortcut: GlobalHotKeyShortcut) -> Int32 { + let status = nextStatuses.isEmpty ? Int32(noErr) : nextStatuses.removeFirst() + if status == noErr { + registeredShortcuts.append(shortcut) + } + return status + } + + func unregister() -> GlobalHotKeyUnregisterResult { + unregisterCount += 1 + if !nextUnregisterResults.isEmpty { + return nextUnregisterResults.removeFirst() + } + let status = nextUnregisterStatuses.isEmpty + ? Int32(noErr) + : nextUnregisterStatuses.removeFirst() + return GlobalHotKeyUnregisterResult( + activeRegistrationReleased: status == noErr, + status: status + ) + } + + func invalidate() { + invalidateCount += 1 + } + + func send(_ event: GlobalHotKeySystemEvent) { + eventHandler?(event) + } +} + +@MainActor +private final class FakeCodexAvailability: + CodexApplicationAvailabilityObserving +{ + var isCodexAvailable: Bool + private var onChange: ((Bool) -> Void)? + private(set) var startCount = 0 + private(set) var stopCount = 0 + + init(isAvailable: Bool) { + isCodexAvailable = isAvailable + } + + func start(onChange: @escaping (Bool) -> Void) { + startCount += 1 + self.onChange = onChange + } + + func stop() { + stopCount += 1 + onChange = nil + } + + func update(_ isAvailable: Bool) { + isCodexAvailable = isAvailable + onChange?(isAvailable) + } +} + +@MainActor +private final class TestCarbonCleanupScheduler { + private final class Item { + let delay: TimeInterval + let action: CarbonGlobalHotKeyBackend.CleanupScheduledAction + var isCancelled = false + + init( + delay: TimeInterval, + action: @escaping CarbonGlobalHotKeyBackend.CleanupScheduledAction + ) { + self.delay = delay + self.action = action + } + } + + private var items: [Item] = [] + private(set) var scheduledDelays: [TimeInterval] = [] + + var activeCount: Int { + items.filter { !$0.isCancelled }.count + } + + func schedule( + delay: TimeInterval, + action: @escaping CarbonGlobalHotKeyBackend.CleanupScheduledAction + ) -> CarbonGlobalHotKeyBackend.CleanupCancellation { + let item = Item(delay: delay, action: action) + items.append(item) + scheduledDelays.append(delay) + return { + item.isCancelled = true + } + } + + func runNext(ignoringCancellation: Bool = false) { + guard !items.isEmpty else { return } + let item = items.removeFirst() + guard ignoringCancellation || !item.isCancelled else { return } + item.action() + } +} diff --git a/Tests/CodexNotesProbeTests/GlobalHotKeyRecorderViewTests.swift b/Tests/CodexNotesProbeTests/GlobalHotKeyRecorderViewTests.swift new file mode 100644 index 0000000..5a1d139 --- /dev/null +++ b/Tests/CodexNotesProbeTests/GlobalHotKeyRecorderViewTests.swift @@ -0,0 +1,158 @@ +import AppKit +import CodexNotesCore +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class GlobalHotKeyRecorderViewTests: XCTestCase { + func testRecorderHasConciseChineseAndEnglishLabels() { + XCTAssertEqual( + L10n.text( + .settingsGlobalHotKeyTitle, + preference: .simplifiedChinese + ), + "显示/隐藏 CodexNotes" + ) + XCTAssertEqual( + L10n.text(.settingsGlobalHotKeyTitle, preference: .english), + "Show/Hide CodexNotes" + ) + XCTAssertEqual( + L10n.text(.globalHotKeyNotSet, preference: .simplifiedChinese), + "未设置" + ) + XCTAssertEqual( + L10n.text(.globalHotKeyNotSet, preference: .english), + "Not Set" + ) + } + + func testEscapeCancelsInsteadOfBecomingARecordedShortcut() { + XCTAssertEqual( + GlobalHotKeyRecorderKeyPolicy.action( + keyCode: 53, + modifierFlags: [.command, .shift], + isRepeat: false + ), + .cancel + ) + } + + func testBackspaceAndForwardDeleteClearTheShortcut() { + for keyCode: UInt16 in [51, 117] { + XCTAssertEqual( + GlobalHotKeyRecorderKeyPolicy.action( + keyCode: keyCode, + modifierFlags: [], + isRepeat: false + ), + .clear + ) + } + } + + func testRepeatedKeyDownIsIgnored() { + XCTAssertEqual( + GlobalHotKeyRecorderKeyPolicy.action( + keyCode: 49, + modifierFlags: [.control, .shift], + isRepeat: true + ), + .ignore + ) + } + + func testRecordedShortcutKeepsOnlySupportedModifierFlags() { + XCTAssertEqual( + GlobalHotKeyRecorderKeyPolicy.action( + keyCode: 49, + modifierFlags: [.control, .shift, .capsLock, .numericPad], + isRepeat: false + ), + .record(keyCode: 49, modifiers: [.control, .shift]) + ) + } + + func testCommandKeyEquivalentIsConsumedBeforeTheApplicationMenu() throws { + let captureView = GlobalHotKeyCaptureNSView() + captureView.isActive = true + var recordedAction: GlobalHotKeyRecorderKeyAction? + captureView.recordShortcut = { keyCode, modifiers in + recordedAction = .record(keyCode: keyCode, modifiers: modifiers) + } + let event = try XCTUnwrap( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .command, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "q", + charactersIgnoringModifiers: "q", + isARepeat: false, + keyCode: 12 + ) + ) + + XCTAssertTrue(captureView.performKeyEquivalent(with: event)) + XCTAssertEqual( + recordedAction, + .record(keyCode: 12, modifiers: .command) + ) + } + + func testWindowResigningKeyCancelsAnActiveRecording() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + defer { window.orderOut(nil) } + let captureView = GlobalHotKeyCaptureNSView() + window.contentView = captureView + captureView.isActive = true + var cancellationCount = 0 + captureView.cancelRecording = { cancellationCount += 1 } + + NotificationCenter.default.post( + name: NSWindow.didResignKeyNotification, + object: window + ) + + XCTAssertEqual(cancellationCount, 1) + } + + func testSettingsSourceRestoresRecordingOnEveryExitPath() throws { + let recorderSource = try source( + "Sources/CodexNotesProbe/GlobalHotKeyRecorderView.swift" + ) + XCTAssertTrue(recorderSource.contains(".onDisappear")) + XCTAssertTrue(recorderSource.contains("cancelRecording()")) + XCTAssertTrue(recorderSource.contains("override func resignFirstResponder()")) + + let settingsSource = try source( + "Sources/CodexNotesProbe/SettingsView.swift" + ) + XCTAssertTrue(settingsSource.contains("globalHotKeyController.beginRecording()")) + XCTAssertTrue(settingsSource.contains("globalHotKeyController.cancelRecording()")) + XCTAssertTrue(settingsSource.contains("commitRecordedShortcut(shortcut)")) + XCTAssertTrue(recorderSource.contains(".accessibilityFocused(")) + XCTAssertTrue(recorderSource.contains(".announcementRequested")) + XCTAssertTrue(recorderSource.contains(".frame(width: 28, height: 28)")) + } + + private func source(_ relativePath: String) throws -> String { + let testsDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + let repositoryRoot = testsDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } +} diff --git a/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift b/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift index 810409e..c3f6a74 100644 --- a/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift +++ b/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift @@ -253,6 +253,58 @@ final class MainWindowFramePersistenceTests: XCTestCase { } } + func testAdaptiveInitialSizePersistsWhenRelaunchingOnASmallerDisplay() { + withDefaults { defaults in + let firstWindow = makeWindow( + frame: testFrame(xOffset: 20, yOffset: 20) + ) + defer { firstWindow.orderOut(nil) } + var firstPlacementCount = 0 + + MainWindowFramePersistence.configure( + window: firstWindow, + defaults: defaults + ) { + firstPlacementCount += 1 + let size = MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: CGSize(width: 2_560, height: 1_409) + ) + firstWindow.setFrame( + NSRect(origin: firstWindow.frame.origin, size: size), + display: false + ) + } + + XCTAssertEqual(firstPlacementCount, 1) + XCTAssertEqual(firstWindow.frame.size, NSSize(width: 548, height: 725)) + + let relaunchedWindow = makeWindow( + frame: testFrame(xOffset: 40, yOffset: 40) + ) + defer { relaunchedWindow.orderOut(nil) } + var relaunchPlacementCount = 0 + MainWindowFramePersistence.configure( + window: relaunchedWindow, + defaults: defaults + ) { + relaunchPlacementCount += 1 + let size = MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: CGSize(width: 1_440, height: 875) + ) + relaunchedWindow.setFrame( + NSRect(origin: relaunchedWindow.frame.origin, size: size), + display: false + ) + } + + XCTAssertEqual(relaunchPlacementCount, 0) + XCTAssertEqual( + relaunchedWindow.frame.size, + NSSize(width: 548, height: 725) + ) + } + } + func testConfiguredWindowContinuesAutosavingMoveAndResize() { withStandardFrameDefaults { _ in let window = makeWindow( diff --git a/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift b/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift new file mode 100644 index 0000000..1125ae0 --- /dev/null +++ b/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift @@ -0,0 +1,126 @@ +import AppKit +import XCTest +@testable import CodexNotesProbe + +final class MainWindowInitialPlacementPolicyTests: XCTestCase { + func testPreferredSizeAdaptsAcrossLaptopAndExternalDisplays() { + let cases: [(CGSize, CGSize)] = [ + (CGSize(width: 1_440, height: 875), CGSize(width: 418, height: 640)), + (CGSize(width: 1_512, height: 949), CGSize(width: 438, height: 683)), + (CGSize(width: 1_728, height: 1_053), CGSize(width: 501, height: 725)), + (CGSize(width: 2_560, height: 1_409), CGSize(width: 548, height: 725)), + (CGSize(width: 6_016, height: 3_290), CGSize(width: 548, height: 725)), + ] + + for (visibleSize, expected) in cases { + XCTAssertEqual( + MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: visibleSize + ), + expected, + "Unexpected size for \(visibleSize)" + ) + } + } + + func testPreferredSizeFallsBackForInvalidScreenInformation() { + XCTAssertEqual( + MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: nil + ), + NSSize(width: 440, height: 680) + ) + XCTAssertEqual( + MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: .zero + ), + NSSize(width: 440, height: 680) + ) + XCTAssertEqual( + MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: CGSize(width: CGFloat.nan, height: 900) + ), + NSSize(width: 440, height: 680) + ) + } + + func testPreferredSizeNeverExceedsAnUnusuallySmallVisibleFrame() { + XCTAssertEqual( + MainWindowInitialPlacementPolicy.preferredSize( + forVisibleFrameSize: CGSize(width: 360, height: 500) + ), + NSSize(width: 360, height: 500) + ) + } + + func testPlacementUsesRightThenLeftThenClampedOverlap() { + let visibleFrame = NSRect(x: 0, y: 0, width: 1_440, height: 900) + + let right = MainWindowInitialPlacementPolicy.initialFrame( + in: visibleFrame, + codexFrame: NSRect(x: 100, y: 100, width: 700, height: 700) + ) + XCTAssertEqual(right.origin, NSPoint(x: 808, y: 152)) + + let left = MainWindowInitialPlacementPolicy.initialFrame( + in: visibleFrame, + codexFrame: NSRect(x: 600, y: 100, width: 700, height: 700) + ) + XCTAssertEqual(left.origin, NSPoint(x: 174, y: 152)) + + let overlap = MainWindowInitialPlacementPolicy.initialFrame( + in: visibleFrame, + codexFrame: NSRect(x: 100, y: 100, width: 1_240, height: 700) + ) + XCTAssertEqual(overlap.origin, NSPoint(x: 914, y: 152)) + XCTAssertTrue(visibleFrame.contains(overlap)) + } + + func testPlacementCentersWithoutCodexWindow() { + let frame = MainWindowInitialPlacementPolicy.initialFrame( + in: NSRect(x: 200, y: 100, width: 1_440, height: 900), + codexFrame: nil + ) + XCTAssertEqual(frame, NSRect(x: 711, y: 226, width: 418, height: 648)) + } + + func testDisplaySelectionUsesLargestQuartzIntersection() { + let left = MainWindowDisplayGeometry( + appKitFrame: NSRect(x: 0, y: 0, width: 1_440, height: 900), + visibleFrame: NSRect(x: 0, y: 0, width: 1_440, height: 875), + quartzFrame: CGRect(x: 0, y: 0, width: 1_440, height: 900) + ) + let right = MainWindowDisplayGeometry( + appKitFrame: NSRect(x: 1_440, y: 0, width: 2_560, height: 1_440), + visibleFrame: NSRect(x: 1_440, y: 0, width: 2_560, height: 1_409), + quartzFrame: CGRect(x: 1_440, y: 0, width: 2_560, height: 1_440) + ) + + XCTAssertEqual( + MainWindowInitialPlacementPolicy.display( + containingQuartzBounds: CGRect( + x: 1_200, + y: 100, + width: 1_000, + height: 700 + ), + among: [left, right] + ), + right + ) + } + + func testQuartzBoundsConvertToVerticallyStackedAppKitDisplay() { + let upperDisplay = MainWindowDisplayGeometry( + appKitFrame: NSRect(x: 0, y: 900, width: 1_920, height: 1_200), + visibleFrame: NSRect(x: 0, y: 900, width: 1_920, height: 1_175), + quartzFrame: CGRect(x: 0, y: -1_200, width: 1_920, height: 1_200) + ) + let converted = MainWindowInitialPlacementPolicy.appKitFrame( + forQuartzBounds: CGRect(x: 100, y: -1_100, width: 800, height: 700), + on: upperDisplay + ) + + XCTAssertEqual(converted, NSRect(x: 100, y: 1_300, width: 800, height: 700)) + } +} diff --git a/Tests/CodexNotesProbeTests/SettingsAboutPresentationTests.swift b/Tests/CodexNotesProbeTests/SettingsAboutPresentationTests.swift new file mode 100644 index 0000000..bf5e3e6 --- /dev/null +++ b/Tests/CodexNotesProbeTests/SettingsAboutPresentationTests.swift @@ -0,0 +1,231 @@ +import AppKit +import CryptoKit +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class SettingsAboutPresentationTests: XCTestCase { + func testPublicLinksUseTheConfirmedDestinations() { + XCTAssertEqual( + SettingsAboutPresentation.repositoryURL.absoluteString, + "https://github.com/jiangsir-tech/CodexNotes" + ) + XCTAssertEqual( + SettingsAboutPresentation.xProfileURL.absoluteString, + "https://x.com/YongJiang_Li_" + ) + XCTAssertEqual( + SettingsAboutPresentation.feedbackEmailAddress, + "li-yongjiang@foxmail.com" + ) + XCTAssertEqual( + SettingsAboutPresentation.feedbackEmailURL.absoluteString, + "mailto:li-yongjiang@foxmail.com" + ) + XCTAssertTrue( + SettingsAboutPresentation.isAllowedProjectURL( + SettingsAboutPresentation.repositoryURL + ) + ) + XCTAssertTrue( + SettingsAboutPresentation.isAllowedXProfileURL( + SettingsAboutPresentation.xProfileURL + ) + ) + XCTAssertTrue( + SettingsAboutPresentation.isAllowedFeedbackEmailURL( + SettingsAboutPresentation.feedbackEmailURL + ) + ) + } + + func testVisibleVersionOmitsTheInternalBuildNumber() { + let version = AppBundleVersion(version: "1.4.68", build: "88") + + XCTAssertEqual(SettingsAboutPresentation.visibleVersion(version), "1.4.68") + XCTAssertFalse(SettingsAboutPresentation.visibleVersion(version).contains("88")) + XCTAssertEqual(version.build, "88") + } + + func testFeedbackEmailCopiesTheExactPublicAddress() { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("SettingsAboutPresentationTests.\(UUID().uuidString)") + ) + defer { pasteboard.releaseGlobally() } + + XCTAssertTrue(SettingsAboutPresentation.copyFeedbackEmail(to: pasteboard)) + XCTAssertEqual( + pasteboard.string(forType: .string), + SettingsAboutPresentation.feedbackEmailAddress + ) + XCTAssertEqual( + SettingsAboutPresentation.copyConfirmationDurationNanoseconds, + 1_500_000_000 + ) + } + + func testAboutSourceUsesTheSharedCoordinatorAndAccessibleAutomaticToggle() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/AboutCodexNotesView.swift" + ) + + XCTAssertTrue(source.contains( + "@ObservedObject private var updateCoordinator: UpdateCheckCoordinator" + )) + XCTAssertTrue(source.contains("updateCoordinator.manualCheck()")) + XCTAssertTrue(source.contains("updateCoordinator.setAutomaticChecksEnabled($0)")) + XCTAssertTrue(source.contains(".settingsAboutAutomaticCheck")) + XCTAssertTrue(source.contains(".settingsAboutAutomaticCheckDescription")) + XCTAssertTrue(source.contains(".toggleStyle(.switch)")) + XCTAssertTrue(source.contains( + ".accessibilityLabel(Text(L10n.text(.settingsAboutAutomaticCheck)))" + )) + XCTAssertTrue(source.contains( + ".accessibilityValue(Text(L10n.text(" + )) + XCTAssertTrue(source.contains( + ".accessibilityHint(Text(L10n.text(.settingsAboutAutomaticCheckDescription)))" + )) + XCTAssertFalse(source.contains("@StateObject private var updateChecker")) + XCTAssertFalse(source.contains("updateChecker.cancel()")) + } + + func testSettingsSourceRequiresAndForwardsTheSharedCoordinator() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/SettingsView.swift" + ) + + XCTAssertTrue(source.contains( + "init(\n updateCoordinator: UpdateCheckCoordinator,\n" + + " globalHotKeyController: GlobalHotKeyController\n )" + )) + XCTAssertTrue(source.contains( + "@ObservedObject private var updateCoordinator: UpdateCheckCoordinator" + )) + XCTAssertTrue(source.contains( + "@ObservedObject private var globalHotKeyController: GlobalHotKeyController" + )) + XCTAssertTrue(source.contains("updateCoordinator: updateCoordinator")) + XCTAssertFalse(source.contains("AboutCodexNotesView(\n palette: palette,\n languageRevision: languageRevision\n")) + } + + func testSettingsCardsFollowTheRequestedInformationHierarchy() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/SettingsView.swift" + ) + let titleKeys = [ + "settingsGeneralTitle", + "settingsEditorTitle", + "settingsAppearanceTitle", + "settingsStatusBarIconTitle", + "settingsAboutTitle" + ] + let positions = try titleKeys.map { titleKey in + try XCTUnwrap( + source.range( + of: "settingsPanel(title: L10n.text(.\(titleKey)))" + )?.lowerBound + ) + } + + XCTAssertEqual(positions, positions.sorted()) + } + + func testExternalLinksProvideHoverFocusAndCursorFeedback() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/AboutCodexNotesView.swift" + ) + + XCTAssertTrue(source.contains("private struct AboutLinkButton: View")) + XCTAssertTrue(source.contains("@State private var isHovered = false")) + XCTAssertTrue(source.contains("@FocusState private var isFocused: Bool")) + XCTAssertTrue(source.contains("trailingSystemImage: \"arrow.up.right\"")) + XCTAssertTrue(source.contains(".accessibilityHidden(true)")) + XCTAssertTrue(source.contains("PointingHandCursorRegion()")) + XCTAssertTrue(source.contains("addCursorRect(bounds, cursor: .pointingHand)")) + XCTAssertTrue(source.contains("isHovered || isFocused")) + XCTAssertTrue(source.contains(".frame(minHeight: 30)")) + XCTAssertTrue(source.contains(".buttonStyle(.plain)")) + XCTAssertFalse(source.contains("private func externalLinkButton(")) + XCTAssertFalse(source.contains(".buttonStyle(.link)\n .fixedSize")) + XCTAssertFalse(source.contains("NSCursor.push")) + XCTAssertFalse(source.contains("NSCursor.pop")) + } + + func testFeedbackEmailKeepsMailAndCopyAsSeparateAccessibleActions() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/AboutCodexNotesView.swift" + ) + + XCTAssertTrue(source.contains("private struct CopyFeedbackEmailButton: View")) + XCTAssertTrue(source.contains( + "private var feedbackEmailRow: some View {\n HStack(alignment: .center, spacing: 4)" + )) + XCTAssertTrue(source.contains("trailingSystemImage: nil")) + XCTAssertTrue(source.contains("Image(systemName: isCopied ? \"checkmark\" : \"doc.on.doc\")")) + XCTAssertTrue(source.contains("SettingsAboutPresentation.copyFeedbackEmail()")) + XCTAssertTrue(source.contains(".task(id: emailCopyNoticeID)")) + XCTAssertTrue(source.contains("emailCopyNoticeID == noticeID")) + XCTAssertTrue(source.contains(".settingsAboutCopyEmailAccessibilityHint")) + XCTAssertTrue(source.contains(".settingsAboutEmailCopied")) + } + + func testPublicLinkAllowListsRejectLookalikesAndInsecureSchemes() throws { + XCTAssertFalse(SettingsAboutPresentation.isAllowedProjectURL(try url( + "http://github.com/jiangsir-tech/CodexNotes" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedProjectURL(try url( + "https://github.com.example.com/jiangsir-tech/CodexNotes" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedProjectURL(try url( + "https://github.com/jiangsir-tech/AnotherProject" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedXProfileURL(try url( + "http://x.com/YongJiang_Li_" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedXProfileURL(try url( + "https://x.com.example.com/YongJiang_Li_" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedXProfileURL(try url( + "https://x.com/SomeoneElse" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedFeedbackEmailURL(try url( + "mailto:someone@example.com" + ))) + XCTAssertFalse(SettingsAboutPresentation.isAllowedFeedbackEmailURL(try url( + "https://example.com/li-yongjiang@foxmail.com" + ))) + } + + func testRewardCodeResourceIsTheReviewedImage() throws { + let resourceURL = try XCTUnwrap(SettingsAboutPresentation.rewardCodeURL()) + let data = try Data(contentsOf: resourceURL) + let digest = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + let image = try XCTUnwrap(SettingsAboutPresentation.rewardCodeImage()) + + XCTAssertEqual( + digest, + "7d3cb0fae45d7b5cc44ed5a092edac8305cbaace0c190ed7a7803ee73aae578f" + ) + XCTAssertEqual(image.size.width, 1_152, accuracy: 0.5) + XCTAssertEqual(image.size.height, 1_152, accuracy: 0.5) + XCTAssertFalse(image.representations.isEmpty) + } + + private func url(_ value: String) throws -> URL { + try XCTUnwrap(URL(string: value)) + } + + private func repositorySource(at relativePath: String) throws -> String { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } +} diff --git a/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift b/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift new file mode 100644 index 0000000..3aa76f1 --- /dev/null +++ b/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift @@ -0,0 +1,36 @@ +import XCTest + +final class SettingsEditorPresentationTests: XCTestCase { + func testResizeHintUsesAnAdaptiveAccessibleFooter() throws { + let source = try repositorySource( + at: "Sources/CodexNotesProbe/SettingsView.swift" + ) + + XCTAssertTrue(source.contains("ViewThatFits(in: .horizontal)")) + XCTAssertTrue(source.contains(".lineLimit(1)")) + XCTAssertTrue(source.contains( + ".fixedSize(horizontal: true, vertical: false)" + )) + XCTAssertTrue(source.contains("VStack(alignment: .leading, spacing: 8)")) + XCTAssertTrue(source.contains(".settingsEditorWindowResizeHint")) + XCTAssertTrue(source.contains( + "Image(systemName: \"arrow.up.left.and.arrow.down.right\")" + )) + XCTAssertTrue(source.contains(".accessibilityElement(children: .combine)")) + + let hintRange = try XCTUnwrap(source.range(of: "editorWindowResizeHint")) + let restoreRange = try XCTUnwrap(source.range(of: "editorRestoreDefaultsButton")) + XCTAssertLessThan(hintRange.lowerBound, restoreRange.lowerBound) + } + + private func repositorySource(at relativePath: String) throws -> String { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } +} diff --git a/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift b/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift new file mode 100644 index 0000000..491c12c --- /dev/null +++ b/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift @@ -0,0 +1,54 @@ +import AppKit +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class SettingsWindowActivatorTests: XCTestCase { + func testReattachingVisibleSettingsWindowPublishesVisibleAgain() { + let window = NSWindow( + contentRect: NSRect(x: 120, y: 120, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + let coordinator = SettingsWindowActivator.Coordinator() + var visibilityChanges: [Bool] = [] + let token = NotificationCenter.default.addObserver( + forName: SettingsWindowVisibilityNotification.name, + object: nil, + queue: .main + ) { notification in + guard let isVisible = notification.userInfo?[ + SettingsWindowVisibilityNotification.isVisibleKey + ] as? Bool else { return } + visibilityChanges.append(isVisible) + } + defer { + NotificationCenter.default.removeObserver(token) + coordinator.invalidate() + window.orderOut(nil) + } + + coordinator.attach( + to: window, + appearanceName: nil, + backgroundColor: .windowBackgroundColor + ) + XCTAssertEqual(visibilityChanges.last, true) + + window.close() + XCTAssertEqual(visibilityChanges.last, false) + + window.orderFrontRegardless() + coordinator.attach( + to: window, + appearanceName: nil, + backgroundColor: .windowBackgroundColor + ) + + XCTAssertTrue(window.isVisible) + XCTAssertEqual(visibilityChanges.last, true) + } +} diff --git a/Tests/CodexNotesProbeTests/ShortcutReferencePanelTests.swift b/Tests/CodexNotesProbeTests/ShortcutReferencePanelTests.swift index 3d791d5..4dde1ea 100644 --- a/Tests/CodexNotesProbeTests/ShortcutReferencePanelTests.swift +++ b/Tests/CodexNotesProbeTests/ShortcutReferencePanelTests.swift @@ -6,8 +6,10 @@ import XCTest @MainActor final class ShortcutReferencePanelTests: XCTestCase { - func testStandardReferenceContainsExactlyTheFiveApprovedShortcuts() { - let presentation = ShortcutReferencePresentation.standard + func testStandardReferenceContainsGlobalShortcutBeforeTheFiveEditorShortcuts() { + let presentation = ShortcutReferencePresentation.standard( + globalHotKeyDisplayName: "⌃⇧Space" + ) XCTAssertEqual(presentation.title, L10n.text(.shortcutsTitle)) XCTAssertEqual( @@ -17,14 +19,27 @@ final class ShortcutReferencePanelTests: XCTestCase { XCTAssertEqual( presentation.sections.map(\.title), [ + L10n.text(.shortcutsSectionGlobal), L10n.text(.shortcutsSectionTodos), L10n.text(.shortcutsSectionNotes), L10n.text(.shortcutsSectionFormatting), ] ) + XCTAssertEqual( + presentation.sections.first?.note, + L10n.text(.shortcutsGlobalAvailabilityNote) + ) + XCTAssertTrue( + presentation.sections.dropFirst().allSatisfy { $0.note == nil } + ) XCTAssertEqual( presentation.items, [ + ShortcutReferenceItem( + id: .toggleCodexNotes, + title: L10n.text(.shortcutsItemToggleCodexNotes), + shortcut: "⌃⇧Space" + ), ShortcutReferenceItem( id: .cycleTodo, title: L10n.text(.shortcutsItemToggleCurrentLineTodo), @@ -52,7 +67,40 @@ final class ShortcutReferencePanelTests: XCTestCase { ), ] ) - XCTAssertEqual(presentation.items.count, 5) + XCTAssertEqual(presentation.items.count, 6) + } + + func testGlobalShortcutReferenceUsesLocalizedNotSetFallback() { + for displayName in [nil, "", " \n"] as [String?] { + let presentation = ShortcutReferencePresentation.standard( + globalHotKeyDisplayName: displayName + ) + + XCTAssertEqual( + presentation.items.first, + ShortcutReferenceItem( + id: .toggleCodexNotes, + title: L10n.text(.shortcutsItemToggleCodexNotes), + shortcut: L10n.text(.globalHotKeyNotSet) + ) + ) + } + } + + func testGlobalShortcutReferenceRebuildsWithCurrentDisplayName() { + let original = ShortcutReferencePresentation.standard( + globalHotKeyDisplayName: "⌃⇧Space" + ) + let changed = ShortcutReferencePresentation.standard( + globalHotKeyDisplayName: "⌃⌥N" + ) + + XCTAssertEqual(original.items.first?.shortcut, "⌃⇧Space") + XCTAssertEqual(changed.items.first?.shortcut, "⌃⌥N") + XCTAssertEqual( + Array(original.items.dropFirst()), + Array(changed.items.dropFirst()) + ) } func testReferenceRowsArePureValuesWithoutActionsOrDisabledState() { diff --git a/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift b/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift index e0da3b7..235c876 100644 --- a/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift +++ b/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift @@ -52,6 +52,16 @@ final class StatusItemControllerTests: XCTestCase { ) } + func testVisibleSettingsAlwaysSwitchesBackToMainWindow() { + XCTAssertEqual( + action( + isWindowVisible: true, + isSettingsVisible: true + ), + .show + ) + } + func testManualHideOverridesAutomaticVisibilityUntilShowResumesFollowing() { var state = MainWindowVisibilityState() XCTAssertEqual(state.preference, .automatic) @@ -368,12 +378,14 @@ final class StatusItemControllerTests: XCTestCase { private func action( isApplicationHidden: Bool = false, isWindowVisible: Bool, - isWindowMiniaturized: Bool = false + isWindowMiniaturized: Bool = false, + isSettingsVisible: Bool = false ) -> MainWindowToggleAction { MainWindowTogglePolicy.action( isApplicationHidden: isApplicationHidden, isWindowVisible: isWindowVisible, - isWindowMiniaturized: isWindowMiniaturized + isWindowMiniaturized: isWindowMiniaturized, + isSettingsVisible: isSettingsVisible ) } } diff --git a/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift b/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift new file mode 100644 index 0000000..1fe5ba5 --- /dev/null +++ b/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift @@ -0,0 +1,1083 @@ +import AppKit +import Foundation +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class UpdateCheckCoordinatorTests: XCTestCase { + func testAutomaticChecksDefaultToOffAndStartDoesNotScheduleWork() { + let fixture = makeFixture() + defer { fixture.remove() } + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: clock, + scheduler: scheduler + ) + + XCTAssertFalse(coordinator.isAutomaticCheckEnabled) + + coordinator.start() + + XCTAssertTrue(scheduler.activeDelays.isEmpty) + XCTAssertFalse( + fixture.defaults.bool(forKey: UpdateCheckPreferenceKey.automaticChecksEnabled) + ) + coordinator.stop() + } + + func testStartingWhenDueUsesTenSecondLaunchDelayAndRecordsRollingDay() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: scheduler + ) + + coordinator.start() + + XCTAssertEqual(scheduler.activeDelays, [10]) + clock.now = referenceDate.addingTimeInterval(10) + scheduler.runNext() + await waitUntil { coordinator.state == .upToDate } + + let fetchCallCount = await fetcher.callCount + XCTAssertEqual(fetchCallCount, 1) + XCTAssertEqual( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastAttemptAt) as? Date, + referenceDate.addingTimeInterval(10) + ) + XCTAssertEqual( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) as? Date, + referenceDate.addingTimeInterval(10) + ) + XCTAssertEqual( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) as? Date, + referenceDate.addingTimeInterval(10 + 24 * 60 * 60) + ) + XCTAssertEqual(scheduler.activeDelays, [24 * 60 * 60]) + coordinator.stop() + } + + func testEnablingAutomaticChecksSchedulesAnImmediatelyDueBackgroundCheck() async { + let fixture = makeFixture() + defer { fixture.remove() } + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: scheduler + ) + coordinator.start() + + coordinator.setAutomaticChecksEnabled(true) + + XCTAssertTrue(coordinator.isAutomaticCheckEnabled) + XCTAssertTrue( + fixture.defaults.bool(forKey: UpdateCheckPreferenceKey.automaticChecksEnabled) + ) + XCTAssertEqual(scheduler.activeDelays, [0]) + scheduler.runNext() + await waitUntil { coordinator.state == .upToDate } + let fetchCallCount = await fetcher.callCount + XCTAssertEqual(fetchCallCount, 1) + coordinator.stop() + } + + func testManualSuccessResetsFailureCountAndTwentyFourHourSchedule() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set(3, forKey: UpdateCheckPreferenceKey.consecutiveFailureCount) + fixture.defaults.set( + referenceDate.addingTimeInterval(-1), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + let clock = TestClock(now: referenceDate) + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: clock, + scheduler: TestUpdateScheduler() + ) + + await coordinator.manualCheck() + + XCTAssertEqual(coordinator.state, .upToDate) + XCTAssertEqual( + fixture.defaults.integer( + forKey: UpdateCheckPreferenceKey.consecutiveFailureCount + ), + 0 + ) + XCTAssertEqual( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) as? Date, + referenceDate.addingTimeInterval(24 * 60 * 60) + ) + } + + func testFailuresUseOneThreeSixAndTwentyFourHourPersistentBackoff() async { + let fixture = makeFixture() + defer { fixture.remove() } + let clock = TestClock(now: referenceDate) + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .failure, + .failure, + .failure, + .failure, + .failure + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: TestUpdateScheduler() + ) + let expectedIntervals: [TimeInterval] = [ + 60 * 60, + 3 * 60 * 60, + 6 * 60 * 60, + 24 * 60 * 60, + 24 * 60 * 60 + ] + + for (index, interval) in expectedIntervals.enumerated() { + clock.now = referenceDate.addingTimeInterval(TimeInterval(index * 100)) + await coordinator.manualCheck() + XCTAssertEqual(coordinator.state, .failed) + XCTAssertEqual( + fixture.defaults.integer( + forKey: UpdateCheckPreferenceKey.consecutiveFailureCount + ), + index + 1 + ) + XCTAssertEqual( + fixture.defaults.object( + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) as? Date, + clock.now.addingTimeInterval(interval) + ) + } + } + + func testAutomaticFailureIsSilentAndDoesNotReplaceCachedUpdate() async { + let fixture = makeFixture() + defer { fixture.remove() } + seedCachedUpdate(in: fixture.defaults, version: "1.5.0") + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + fixture.defaults.set( + referenceDate.addingTimeInterval(-1), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + let clock = TestClock(now: referenceDate) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { _ in + throw TestFetchError.failed + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: TestUpdateScheduler() + ) + let expectedUpdate = AvailableAppUpdate(version: "1.5.0", url: releaseURL("1.5.0")) + + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + await coordinator.checkIfDue() + + XCTAssertEqual(coordinator.availableUpdate, expectedUpdate) + XCTAssertEqual(coordinator.bannerUpdate, expectedUpdate) + XCTAssertEqual( + coordinator.state, + .updateAvailable(version: "1.5.0", url: releaseURL("1.5.0")) + ) + XCTAssertEqual( + fixture.defaults.integer( + forKey: UpdateCheckPreferenceKey.consecutiveFailureCount + ), + 1 + ) + } + + func testCacheIsRejectedForUnsafeURLOrVersionNotNewerThanInstalled() { + let unsafeFixture = makeFixture() + defer { unsafeFixture.remove() } + unsafeFixture.defaults.set( + "1.5.0", + forKey: UpdateCheckPreferenceKey.availableVersion + ) + unsafeFixture.defaults.set( + "https://github.com.evil.example/jiangsir-tech/CodexNotes/releases/tag/v1.5.0", + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + + let unsafeCoordinator = makeCoordinator( + defaults: unsafeFixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: TestClock(now: referenceDate), + scheduler: TestUpdateScheduler() + ) + + XCTAssertNil(unsafeCoordinator.availableUpdate) + XCTAssertNil( + unsafeFixture.defaults.object( + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + ) + + let staleFixture = makeFixture() + defer { staleFixture.remove() } + seedCachedUpdate(in: staleFixture.defaults, version: "1.5.0") + let staleCoordinator = makeCoordinator( + defaults: staleFixture.defaults, + checker: successfulChecker(tag: "1.5.0", currentVersion: "1.5.0"), + installedVersion: "1.5.0", + clock: TestClock(now: referenceDate), + scheduler: TestUpdateScheduler() + ) + + XCTAssertNil(staleCoordinator.availableUpdate) + XCTAssertEqual(staleCoordinator.state, .idle) + XCTAssertNil( + staleFixture.defaults.object(forKey: UpdateCheckPreferenceKey.availableVersion) + ) + } + + func testCacheRejectsNonTagWrongTagAndNonExactReleaseURLs() { + let invalidURLs = [ + "https://github.com/jiangsir-tech/CodexNotes/releases/download/v1.5.0/CodexNotes.zip", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v9.9.9", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0/extra", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0?download=1", + "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v1.5.0#notes" + ] + + for invalidURL in invalidURLs { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + "1.5.0", + forKey: UpdateCheckPreferenceKey.availableVersion + ) + fixture.defaults.set( + invalidURL, + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: TestClock(now: referenceDate), + scheduler: TestUpdateScheduler() + ) + + XCTAssertNil(coordinator.availableUpdate, "Accepted \(invalidURL)") + XCTAssertNil( + fixture.defaults.object( + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + ) + } + } + + func testBannerDismissalPersistsPerVersionAndNewVersionAppearsAgain() async { + let fixture = makeFixture() + defer { fixture.remove() } + seedCachedUpdate(in: fixture.defaults, version: "1.5.0") + fixture.defaults.set( + "1.5.0", + forKey: UpdateCheckPreferenceKey.dismissedBannerVersion + ) + let clock = TestClock(now: referenceDate) + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.6.0"), + clock: clock, + scheduler: TestUpdateScheduler() + ) + + XCTAssertNil(coordinator.bannerUpdate) + await coordinator.manualCheck() + + let newUpdate = AvailableAppUpdate(version: "1.6.0", url: releaseURL("1.6.0")) + XCTAssertEqual(coordinator.availableUpdate, newUpdate) + XCTAssertEqual(coordinator.bannerUpdate, newUpdate) + + coordinator.dismissBanner() + + XCTAssertNil(coordinator.bannerUpdate) + XCTAssertEqual( + fixture.defaults.string( + forKey: UpdateCheckPreferenceKey.dismissedBannerVersion + ), + "1.6.0" + ) + + let restoredCoordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.6.0"), + clock: clock, + scheduler: TestUpdateScheduler() + ) + XCTAssertEqual(restoredCoordinator.availableUpdate, newUpdate) + XCTAssertNil(restoredCoordinator.bannerUpdate) + } + + func testUpToDateResultClearsCachedUpdateAndBanner() async { + let fixture = makeFixture() + defer { fixture.remove() } + seedCachedUpdate(in: fixture.defaults, version: "1.5.0") + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: TestClock(now: referenceDate), + scheduler: TestUpdateScheduler() + ) + + await coordinator.manualCheck() + + XCTAssertEqual(coordinator.state, .upToDate) + XCTAssertNil(coordinator.availableUpdate) + XCTAssertNil(coordinator.bannerUpdate) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.availableVersion) + ) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.availableReleaseURL) + ) + } + + func testManualCheckJoinsAutomaticRequestAndPublishesItsResult() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let suspendedFetcher = SuspendedCoordinatorFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await suspendedFetcher.fetch(request) + } + let clock = TestClock(now: referenceDate) + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: TestUpdateScheduler() + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + let automaticCheck = Task { await coordinator.checkIfDue() } + await waitUntil { await suspendedFetcher.callCount == 1 } + XCTAssertEqual(coordinator.state, .idle) + + let manualCheck = Task { await coordinator.manualCheck() } + await waitUntil { coordinator.state == .checking } + let joinedCallCount = await suspendedFetcher.callCount + XCTAssertEqual(joinedCallCount, 1) + + await suspendedFetcher.resume() + await automaticCheck.value + await manualCheck.value + + let completedCallCount = await suspendedFetcher.callCount + XCTAssertEqual(completedCallCount, 1) + XCTAssertEqual( + coordinator.state, + .updateAvailable(version: "1.5.0", url: releaseURL("1.5.0")) + ) + XCTAssertNotNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) + ) + } + + func testJoinerFinishesFirstWithoutReleasingSingleFlightOwnership() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let fetcher = SuspendedCoordinatorFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let clock = TestClock(now: referenceDate) + let barrier = InitiatorOnlyResultDeliveryBarrier() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: TestUpdateScheduler(), + resultDeliveryBarrier: barrier.wait + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + let initiator = Task { await coordinator.checkIfDue() } + await waitUntil { await fetcher.callCount == 1 } + let manualJoiner = Task { await coordinator.manualCheck() } + await waitUntil { coordinator.state == .checking } + + await fetcher.resume() + await manualJoiner.value + + XCTAssertEqual( + coordinator.state, + .updateAvailable(version: "1.5.0", url: releaseURL("1.5.0")) + ) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) + ) + + await coordinator.checkIfDue() + let callCountBeforeInitiatorDelivery = await fetcher.callCount + XCTAssertEqual(callCountBeforeInitiatorDelivery, 1) + XCTAssertGreaterThanOrEqual(barrier.joinedDeliveryCount, 2) + + barrier.resumeInitiator() + await initiator.value + + let finalCallCount = await fetcher.callCount + XCTAssertEqual(finalCallCount, 1) + XCTAssertNotNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) + ) + XCTAssertNotNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) + ) + XCTAssertEqual(coordinator.availableUpdate?.version, "1.5.0") + coordinator.stop() + } + + func testStoppingCancelsAutomaticRequestWithoutRecordingFailure() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let suspendedFetcher = SuspendedCoordinatorFetcher( + result: ( + releaseData(tag: "1.5.0"), + httpResponse(statusCode: 200) + ) + ) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await suspendedFetcher.fetch(request) + } + let clock = TestClock(now: referenceDate) + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: TestUpdateScheduler() + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + let automaticCheck = Task { await coordinator.checkIfDue() } + await waitUntil { await suspendedFetcher.callCount == 1 } + coordinator.stop() + await suspendedFetcher.resume() + await automaticCheck.value + + XCTAssertEqual( + fixture.defaults.integer( + forKey: UpdateCheckPreferenceKey.consecutiveFailureCount + ), + 0 + ) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) + ) + XCTAssertEqual(coordinator.state, .idle) + } + + func testCompletedAutomaticResultIsDiscardedWhenStopWinsDeliveryRace() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let barrier = TestResultDeliveryBarrier() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.5.0"), + clock: clock, + scheduler: TestUpdateScheduler(), + resultDeliveryBarrier: barrier.wait + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + let automaticCheck = Task { await coordinator.checkIfDue() } + await waitUntil { barrier.entryCount == 1 } + coordinator.stop() + barrier.resume() + await automaticCheck.value + + XCTAssertEqual(coordinator.state, .idle) + XCTAssertNil(coordinator.availableUpdate) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) + ) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) + ) + } + + func testCompletedAutomaticResultIsDiscardedWhenDisableWinsDeliveryRace() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let barrier = TestResultDeliveryBarrier() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.5.0"), + clock: clock, + scheduler: TestUpdateScheduler(), + resultDeliveryBarrier: barrier.wait + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + let automaticCheck = Task { await coordinator.checkIfDue() } + await waitUntil { barrier.entryCount == 1 } + coordinator.setAutomaticChecksEnabled(false) + barrier.resume() + await automaticCheck.value + + XCTAssertFalse(coordinator.isAutomaticCheckEnabled) + XCTAssertEqual(coordinator.state, .idle) + XCTAssertNil(coordinator.availableUpdate) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastSuccessAt) + ) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) + ) + coordinator.stop() + } + + func testActivationAndWakeRescheduleAnOverdueCheckImmediately() { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + fixture.defaults.set( + referenceDate.addingTimeInterval(100), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let appCenter = NotificationCenter() + let workspaceCenter = NotificationCenter() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: clock, + scheduler: scheduler, + applicationNotificationCenter: appCenter, + workspaceNotificationCenter: workspaceCenter + ) + coordinator.start() + XCTAssertEqual(scheduler.activeDelays, [100]) + + clock.now = referenceDate.addingTimeInterval(101) + appCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [0]) + + workspaceCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [0]) + coordinator.stop() + } + + func testActivationAndWakeCannotBypassStartupGraceDeadline() { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let appCenter = NotificationCenter() + let workspaceCenter = NotificationCenter() + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: successfulChecker(tag: "1.4.68"), + clock: clock, + scheduler: scheduler, + applicationNotificationCenter: appCenter, + workspaceNotificationCenter: workspaceCenter + ) + + coordinator.start() + XCTAssertEqual(scheduler.activeDelays, [10]) + + appCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + workspaceCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [10]) + + clock.now = referenceDate.addingTimeInterval(4) + appCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [6]) + coordinator.stop() + } + + func testForwardWallClockJumpCannotBypassMonotonicStartupGrace() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let monotonicClock = TestMonotonicClock(now: 500) + let scheduler = TestUpdateScheduler() + let appCenter = NotificationCenter() + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + monotonicClock: monotonicClock, + scheduler: scheduler, + applicationNotificationCenter: appCenter + ) + coordinator.start() + + clock.now = referenceDate.addingTimeInterval(365 * 24 * 60 * 60) + monotonicClock.now = 502 + appCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + + XCTAssertEqual(scheduler.activeDelays, [8]) + await coordinator.checkIfDue() + let callCount = await fetcher.callCount + XCTAssertEqual(callCount, 0) + XCTAssertEqual(scheduler.activeDelays, [8]) + coordinator.stop() + } + + func testBackwardWallClockJumpCannotExtendMonotonicStartupGrace() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let monotonicClock = TestMonotonicClock(now: 800) + let scheduler = TestUpdateScheduler() + let workspaceCenter = NotificationCenter() + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + monotonicClock: monotonicClock, + scheduler: scheduler, + workspaceNotificationCenter: workspaceCenter + ) + coordinator.start() + + clock.now = referenceDate.addingTimeInterval(-365 * 24 * 60 * 60) + monotonicClock.now = 804 + workspaceCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [6]) + + monotonicClock.now = 810 + workspaceCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + XCTAssertEqual(scheduler.activeDelays, [0]) + scheduler.runNext() + await waitUntil { coordinator.state == .upToDate } + + let callCount = await fetcher.callCount + XCTAssertEqual(callCount, 1) + coordinator.stop() + } + + func testTimerFiringThenStopPreventsQueuedAutomaticTaskFromRequesting() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + let clock = TestClock(now: referenceDate) + let scheduler = TestUpdateScheduler() + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: clock, + scheduler: scheduler + ) + coordinator.start() + clock.now = referenceDate.addingTimeInterval(10) + + scheduler.runNext() + coordinator.stop() + for _ in 0..<5 { + await Task.yield() + } + + let callCount = await fetcher.callCount + XCTAssertEqual(callCount, 0) + XCTAssertNil( + fixture.defaults.object(forKey: UpdateCheckPreferenceKey.lastAttemptAt) + ) + } + + func testCheckBeforeEligibilityDoesNotIssueRequest() async { + let fixture = makeFixture() + defer { fixture.remove() } + fixture.defaults.set( + true, + forKey: UpdateCheckPreferenceKey.automaticChecksEnabled + ) + fixture.defaults.set( + referenceDate.addingTimeInterval(3_600), + forKey: UpdateCheckPreferenceKey.nextEligibleAt + ) + let fetcher = ScriptedUpdateFetcher(outcomes: [ + .response(releaseData(tag: "1.4.68"), httpResponse(statusCode: 200)) + ]) + let checker = AppUpdateChecker(currentVersion: "1.4.68") { request in + try await fetcher.fetch(request) + } + let coordinator = makeCoordinator( + defaults: fixture.defaults, + checker: checker, + clock: TestClock(now: referenceDate), + scheduler: TestUpdateScheduler() + ) + coordinator.start() + + await coordinator.checkIfDue() + + let fetchCallCount = await fetcher.callCount + XCTAssertEqual(fetchCallCount, 0) + XCTAssertEqual(coordinator.state, .idle) + } + + private var referenceDate: Date { + Date(timeIntervalSince1970: 1_800_000_000) + } + + private func makeCoordinator( + defaults: UserDefaults, + checker: AppUpdateChecker, + installedVersion: String = "1.4.68", + clock: TestClock, + monotonicClock: TestMonotonicClock? = nil, + scheduler: TestUpdateScheduler, + applicationNotificationCenter: NotificationCenter = NotificationCenter(), + workspaceNotificationCenter: NotificationCenter = NotificationCenter(), + resultDeliveryBarrier: @escaping UpdateCheckCoordinator.ResultDeliveryBarrier = { _ in } + ) -> UpdateCheckCoordinator { + UpdateCheckCoordinator( + defaults: defaults, + checker: checker, + installedVersion: installedVersion, + clock: { clock.now }, + monotonicClock: { + monotonicClock?.now ?? clock.now.timeIntervalSinceReferenceDate + }, + scheduler: scheduler.schedule, + applicationNotificationCenter: applicationNotificationCenter, + workspaceNotificationCenter: workspaceNotificationCenter, + resultDeliveryBarrier: resultDeliveryBarrier + ) + } + + private func successfulChecker( + tag: String, + currentVersion: String = "1.4.68" + ) -> AppUpdateChecker { + let data = releaseData(tag: tag) + let response = httpResponse(statusCode: 200) + return AppUpdateChecker(currentVersion: currentVersion) { _ in + (data, response) + } + } + + private func seedCachedUpdate(in defaults: UserDefaults, version: String) { + defaults.set(version, forKey: UpdateCheckPreferenceKey.availableVersion) + defaults.set( + releaseURL(version).absoluteString, + forKey: UpdateCheckPreferenceKey.availableReleaseURL + ) + } + + private func releaseURL(_ version: String) -> URL { + URL( + string: "https://github.com/jiangsir-tech/CodexNotes/releases/tag/v\(version)" + )! + } + + private func releaseData(tag: String) -> Data { + let normalizedVersion = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + return try! JSONSerialization.data(withJSONObject: [ + "tag_name": tag, + "html_url": releaseURL(normalizedVersion).absoluteString, + "draft": false, + "prerelease": false + ]) + } + + private func httpResponse(statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: AppUpdateChecker.latestReleaseAPIURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } + + private func makeFixture() -> DefaultsFixture { + DefaultsFixture() + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 1_000_000_000, + _ predicate: @escaping @MainActor () async -> Bool + ) async { + let startedAt = DispatchTime.now().uptimeNanoseconds + while !(await predicate()) { + if DispatchTime.now().uptimeNanoseconds - startedAt >= timeoutNanoseconds { + XCTFail("Timed out waiting for asynchronous coordinator state") + return + } + try? await Task.sleep(nanoseconds: 1_000_000) + } + } +} + +private final class DefaultsFixture { + let defaults: UserDefaults + private let suiteName: String + + init() { + suiteName = "UpdateCheckCoordinatorTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + func remove() { + defaults.removePersistentDomain(forName: suiteName) + } +} + +@MainActor +private final class TestClock { + var now: Date + + init(now: Date) { + self.now = now + } +} + +@MainActor +private final class TestMonotonicClock { + var now: TimeInterval + + init(now: TimeInterval) { + self.now = now + } +} + +@MainActor +private final class TestResultDeliveryBarrier { + private var continuation: CheckedContinuation? + private(set) var entryCount = 0 + + func wait(initiatedRequest: Bool) async { + entryCount += 1 + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + +@MainActor +private final class InitiatorOnlyResultDeliveryBarrier { + private var initiatorContinuation: CheckedContinuation? + private(set) var joinedDeliveryCount = 0 + + func wait(initiatedRequest: Bool) async { + guard initiatedRequest else { + joinedDeliveryCount += 1 + return + } + await withCheckedContinuation { continuation in + initiatorContinuation = continuation + } + } + + func resumeInitiator() { + initiatorContinuation?.resume() + initiatorContinuation = nil + } +} + +@MainActor +private final class TestUpdateScheduler { + private final class Token { + var isCancelled = false + } + + private struct Entry { + let delay: TimeInterval + let action: UpdateCheckCoordinator.ScheduledAction + let token: Token + } + + private var entries: [Entry] = [] + + var activeDelays: [TimeInterval] { + entries.filter { !$0.token.isCancelled }.map(\.delay) + } + + func schedule( + _ delay: TimeInterval, + _ action: @escaping UpdateCheckCoordinator.ScheduledAction + ) -> UpdateCheckCoordinator.Cancellation { + let token = Token() + entries.append(Entry(delay: delay, action: action, token: token)) + return { + token.isCancelled = true + } + } + + func runNext() { + guard let index = entries.firstIndex(where: { !$0.token.isCancelled }) else { + return + } + let entry = entries.remove(at: index) + entry.token.isCancelled = true + entry.action() + } +} + +private actor ScriptedUpdateFetcher { + enum Outcome { + case response(Data, URLResponse) + case failure + } + + private var outcomes: [Outcome] + private(set) var callCount = 0 + + init(outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func fetch(_ request: URLRequest) throws -> (Data, URLResponse) { + callCount += 1 + guard !outcomes.isEmpty else { + throw TestFetchError.failed + } + switch outcomes.removeFirst() { + case let .response(data, response): + return (data, response) + case .failure: + throw TestFetchError.failed + } + } +} + +private actor SuspendedCoordinatorFetcher { + private let result: (Data, URLResponse) + private var continuation: CheckedContinuation<(Data, URLResponse), Error>? + private(set) var callCount = 0 + + init(result: (Data, URLResponse)) { + self.result = result + } + + func fetch(_ request: URLRequest) async throws -> (Data, URLResponse) { + callCount += 1 + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + continuation?.resume(returning: result) + continuation = nil + } +} + +private enum TestFetchError: Error { + case failed +} From 5bfc704c4b3df81b0ab1e28ce1ea8f8c135042a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:11:18 +0800 Subject: [PATCH 2/5] Fix Xcode 15.4 hotkey scheduler isolation --- Sources/CodexNotesProbe/GlobalHotKeyController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexNotesProbe/GlobalHotKeyController.swift b/Sources/CodexNotesProbe/GlobalHotKeyController.swift index 2b075b7..d6f3ad1 100644 --- a/Sources/CodexNotesProbe/GlobalHotKeyController.swift +++ b/Sources/CodexNotesProbe/GlobalHotKeyController.swift @@ -310,8 +310,7 @@ final class CarbonGlobalHotKeyBackend: GlobalHotKeyRegistering { init( cleanupRetryDelays: [TimeInterval] = CarbonGlobalHotKeyBackend .defaultCleanupRetryDelays, - cleanupScheduler: @escaping CleanupScheduler = CarbonGlobalHotKeyBackend - .mainQueueCleanupScheduler, + cleanupScheduler: CleanupScheduler? = nil, registrationOperation: RegistrationOperation? = nil, unregistrationOperation: @escaping UnregistrationOperation = { UnregisterEventHotKey($0) @@ -319,6 +318,7 @@ final class CarbonGlobalHotKeyBackend: GlobalHotKeyRegistering { ) { self.cleanupRetryDelays = cleanupRetryDelays.map { max(0, $0) } self.cleanupScheduler = cleanupScheduler + ?? CarbonGlobalHotKeyBackend.mainQueueCleanupScheduler self.registrationOperation = registrationOperation self.unregistrationOperation = unregistrationOperation } From 26486a8d89887dbbb4ae2f1f7a046e7251c4bc07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:14:23 +0800 Subject: [PATCH 3/5] Fix Xcode 15.4 update interval inference --- Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift b/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift index 1fe5ba5..7696729 100644 --- a/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift +++ b/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift @@ -71,7 +71,10 @@ final class UpdateCheckCoordinatorTests: XCTestCase { fixture.defaults.object(forKey: UpdateCheckPreferenceKey.nextEligibleAt) as? Date, referenceDate.addingTimeInterval(10 + 24 * 60 * 60) ) - XCTAssertEqual(scheduler.activeDelays, [24 * 60 * 60]) + XCTAssertEqual( + scheduler.activeDelays, + [TimeInterval(24 * 60 * 60)] + ) coordinator.stop() } From 6ac8e700562e61cbc199fd0d188a3b7f2cd528d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:23:59 +0800 Subject: [PATCH 4/5] Make selection notice timing test deterministic --- Sources/CodexNotesProbe/ProbeViewModel.swift | 52 +++++++++++---- .../ProbeViewModelSelectionMoveTests.swift | 63 ++++++++++++++++--- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/Sources/CodexNotesProbe/ProbeViewModel.swift b/Sources/CodexNotesProbe/ProbeViewModel.swift index 1e7c5e4..8c08afa 100644 --- a/Sources/CodexNotesProbe/ProbeViewModel.swift +++ b/Sources/CodexNotesProbe/ProbeViewModel.swift @@ -74,6 +74,13 @@ private enum SelectionMoveCoordinationError: LocalizedError { @MainActor final class ProbeViewModel: ObservableObject { + typealias SelectionMoveNoticeScheduledAction = @MainActor () -> Void + typealias SelectionMoveNoticeDismissalCancellation = @MainActor () -> Void + typealias SelectionMoveNoticeScheduler = @MainActor ( + _ delay: Duration, + _ action: @escaping SelectionMoveNoticeScheduledAction + ) -> SelectionMoveNoticeDismissalCancellation + enum State: Equatable { case starting case detected @@ -129,12 +136,14 @@ final class ProbeViewModel: ObservableObject { private let metadataRefreshInterval: Duration private let newTaskProjectRefreshInterval: Duration private let selectionMoveNoticeDuration: Duration + private let selectionMoveNoticeScheduler: SelectionMoveNoticeScheduler private var monitoringTask: Task? private var metadataRefreshTask: Task? private var metadataRefreshRequestID: UUID? private var nextMetadataRefresh: ContinuousClock.Instant? private var autosaveTask: Task? - private var selectionMoveNoticeDismissalTask: Task? + private var selectionMoveNoticeDismissalCancellation: + SelectionMoveNoticeDismissalCancellation? private var selectionMoveNoticeDismissalRequestID: UUID? private var selectionMoveNoticeIsHovered = false private var isLoadingDocument = false @@ -161,7 +170,8 @@ final class ProbeViewModel: ObservableObject { globalProjectCandidateProvider: any CodexGlobalProjectCandidateProviding = CodexProjectStore(), metadataRefreshInterval: Duration = .seconds(1), newTaskProjectRefreshInterval: Duration = .milliseconds(250), - selectionMoveNoticeDuration: Duration = .seconds(8) + selectionMoveNoticeDuration: Duration = .seconds(8), + selectionMoveNoticeScheduler: SelectionMoveNoticeScheduler? = nil ) { self.noteStore = noteStore self.noteImageStore = noteImageStore ?? NoteImageStore(rootURL: noteStore.rootURL) @@ -170,13 +180,17 @@ final class ProbeViewModel: ObservableObject { self.metadataRefreshInterval = metadataRefreshInterval self.newTaskProjectRefreshInterval = newTaskProjectRefreshInterval self.selectionMoveNoticeDuration = selectionMoveNoticeDuration + self.selectionMoveNoticeScheduler = selectionMoveNoticeScheduler + ?? Self.mainQueueSelectionMoveNoticeScheduler } deinit { monitoringTask?.cancel() metadataRefreshTask?.cancel() autosaveTask?.cancel() - selectionMoveNoticeDismissalTask?.cancel() + MainActor.assumeIsolated { + selectionMoveNoticeDismissalCancellation?() + } } var canEdit: Bool { @@ -1201,21 +1215,17 @@ final class ProbeViewModel: ObservableObject { let requestID = UUID() let duration = selectionMoveNoticeDuration selectionMoveNoticeDismissalRequestID = requestID - selectionMoveNoticeDismissalTask = Task { [weak self] in - do { - try await Task.sleep(for: duration) - } catch { - return - } - guard !Task.isCancelled else { return } + selectionMoveNoticeDismissalCancellation = selectionMoveNoticeScheduler( + duration + ) { [weak self] in self?.expireSelectionMoveNotice(requestID: requestID) } } private func cancelSelectionMoveNoticeDismissal() { selectionMoveNoticeDismissalRequestID = nil - selectionMoveNoticeDismissalTask?.cancel() - selectionMoveNoticeDismissalTask = nil + selectionMoveNoticeDismissalCancellation?() + selectionMoveNoticeDismissalCancellation = nil } private func expireSelectionMoveNotice(requestID: UUID) { @@ -1223,11 +1233,27 @@ final class ProbeViewModel: ObservableObject { !selectionMoveNoticeIsHovered else { return } selectionMoveNoticeDismissalRequestID = nil - selectionMoveNoticeDismissalTask = nil + selectionMoveNoticeDismissalCancellation = nil selectionMoveNotice = nil pendingSelectionMove = nil } + private static let mainQueueSelectionMoveNoticeScheduler: + SelectionMoveNoticeScheduler = { duration, action in + let task = Task { @MainActor in + do { + try await Task.sleep(for: duration) + } catch { + return + } + guard !Task.isCancelled else { return } + action() + } + return { + task.cancel() + } + } + private func clearSelectionMoveNotice() { cancelSelectionMoveNoticeDismissal() selectionMoveNoticeIsHovered = false diff --git a/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift b/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift index a4fafc9..bcc6eeb 100644 --- a/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift +++ b/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift @@ -234,29 +234,36 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { try await waitUntil { harness.model.selectionMoveNotice == nil } } - func testHoverPausesNoticeAndLeavingRestartsFullDuration() async throws { + func testHoverPausesNoticeAndLeavingRestartsFullDuration() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "悬停测试", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(180) + selectionMoveNoticeDuration: .milliseconds(180), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let moveSnapshot = try snapshot(in: harness.model, selecting: "悬停测试") XCTAssertNotNil(move(moveSnapshot, in: harness.model, to: .project)) let noticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) - try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(scheduler.scheduledDelays, [.milliseconds(180)]) harness.model.setSelectionMoveNoticeHovered(true, for: noticeID) - try await Task.sleep(for: .milliseconds(250)) + scheduler.advance(by: .seconds(1)) XCTAssertNotNil(harness.model.selectionMoveNotice) XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) harness.model.setSelectionMoveNoticeHovered(false, for: noticeID) - try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual( + scheduler.scheduledDelays, + [.milliseconds(180), .milliseconds(180)] + ) + scheduler.advance(by: .milliseconds(179)) XCTAssertNotNil(harness.model.selectionMoveNotice) - try await waitUntil { harness.model.selectionMoveNotice == nil } + scheduler.advance(by: .milliseconds(1)) + XCTAssertNil(harness.model.selectionMoveNotice) XCTAssertNil(harness.model.undoLastSelectionMove()) } @@ -722,7 +729,8 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { private func makeHarness( taskText: String, projectText: String, - selectionMoveNoticeDuration: Duration = .seconds(8) + selectionMoveNoticeDuration: Duration = .seconds(8), + selectionMoveNoticeScheduler: ProbeViewModel.SelectionMoveNoticeScheduler? = nil ) throws -> SelectionMoveHarness { let root = temporaryNoteRoot() let selection = makeSelection() @@ -742,7 +750,8 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { noteStore: store, metadataProvider: provider, metadataRefreshInterval: .zero, - selectionMoveNoticeDuration: selectionMoveNoticeDuration + selectionMoveNoticeDuration: selectionMoveNoticeDuration, + selectionMoveNoticeScheduler: selectionMoveNoticeScheduler ) model.apply(selection, recordLatency: false) return SelectionMoveHarness( @@ -827,6 +836,44 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { } } +@MainActor +private final class SelectionMoveNoticeTestScheduler { + private final class Token { + var isCancelled = false + } + + private struct Entry { + let deadline: Duration + let token: Token + let action: ProbeViewModel.SelectionMoveNoticeScheduledAction + } + + private var now: Duration = .zero + private var entries: [Entry] = [] + private(set) var scheduledDelays: [Duration] = [] + + func schedule( + after delay: Duration, + action: @escaping ProbeViewModel.SelectionMoveNoticeScheduledAction + ) -> ProbeViewModel.SelectionMoveNoticeDismissalCancellation { + let token = Token() + entries.append(Entry(deadline: now + delay, token: token, action: action)) + scheduledDelays.append(delay) + return { + token.isCancelled = true + } + } + + func advance(by interval: Duration) { + now += interval + let dueEntries = entries.filter { $0.deadline <= now } + entries.removeAll { $0.deadline <= now } + for entry in dueEntries where !entry.token.isCancelled { + entry.action() + } + } +} + @MainActor private struct SelectionMoveHarness { let root: URL From 3902e057bba4daccb0eb8342cf91524de6129b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:32:25 +0800 Subject: [PATCH 5/5] Use virtual time for selection notice tests --- .../ProbeViewModelSelectionMoveTests.swift | 137 +++++++++++++----- 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift b/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift index bcc6eeb..cd50feb 100644 --- a/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift +++ b/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift @@ -121,11 +121,13 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { XCTAssertEqual(harness.model.selectionMoveNotice?.canViewDestination, false) } - func testMoveNoticeAutoDismissesInBothDirectionsAndClearsPendingActions() async throws { + func testMoveNoticeAutoDismissesInBothDirectionsAndClearsPendingActions() throws { + let taskToProjectScheduler = SelectionMoveNoticeTestScheduler() let taskToProject = try makeHarness( taskText: "移到项目", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(80) + selectionMoveNoticeDuration: .milliseconds(80), + selectionMoveNoticeScheduler: taskToProjectScheduler.schedule ) defer { taskToProject.removeTemporaryFiles() } @@ -133,15 +135,20 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { XCTAssertNotNil(move(taskSnapshot, in: taskToProject.model, to: .project)) XCTAssertNotNil(taskToProject.model.selectionMoveNotice) - try await waitUntil { taskToProject.model.selectionMoveNotice == nil } + taskToProjectScheduler.advance(by: .milliseconds(79)) + XCTAssertNotNil(taskToProject.model.selectionMoveNotice) + taskToProjectScheduler.advance(by: .milliseconds(1)) + XCTAssertNil(taskToProject.model.selectionMoveNotice) XCTAssertNil(taskToProject.model.viewSelectionMoveDestination()) XCTAssertNil(taskToProject.model.undoLastSelectionMove()) + let projectToTaskScheduler = SelectionMoveNoticeTestScheduler() let projectToTask = try makeHarness( taskText: "任务正文", projectText: "移到任务", - selectionMoveNoticeDuration: .milliseconds(80) + selectionMoveNoticeDuration: .milliseconds(80), + selectionMoveNoticeScheduler: projectToTaskScheduler.schedule ) defer { projectToTask.removeTemporaryFiles() } projectToTask.model.selectScope(.project) @@ -150,44 +157,62 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { XCTAssertNotNil(move(projectSnapshot, in: projectToTask.model, to: .task)) XCTAssertEqual(projectToTask.model.selectionMoveNotice?.destinationScope, .task) - try await waitUntil { projectToTask.model.selectionMoveNotice == nil } + projectToTaskScheduler.advance(by: .milliseconds(79)) + XCTAssertNotNil(projectToTask.model.selectionMoveNotice) + projectToTaskScheduler.advance(by: .milliseconds(1)) + XCTAssertNil(projectToTask.model.selectionMoveNotice) XCTAssertNil(projectToTask.model.viewSelectionMoveDestination()) XCTAssertNil(projectToTask.model.undoLastSelectionMove()) } - func testNewMoveRestartsNoticeDeadlineAndOldTaskCannotDismissSameNotice() async throws { + func testNewMoveRestartsNoticeDeadlineAndOldTaskCannotDismissSameNotice() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "第一次\n第二次", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(400) + selectionMoveNoticeDuration: .milliseconds(400), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let firstSnapshot = try snapshot(in: harness.model, selecting: "第一次") XCTAssertNotNil(move(firstSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(300)) + let firstNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + scheduler.advance(by: .milliseconds(300)) let secondSnapshot = try snapshot(in: harness.model, selecting: "第二次") XCTAssertNotNil(move(secondSnapshot, in: harness.model, to: .project)) + let secondNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + XCTAssertNotEqual(secondNoticeID, firstNoticeID) + XCTAssertEqual( + scheduler.scheduledDelays, + [.milliseconds(400), .milliseconds(400)] + ) // Cross the first move's original deadline while remaining well inside // the second move's full display duration. Both notices intentionally // have the same value, so identity must come from the scheduled request. - try await Task.sleep(for: .milliseconds(160)) + scheduler.advance(by: .milliseconds(160)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, secondNoticeID) XCTAssertEqual(harness.model.selectionMoveNotice?.destinationScope, .project) XCTAssertEqual(harness.model.selectionMoveNotice?.destinationName, "Project A") XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) - try await waitUntil { harness.model.selectionMoveNotice == nil } + scheduler.advance(by: .milliseconds(239)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, secondNoticeID) + scheduler.advance(by: .milliseconds(1)) + XCTAssertNil(harness.model.selectionMoveNotice) XCTAssertNil(harness.model.undoLastSelectionMove()) } - func testNewNoticeResetsOldHoverAndIgnoresStaleHoverEnter() async throws { + func testNewNoticeResetsOldHoverAndIgnoresStaleHoverEnter() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "第一次\n第二次", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(120) + selectionMoveNoticeDuration: .milliseconds(120), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } @@ -195,6 +220,8 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { XCTAssertNotNil(move(firstSnapshot, in: harness.model, to: .project)) let oldNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) harness.model.setSelectionMoveNoticeHovered(true, for: oldNoticeID) + XCTAssertEqual(scheduler.pendingActionCount, 0) + scheduler.advance(by: .milliseconds(60)) let secondSnapshot = try snapshot(in: harness.model, selecting: "第二次") XCTAssertNotNil(move(secondSnapshot, in: harness.model, to: .project)) @@ -204,14 +231,22 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { // A new notice must clear the previous banner's hover state, and a late // mouse-enter callback from that old banner must not pause the new one. harness.model.setSelectionMoveNoticeHovered(true, for: oldNoticeID) - try await waitUntil { harness.model.selectionMoveNotice == nil } + XCTAssertEqual(scheduler.pendingActionCount, 1) + scheduler.advance(by: .milliseconds(60)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, newNoticeID) + scheduler.advance(by: .milliseconds(59)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, newNoticeID) + scheduler.advance(by: .milliseconds(1)) + XCTAssertNil(harness.model.selectionMoveNotice) } - func testStaleHoverExitCannotResumeCurrentPausedNotice() async throws { + func testStaleHoverExitCannotResumeCurrentPausedNotice() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "第一次\n第二次", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(120) + selectionMoveNoticeDuration: .milliseconds(120), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } @@ -227,11 +262,17 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { // SwiftUI may deliver the removed old banner's mouse-exit/onDisappear // after the replacement is visible. Its ID must not restart the timer. harness.model.setSelectionMoveNoticeHovered(false, for: oldNoticeID) - try await Task.sleep(for: .milliseconds(180)) + XCTAssertEqual(scheduler.pendingActionCount, 0) + scheduler.advance(by: .seconds(1)) XCTAssertEqual(harness.model.selectionMoveNotice?.id, newNoticeID) harness.model.setSelectionMoveNoticeHovered(false, for: newNoticeID) - try await waitUntil { harness.model.selectionMoveNotice == nil } + XCTAssertEqual(scheduler.scheduledDelays.count, 3) + XCTAssertEqual(scheduler.scheduledDelays.last, .milliseconds(120)) + scheduler.advance(by: .milliseconds(119)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, newNoticeID) + scheduler.advance(by: .milliseconds(1)) + XCTAssertNil(harness.model.selectionMoveNotice) } func testHoverPausesNoticeAndLeavingRestartsFullDuration() throws { @@ -267,17 +308,20 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { XCTAssertNil(harness.model.undoLastSelectionMove()) } - func testViewingDestinationRestartsNoticeAndKeepsUndoAvailable() async throws { + func testViewingDestinationRestartsNoticeAndKeepsUndoAvailable() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "查看后仍可撤销", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(400) + selectionMoveNoticeDuration: .milliseconds(400), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let moveSnapshot = try snapshot(in: harness.model, selecting: "查看后仍可撤销") let result = try XCTUnwrap(move(moveSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(300)) + let noticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + scheduler.advance(by: .milliseconds(300)) XCTAssertEqual( harness.model.viewSelectionMoveDestination(), @@ -285,70 +329,84 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { ) XCTAssertEqual(harness.model.selectionMoveNotice?.canViewDestination, false) XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) + XCTAssertEqual( + scheduler.scheduledDelays, + [.milliseconds(400), .milliseconds(400)] + ) - try await Task.sleep(for: .milliseconds(160)) - XCTAssertNotNil(harness.model.selectionMoveNotice) + scheduler.advance(by: .milliseconds(160)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, noticeID) XCTAssertEqual(harness.model.undoLastSelectionMove(), result) XCTAssertNil(harness.model.selectionMoveNotice) } - func testManualDismissCancelsOldTaskWithoutHarmingNextNotice() async throws { + func testManualDismissCancelsOldTaskWithoutHarmingNextNotice() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "第一次\n第二次", projectText: "项目正文", - selectionMoveNoticeDuration: .milliseconds(400) + selectionMoveNoticeDuration: .milliseconds(400), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let firstSnapshot = try snapshot(in: harness.model, selecting: "第一次") XCTAssertNotNil(move(firstSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(300)) + scheduler.advance(by: .milliseconds(300)) harness.model.dismissSelectionMoveNotice() XCTAssertNil(harness.model.selectionMoveNotice) + XCTAssertEqual(scheduler.pendingActionCount, 0) let secondSnapshot = try snapshot(in: harness.model, selecting: "第二次") XCTAssertNotNil(move(secondSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(160)) + let secondNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + scheduler.advance(by: .milliseconds(160)) - XCTAssertNotNil(harness.model.selectionMoveNotice) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, secondNoticeID) XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) harness.model.dismissSelectionMoveNotice() } - func testUndoCancelsOldTaskWithoutHarmingNextNotice() async throws { + func testUndoCancelsOldTaskWithoutHarmingNextNotice() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "第一次\n第二次", projectText: "项目正文", - selectionMoveNoticeDuration: .seconds(2) + selectionMoveNoticeDuration: .seconds(2), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let firstSnapshot = try snapshot(in: harness.model, selecting: "第一次") let firstResult = try XCTUnwrap(move(firstSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .seconds(1)) + scheduler.advance(by: .seconds(1)) XCTAssertEqual(harness.model.undoLastSelectionMove(), firstResult) XCTAssertNil(harness.model.selectionMoveNotice) + XCTAssertEqual(scheduler.pendingActionCount, 0) let secondSnapshot = try snapshot(in: harness.model, selecting: "第二次") XCTAssertNotNil(move(secondSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(1_500)) + let secondNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + scheduler.advance(by: .milliseconds(1_500)) - XCTAssertNotNil(harness.model.selectionMoveNotice) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, secondNoticeID) XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) harness.model.dismissSelectionMoveNotice() } - func testIdentityChangeCancelsOldTaskWithoutHarmingNewIdentityNotice() async throws { + func testIdentityChangeCancelsOldTaskWithoutHarmingNewIdentityNotice() throws { + let scheduler = SelectionMoveNoticeTestScheduler() let harness = try makeHarness( taskText: "任务 A 的移动", projectText: "Project A 正文", - selectionMoveNoticeDuration: .milliseconds(400) + selectionMoveNoticeDuration: .milliseconds(400), + selectionMoveNoticeScheduler: scheduler.schedule ) defer { harness.removeTemporaryFiles() } let firstSnapshot = try snapshot(in: harness.model, selecting: "任务 A 的移动") XCTAssertNotNil(move(firstSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(300)) + scheduler.advance(by: .milliseconds(300)) let selectionB = makeSelection(id: "thread-b") harness.provider.set( @@ -361,12 +419,15 @@ final class ProbeViewModelSelectionMoveTests: XCTestCase { ) harness.model.apply(selectionB, recordLatency: false) XCTAssertNil(harness.model.selectionMoveNotice) + XCTAssertEqual(scheduler.pendingActionCount, 0) harness.model.noteText = "任务 B 的移动" let secondSnapshot = try snapshot(in: harness.model, selecting: "任务 B 的移动") XCTAssertNotNil(move(secondSnapshot, in: harness.model, to: .project)) - try await Task.sleep(for: .milliseconds(160)) + let secondNoticeID = try XCTUnwrap(harness.model.selectionMoveNotice?.id) + scheduler.advance(by: .milliseconds(160)) + XCTAssertEqual(harness.model.selectionMoveNotice?.id, secondNoticeID) XCTAssertEqual(harness.model.selectionMoveNotice?.destinationScope, .project) XCTAssertEqual(harness.model.selectionMoveNotice?.destinationName, "Project B") XCTAssertEqual(harness.model.selectionMoveNotice?.canUndo, true) @@ -852,6 +913,10 @@ private final class SelectionMoveNoticeTestScheduler { private var entries: [Entry] = [] private(set) var scheduledDelays: [Duration] = [] + var pendingActionCount: Int { + entries.filter { !$0.token.isCancelled }.count + } + func schedule( after delay: Duration, action: @escaping ProbeViewModel.SelectionMoveNoticeScheduledAction