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
+}