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..d6f3ad1 --- /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: CleanupScheduler? = nil, + registrationOperation: RegistrationOperation? = nil, + unregistrationOperation: @escaping UnregistrationOperation = { + UnregisterEventHotKey($0) + } + ) { + self.cleanupRetryDelays = cleanupRetryDelays.map { max(0, $0) } + self.cleanupScheduler = cleanupScheduler + ?? CarbonGlobalHotKeyBackend.mainQueueCleanupScheduler + 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/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/Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg b/Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg new file mode 100644 index 0000000..4505b30 Binary files /dev/null and b/Sources/CodexNotesProbe/Resources/WeChatRewardCode.jpg differ 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/ProbeViewModelSelectionMoveTests.swift b/Tests/CodexNotesProbeTests/ProbeViewModelSelectionMoveTests.swift index a4fafc9..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,50 +262,66 @@ 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() 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()) } - 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(), @@ -278,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( @@ -354,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) @@ -722,7 +790,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 +811,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 +897,48 @@ 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] = [] + + var pendingActionCount: Int { + entries.filter { !$0.token.isCancelled }.count + } + + 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 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..7696729 --- /dev/null +++ b/Tests/CodexNotesProbeTests/UpdateCheckCoordinatorTests.swift @@ -0,0 +1,1086 @@ +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, + [TimeInterval(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 +}