Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AppBundle/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.4.71</string>
<string>1.4.83</string>
<key>CFBundleVersion</key>
<string>91</string>
<string>103</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ CodexNotes 是一个跟随 Codex 当前任务自动切换的原生 macOS Markdow
- Apple Silicon(arm64)或 Intel(x86_64)Mac
- macOS 14.0 或更高版本
- 正式 Release 为 Universal 2,同一份应用同时包含 arm64 与 x86_64
- 当前已验证兼容 Codex `26.803.41515`
- 当前已验证兼容 Codex `26.803.81509`

CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背书关系。

Expand All @@ -21,6 +21,8 @@ CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背
2. 解压后,把 `CodexNotes.app` 移到 `/Applications` 或 `~/Applications`。
3. 首次打开后,CodexNotes 会常驻菜单栏,不会在 Dock 显示图标。

“自动避让 Codex 侧边栏”默认关闭,可在设置中主动开启。若要精确识别尚未打开任何网页、终端或文件的空侧边栏,需要按设置页提示授予辅助功能权限;即使不开启自动避让,仍可使用笔记窗口右上角按钮手动折叠和展开。

正式 Release 使用 Developer ID 签名并通过 Apple notarization。你可以在终端验证:

```sh
Expand All @@ -42,6 +44,8 @@ spctl --assess --type exec --verbose=4 /Applications/CodexNotes.app
- 支持系统原色及六套自定义主题,并可调整字号与行间距
- 支持跟随系统、简体中文和 English 三种语言选项
- 支持自定义全局显示/隐藏快捷键;只在 Codex 正在运行且未隐藏时生效
- 支持手动把笔记窗口折叠为右上角锚定的半透明紧凑胶囊,只保留应用名称和展开按钮
- 可选“自动避让 Codex 侧边栏”:每轮侧边栏打开时自动收起一次,关闭时优先恢复打开侧边栏前的展开状态
- 可选择登录 Mac 时启动;该功能默认关闭并使用 macOS 原生登录项
- 记住窗口位置和大小;隐藏、重新显示或重启后不会漂移
- 关于页显示版本、作者与项目链接,并支持手动检查或主动开启每日 GitHub Release 更新检查
Expand All @@ -61,6 +65,8 @@ Markdown 原文是唯一数据源。图形复选框、图片预览、粗体和

CodexNotes 不上传笔记、不提供云同步,也不包含遥测或分析服务。应用只读取 Codex 写在本机的状态与导航日志,用来识别当前任务。

自动避让开启后,CodexNotes 会优先通过 macOS 辅助功能读取 Codex 主窗口的布局,以判断右侧边栏是否展开;它不读取键盘输入,也不会把窗口结构发送到网络。未授予权限时会降级读取 Codex 本机状态文件,但无法可靠识别没有标签内容的空侧边栏。该功能默认关闭,关闭后不会进行上述侧边栏检测,手动折叠仍然可用。

只有当你手动点击“检查更新”,或主动开启“自动检查更新”后,应用才会访问 GitHub Releases API。成功检查后间隔 24 小时;网络失败时会静默退避重试。应用只检查版本,不会自动下载或安装;请求不包含笔记内容、设备标识或遥测数据。

## 快捷键
Expand Down Expand Up @@ -103,7 +109,7 @@ zsh scripts/release-notarized.sh

## 兼容性说明

自动跟随目前依赖 Codex `26.803.41515` 写出的本机主窗口导航日志。该日志不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试。
自动跟随和侧边栏避让目前依赖 Codex `26.803.81509` 的本机导航状态及窗口结构。它们不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试;识别失败时,侧边栏避让会保持窗口展开,不会猜测性收起

Universal 2 产物会验证 arm64 与 x86_64 两个架构。当前 Codex 联动验证以 Apple Silicon 真机为主;Intel 版已包含原生 x86_64 架构代码,完整联动兼容性将在真实 Intel Mac 上持续验证,交叉编译或 Rosetta 启动仅作为补充。

Expand Down
144 changes: 144 additions & 0 deletions Sources/CodexNotesCore/CodexRightPanelStateReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import CoreFoundation
import Foundation

public enum CodexRightPanelState: String, Codable, Equatable, Sendable {
case unknown
case closed
case open
}

/// Parses Codex's persisted right-panel state for one exact thread.
///
/// The generic panel state is authoritative when it contains a strict Boolean.
/// If that private schema is unavailable, the browser-only state is a backward
/// compatible fallback. A valid atom store with neither exact-thread key means
/// the panel is still in its default closed state. Present but drifted values
/// remain unknown and never borrow another thread's state or rely on truthiness.
public enum CodexRightPanelStateParser {
private static let atomStateKey = "electron-persisted-atom-state"
private static let genericThreadKeyPrefix = "thread-tab-routes-v1:"
private static let browserThreadKeyPrefix = "thread-browser-tabs-v1:"

public static func state(
for threadID: String,
in data: Data
) -> CodexRightPanelState {
guard !threadID.isEmpty,
let root = try? JSONSerialization.jsonObject(with: data),
let rootObject = root as? [String: Any],
let atomState = rootObject[atomStateKey] as? [String: Any]
else {
return .unknown
}

let genericKey = genericPersistedKey(for: threadID)
let browserKey = browserPersistedKey(for: threadID)
let genericValue = atomState[genericKey]
let browserValue = atomState[browserKey]

if let genericThreadState = genericValue as? [String: Any],
let topology = genericThreadState["topology"] as? [String: Any],
let right = topology["right"] as? [String: Any],
let state = strictBooleanState(right["open"]) {
return state
}

if let browserThreadState = browserValue as? [String: Any],
let state = strictBooleanState(
browserThreadState["rightPanelOpen"]
) {
return state
}

if genericValue == nil, browserValue == nil {
return .closed
}

return .unknown
}

/// The legacy browser-only key, retained for source compatibility.
public static func persistedKey(for threadID: String) -> String {
browserPersistedKey(for: threadID)
}

/// Matches Codex's generic right-panel persisted key.
public static func genericPersistedKey(for threadID: String) -> String {
genericThreadKeyPrefix + encodeURIComponent(threadID)
}

/// Matches Codex's browser-only right-panel persisted key.
public static func browserPersistedKey(for threadID: String) -> String {
browserThreadKeyPrefix + encodeURIComponent(threadID)
}

private static func strictBooleanState(
_ value: Any?
) -> CodexRightPanelState? {
guard let number = value as? NSNumber,
CFGetTypeID(number) == CFBooleanGetTypeID()
else {
return nil
}

return number.boolValue ? .open : .closed
}

private static func encodeURIComponent(_ value: String) -> String {
var result = ""
result.reserveCapacity(value.utf8.count)

for byte in value.utf8 {
if isEncodeURIComponentUnescaped(byte) {
result.unicodeScalars.append(UnicodeScalar(byte))
} else {
result.append(String(format: "%%%02X", byte))
}
}
return result
}

private static func isEncodeURIComponentUnescaped(_ byte: UInt8) -> Bool {
switch byte {
case 0x30 ... 0x39, // 0-9
0x41 ... 0x5A, // A-Z
0x61 ... 0x7A, // a-z
0x21, // !
0x27, // '
0x28, // (
0x29, // )
0x2A, // *
0x2D, // -
0x2E, // .
0x5F, // _
0x7E: // ~
return true
default:
return false
}
}
}

/// Reads a fresh snapshot on each call so atomic replacements of Codex's state
/// file are observed without retaining stale panel state.
public actor CodexRightPanelStateReader {
private let globalStateURL: URL

public init(
globalStateURL: URL = CodexEnvironment.defaultStateDatabase
.deletingLastPathComponent()
.appendingPathComponent(".codex-global-state.json")
) {
self.globalStateURL = globalStateURL
}

public func state(for threadID: String) -> CodexRightPanelState {
guard let data = try? Data(
contentsOf: globalStateURL,
options: .mappedIfSafe
) else {
return .unknown
}
return CodexRightPanelStateParser.state(for: threadID, in: data)
}
}
12 changes: 12 additions & 0 deletions Sources/CodexNotesCore/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ 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 settingsRightPanelAvoidanceTitle = "settings.right_panel_avoidance.title"
case settingsRightPanelAvoidanceDescription = "settings.right_panel_avoidance.description"
case settingsRightPanelAvoidancePermissionTitle = "settings.right_panel_avoidance.permission_title"
case settingsRightPanelAvoidanceAuthorized = "settings.right_panel_avoidance.authorized"
case settingsRightPanelAvoidanceNotAuthorized = "settings.right_panel_avoidance.not_authorized"
case settingsRightPanelAvoidancePermissionDescription = "settings.right_panel_avoidance.permission_description"
case settingsRightPanelAvoidanceRequestPermission = "settings.right_panel_avoidance.request_permission"
case settingsRightPanelAvoidanceOpenSystemSettings = "settings.right_panel_avoidance.open_system_settings"
case settingsGlobalHotKeyTitle = "settings.global_hot_key.title"
case settingsGlobalHotKeyDescription = "settings.global_hot_key.description"
case settingsGlobalHotKeyRecord = "settings.global_hot_key.record"
Expand Down Expand Up @@ -349,6 +357,10 @@ public enum L10n {
case statusItemQuit = "status_item.quit"
case mainWindowCloseAccessibilityLabel = "main_window.close.accessibility_label"
case mainWindowCloseAccessibilityHelp = "main_window.close.accessibility_help"
case mainWindowCollapseAccessibilityLabel = "main_window.collapse.accessibility_label"
case mainWindowCollapseAccessibilityHelp = "main_window.collapse.accessibility_help"
case mainWindowExpandAccessibilityLabel = "main_window.expand.accessibility_label"
case mainWindowExpandAccessibilityHelp = "main_window.expand.accessibility_help"
case windowCloseHoverHint = "window.close.hover_hint"
case appCommandSaveNow = "app.command.save_now"

Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"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.right_panel_avoidance.title" = "Automatically Avoid the Codex Sidebar";
"settings.right_panel_avoidance.description" = "When enabled, CodexNotes collapses once per right-sidebar cycle and gives priority to the pre-sidebar expanded state when the sidebar closes. When automatic avoidance is off, the top-right button still collapses and expands CodexNotes manually.";
"settings.right_panel_avoidance.permission_title" = "Accessibility Permission";
"settings.right_panel_avoidance.authorized" = "Allowed";
"settings.right_panel_avoidance.not_authorized" = "Not Allowed";
"settings.right_panel_avoidance.permission_description" = "Detecting an empty sidebar requires Accessibility permission. CodexNotes only reads the Codex window layout; it does not read keyboard input.";
"settings.right_panel_avoidance.request_permission" = "Allow Accessibility Permission";
"settings.right_panel_avoidance.open_system_settings" = "Open System Settings";
"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";
Expand Down Expand Up @@ -289,6 +297,10 @@
"status_item.quit" = "Quit CodexNotes";
"main_window.close.accessibility_label" = "Hide CodexNotes";
"main_window.close.accessibility_help" = "Hides the notes window. CodexNotes will keep running in the menu bar.";
"main_window.collapse.accessibility_label" = "Collapse CodexNotes";
"main_window.collapse.accessibility_help" = "Collapses the note content and leaves only the title bar.";
"main_window.expand.accessibility_label" = "Expand CodexNotes";
"main_window.expand.accessibility_help" = "Expands the complete notes window.";
"window.close.hover_hint" = "Hide CodexNotes";
"app.command.save_now" = "Save Now";

Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"settings.launch_at_login.requires_approval" = "CodexNotes 已添加为登录项,但尚未生效;请在系统设置中允许。";
"settings.launch_at_login.open_system_settings" = "打开系统设置";
"settings.launch_at_login.error" = "无法更改登录启动设置,请重试。";
"settings.right_panel_avoidance.title" = "自动避让 Codex 侧边栏";
"settings.right_panel_avoidance.description" = "开启后,右侧边栏打开时每轮自动收起一次,关闭时优先恢复打开边栏前的展开状态。关闭自动避让后,仍可使用右上角按钮手动折叠和展开。";
"settings.right_panel_avoidance.permission_title" = "辅助功能权限";
"settings.right_panel_avoidance.authorized" = "已允许";
"settings.right_panel_avoidance.not_authorized" = "未允许";
"settings.right_panel_avoidance.permission_description" = "精确识别空侧边栏需要辅助功能权限。CodexNotes 只读取 Codex 窗口布局,不读取键盘输入。";
"settings.right_panel_avoidance.request_permission" = "允许辅助功能权限";
"settings.right_panel_avoidance.open_system_settings" = "打开系统设置";
"settings.global_hot_key.title" = "显示/隐藏 CodexNotes";
"settings.global_hot_key.description" = "Codex 正在显示时,可在任意应用中使用;若同时触发其他应用或无响应,请换一个";
"settings.global_hot_key.record" = "设置快捷键";
Expand Down Expand Up @@ -289,6 +297,10 @@
"status_item.quit" = "退出 CodexNotes";
"main_window.close.accessibility_label" = "隐藏 CodexNotes";
"main_window.close.accessibility_help" = "隐藏笔记窗口,应用继续在状态栏运行";
"main_window.collapse.accessibility_label" = "折叠 CodexNotes";
"main_window.collapse.accessibility_help" = "收起笔记内容,只保留标题栏";
"main_window.expand.accessibility_label" = "展开 CodexNotes";
"main_window.expand.accessibility_help" = "展开完整笔记窗口";
"window.close.hover_hint" = "隐藏 CodexNotes";
"app.command.save_now" = "立即保存";

Expand Down
37 changes: 37 additions & 0 deletions Sources/CodexNotesCore/RightPanelAvoidancePreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Shared persistence contract for the optional Codex right-panel avoidance.
///
/// Installations that have already saved an explicit choice retain it. Every
/// installation without this key starts with the optional behavior disabled,
/// including upgrades from public versions that predate automatic avoidance.
/// `migrateIfNeeded` persists that decision before any `@AppStorage` reader is
/// created, so every window observes one stable value for the entire launch.
public enum RightPanelAvoidancePreference {
public static let key = "rightPanelAvoidanceEnabled"
public static let defaultValue = false

@discardableResult
public static func migrateIfNeeded(
defaults: UserDefaults = .standard,
persistentDomainName: String
) -> Bool {
let persistentDomain = defaults.persistentDomain(
forName: persistentDomainName
)

if let storedValue = persistentDomain?[key] as? NSNumber {
return storedValue.boolValue
}

defaults.set(defaultValue, forKey: key)
return defaultValue
}

public static func load(from defaults: UserDefaults = .standard) -> Bool {
guard defaults.object(forKey: key) != nil else {
return defaultValue
}
return defaults.bool(forKey: key)
}
}
Loading