diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b99d857..fe826b2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,12 +16,18 @@ jobs:
fail-fast: false
matrix:
include:
- - name: Xcode 15.4
+ - name: Xcode 15.4 arm64
os: macos-14
developer_directory: /Applications/Xcode_15.4.app/Contents/Developer
- - name: Current
+ architecture: arm64
+ - name: Current arm64
os: macos-26
developer_directory: ""
+ architecture: arm64
+ - name: macOS 15 Intel
+ os: macos-15-intel
+ developer_directory: ""
+ architecture: x86_64
steps:
- name: Check out repository
@@ -31,6 +37,16 @@ jobs:
if: matrix.developer_directory != ''
run: sudo xcode-select --switch "${{ matrix.developer_directory }}"
+ - name: Verify runner architecture
+ env:
+ EXPECTED_ARCHITECTURE: ${{ matrix.architecture }}
+ run: |
+ actual_architecture="$(uname -m)"
+ if [[ "$actual_architecture" != "$EXPECTED_ARCHITECTURE" ]]; then
+ echo "Expected $EXPECTED_ARCHITECTURE runner, got $actual_architecture." >&2
+ exit 1
+ fi
+
- name: Report toolchain
run: |
xcodebuild -version
@@ -42,3 +58,77 @@ jobs:
- name: Run tests
run: COPYFILE_DISABLE=1 swift test
+
+ universal-package:
+ name: Package (Universal)
+ runs-on: macos-14
+ timeout-minutes: 25
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Select Xcode 15.4
+ run: sudo xcode-select --switch /Applications/Xcode_15.4.app/Contents/Developer
+
+ - name: Report packaging toolchain
+ run: |
+ xcodebuild -version
+ swift --version
+ xcrun --show-sdk-version
+
+ - name: Package Universal app without release credentials
+ env:
+ CODEX_NOTES_INSTALL_LOCAL: "0"
+ COPYFILE_DISABLE: "1"
+ run: zsh scripts/package-app.sh
+
+ - name: Verify Universal archive and all code slices
+ env:
+ EXTRACT_DIRECTORY: ${{ runner.temp }}/codexnotes-universal
+ run: |
+ mkdir -p "$EXTRACT_DIRECTORY"
+ ditto -x -k dist/CodexNotes.zip "$EXTRACT_DIRECTORY"
+ app_path="$EXTRACT_DIRECTORY/CodexNotes.app"
+ test -d "$app_path"
+ zsh scripts/verify-universal-app.sh "$app_path"
+ codesign \
+ --verify \
+ --deep \
+ --strict \
+ --all-architectures \
+ --verbose=2 \
+ "$app_path"
+
+ - name: Reject an app with one architecture removed
+ env:
+ EXTRACT_DIRECTORY: ${{ runner.temp }}/codexnotes-universal
+ NEGATIVE_DIRECTORY: ${{ runner.temp }}/codexnotes-missing-slice
+ run: |
+ mkdir -p "$NEGATIVE_DIRECTORY"
+ negative_app="$NEGATIVE_DIRECTORY/CodexNotes.app"
+ ditto "$EXTRACT_DIRECTORY/CodexNotes.app" "$negative_app"
+
+ executable="$negative_app/Contents/MacOS/CodexNotesProbe"
+ thin_executable="$NEGATIVE_DIRECTORY/CodexNotesProbe-arm64"
+ lipo "$executable" -remove x86_64 -output "$thin_executable"
+ mv "$thin_executable" "$executable"
+ chmod 755 "$executable"
+ if [[ "$(lipo -archs "$executable")" != "arm64" ]]; then
+ echo "Failed to construct the arm64-only negative fixture." >&2
+ exit 1
+ fi
+
+ codesign --force --deep --sign - "$negative_app"
+ codesign \
+ --verify \
+ --deep \
+ --strict \
+ --all-architectures \
+ --verbose=2 \
+ "$negative_app"
+
+ if zsh scripts/verify-universal-app.sh "$negative_app"; then
+ echo "Universal verifier accepted an app missing x86_64." >&2
+ exit 1
+ fi
diff --git a/AppBundle/Info.plist b/AppBundle/Info.plist
index 7036042..a3b2962 100644
--- a/AppBundle/Info.plist
+++ b/AppBundle/Info.plist
@@ -24,9 +24,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.69
+ 1.4.70
CFBundleVersion
- 89
+ 90
LSUIElement
LSMinimumSystemVersion
diff --git a/README.md b/README.md
index 15fc8bf..6774e4e 100644
--- a/README.md
+++ b/README.md
@@ -8,22 +8,23 @@ CodexNotes 是一个跟随 Codex 当前任务自动切换的原生 macOS Markdow
## 系统要求
-- Apple Silicon Mac(arm64)
+- Apple Silicon(arm64)或 Intel(x86_64)Mac
- macOS 14.0 或更高版本
+- 正式 Release 为 Universal 2,同一份应用同时包含 arm64 与 x86_64
- 当前已验证兼容 Codex `26.803.41515`
CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背书关系。
## 安装
-1. 从 [Releases](https://github.com/jiangsir-tech/CodexNotes/releases/latest) 下载最新版 ZIP。
+1. 从 [Releases](https://github.com/jiangsir-tech/CodexNotes/releases/latest) 下载 `CodexNotes-v<版本号>-macOS-universal.zip`;同一份 Universal 2 文件适用于 Apple Silicon 与 Intel Mac。
2. 解压后,把 `CodexNotes.app` 移到 `/Applications` 或 `~/Applications`。
3. 首次打开后,CodexNotes 会常驻菜单栏,不会在 Dock 显示图标。
正式 Release 使用 Developer ID 签名并通过 Apple notarization。你可以在终端验证:
```sh
-codesign --verify --deep --strict --verbose=2 /Applications/CodexNotes.app
+codesign --verify --deep --strict --all-architectures --verbose=2 /Applications/CodexNotes.app
spctl --assess --type exec --verbose=4 /Applications/CodexNotes.app
```
@@ -84,7 +85,7 @@ zsh scripts/verify-localizations.sh
zsh scripts/package-app.sh
```
-`package-app.sh` 默认只在 `dist/` 生成本地 ad-hoc 构建,不会覆盖已安装应用。开发者如需安装本地构建,必须显式执行:
+`package-app.sh` 默认只在 `dist/` 生成 Universal 2 的本地 ad-hoc 构建,不会覆盖已安装应用。开发者如需安装本地构建,必须显式执行:
```sh
CODEX_NOTES_INSTALL_LOCAL=1 \
@@ -104,6 +105,8 @@ zsh scripts/release-notarized.sh
自动跟随目前依赖 Codex `26.803.41515` 写出的本机主窗口导航日志。该日志不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试。
+Universal 2 产物会验证 arm64 与 x86_64 两个架构。当前 Codex 联动验证以 Apple Silicon 真机为主;Intel 版已包含原生 x86_64 架构代码,完整联动兼容性将在真实 Intel Mac 上持续验证,交叉编译或 Rosetta 启动仅作为补充。
+
## 许可证
本仓库目前尚未采用开源许可证。公开源码用于透明度、审查与问题反馈,不代表授予复制、修改或再分发源码及品牌素材的许可。
diff --git a/Sources/CodexNotesCore/CompanionVisibilityPolicy.swift b/Sources/CodexNotesCore/CompanionVisibilityPolicy.swift
index 4c12a15..e31d331 100644
--- a/Sources/CodexNotesCore/CompanionVisibilityPolicy.swift
+++ b/Sources/CodexNotesCore/CompanionVisibilityPolicy.swift
@@ -6,9 +6,9 @@ public enum CompanionVisibilityPolicy {
public static func shouldShow(
frontmostBundleIdentifier: String?,
companionBundleIdentifier: String?,
- isSettingsVisible: Bool = false
+ isCodexAvailable: Bool
) -> Bool {
- guard !isSettingsVisible else { return false }
+ guard isCodexAvailable else { return false }
guard let frontmostBundleIdentifier else { return false }
return frontmostBundleIdentifier == codexBundleIdentifier
|| frontmostBundleIdentifier == companionBundleIdentifier
diff --git a/Sources/CodexNotesCore/Localization.swift b/Sources/CodexNotesCore/Localization.swift
index bbae073..284e80a 100644
--- a/Sources/CodexNotesCore/Localization.swift
+++ b/Sources/CodexNotesCore/Localization.swift
@@ -277,9 +277,10 @@ public enum L10n {
case settingsEditorFontSizeAccessibilityHint = "settings.editor.font_size.accessibility_hint"
case settingsEditorLineSpacing = "settings.editor.line_spacing"
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 settingsEditorRestoreWindowSize = "settings.editor.restore_window_size"
+ case settingsEditorRestoreWindowSizeAccessibilityHint = "settings.editor.restore_window_size.accessibility_hint"
+ case settingsEditorWindowSizeRestored = "settings.editor.window_size_restored"
case settingsEditorRestoreDefaults = "settings.editor.restore_defaults"
case settingsEditorRestoreDefaultsAccessibilityLabel = "settings.editor.restore_defaults.accessibility_label"
case settingsEditorRestoreDefaultsAccessibilityHint = "settings.editor.restore_defaults.accessibility_hint"
diff --git a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
index 0283cf0..f19ab58 100644
--- a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
+++ b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
@@ -217,9 +217,10 @@
"settings.editor.font_size.accessibility_hint" = "Adjust the font size of note text.";
"settings.editor.line_spacing" = "Line Spacing";
"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.window_resize_hint" = "Text size and line spacing update the note window live. Drag its edges to resize it; CodexNotes remembers the size.";
+"settings.editor.restore_window_size" = "Restore Default Note Window Size";
+"settings.editor.restore_window_size.accessibility_hint" = "Restores the note window to the default size for the current display while keeping its position when possible. Notes and editor settings are not changed.";
+"settings.editor.window_size_restored" = "Note window restored to its default 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.";
diff --git a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
index 9d5a52e..714e45d 100644
--- a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
+++ b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
@@ -217,9 +217,10 @@
"settings.editor.font_size.accessibility_hint" = "调整笔记正文的字体大小";
"settings.editor.line_spacing" = "行间距";
"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.window_resize_hint" = "字号和行距会实时应用到笔记窗口;拖动窗口边缘可调整大小,尺寸会自动记住。";
+"settings.editor.restore_window_size" = "恢复笔记窗口默认大小";
+"settings.editor.restore_window_size.accessibility_hint" = "将笔记窗口恢复为适合当前显示器的默认大小,尽量保留窗口位置,不会更改笔记内容或编辑器设置。";
+"settings.editor.window_size_restored" = "笔记窗口已恢复默认大小";
"settings.editor.restore_defaults" = "恢复编辑器默认";
"settings.editor.restore_defaults.accessibility_label" = "恢复编辑器默认设置";
"settings.editor.restore_defaults.accessibility_hint" = "恢复 15 pt 正文字号和 4 pt 行间距";
diff --git a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
index 6d76678..b145d3d 100644
--- a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
+++ b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
@@ -85,8 +85,8 @@ struct CodexNotesProbeApp: App {
}
}
.defaultSize(
- width: MainWindowInitialPlacementPolicy.fallbackSize.width,
- height: MainWindowInitialPlacementPolicy.fallbackSize.height
+ width: MainWindowInitialPlacementPolicy.swiftUIBootstrapSize.width,
+ height: MainWindowInitialPlacementPolicy.swiftUIBootstrapSize.height
)
.windowResizability(.contentMinSize)
.commands {
diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift
index cf232bf..dbb8ade 100644
--- a/Sources/CodexNotesProbe/ContentView.swift
+++ b/Sources/CodexNotesProbe/ContentView.swift
@@ -1955,18 +1955,26 @@ struct WindowConfigurator: NSViewRepresentable {
final class Coordinator: NSObject, NSWindowDelegate {
private weak var window: NSWindow?
private var activationObserver: NSObjectProtocol?
- private var settingsVisibilityObserver: NSObjectProtocol?
private var toggleWindowObserver: NSObjectProtocol?
private var showWindowObserver: NSObjectProtocol?
+ private var restoreDefaultSizeObserver: NSObjectProtocol?
private var framePersistenceObservers: [NSObjectProtocol] = []
private weak var originalWindowDelegate: NSWindowDelegate?
private var visibilityState = MainWindowVisibilityState()
- private var isSettingsVisible = false
private var codexActivationTimeoutWorkItem: DispatchWorkItem?
+ private let codexAvailabilityMonitor: CodexApplicationAvailabilityObserving
private let closeButtonHoverHintController = CloseButtonHoverHintController()
private var appliedLanguageRevision: String?
private var isInvalidated = false
+ init(
+ codexAvailabilityMonitor: CodexApplicationAvailabilityObserving? = nil
+ ) {
+ self.codexAvailabilityMonitor = codexAvailabilityMonitor
+ ?? CodexApplicationAvailabilityMonitor()
+ super.init()
+ }
+
func attach(to window: NSWindow, languageRevision: String) {
guard !isInvalidated,
window.identifier == CodexNotesWindowIdentifier.main else { return }
@@ -1984,6 +1992,9 @@ struct WindowConfigurator: NSViewRepresentable {
self.window = window
originalWindowDelegate = window.delegate
window.delegate = self
+ codexAvailabilityMonitor.start { [weak self] isAvailable in
+ self?.codexAvailabilityDidChange(isAvailable)
+ }
if activationObserver == nil {
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification,
@@ -1997,20 +2008,6 @@ struct WindowConfigurator: NSViewRepresentable {
}
}
}
- if settingsVisibilityObserver == nil {
- settingsVisibilityObserver = NotificationCenter.default.addObserver(
- forName: SettingsWindowVisibilityNotification.name,
- object: nil,
- queue: .main
- ) { [weak self] notification in
- guard let isVisible = notification.userInfo?[
- SettingsWindowVisibilityNotification.isVisibleKey
- ] as? Bool else { return }
- MainActor.assumeIsolated {
- self?.settingsVisibilityDidChange(isVisible)
- }
- }
- }
if toggleWindowObserver == nil {
toggleWindowObserver = NotificationCenter.default.addObserver(
forName: MainWindowCommandNotification.toggle,
@@ -2033,6 +2030,17 @@ struct WindowConfigurator: NSViewRepresentable {
}
}
}
+ if restoreDefaultSizeObserver == nil {
+ restoreDefaultSizeObserver = NotificationCenter.default.addObserver(
+ forName: MainWindowCommandNotification.restoreDefaultSize,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ self?.restoreDefaultWindowSize()
+ }
+ }
+ }
installFramePersistenceObservers(for: window)
appliedLanguageRevision = languageRevision
refreshCloseButtonHoverHint(for: window)
@@ -2048,14 +2056,11 @@ struct WindowConfigurator: NSViewRepresentable {
func detach() {
closeButtonHoverHintController.detach()
cancelCodexActivationTimeout()
+ codexAvailabilityMonitor.stop()
if let activationObserver {
NSWorkspace.shared.notificationCenter.removeObserver(activationObserver)
}
activationObserver = nil
- if let settingsVisibilityObserver {
- NotificationCenter.default.removeObserver(settingsVisibilityObserver)
- }
- settingsVisibilityObserver = nil
if let toggleWindowObserver {
NotificationCenter.default.removeObserver(toggleWindowObserver)
}
@@ -2064,6 +2069,12 @@ struct WindowConfigurator: NSViewRepresentable {
NotificationCenter.default.removeObserver(showWindowObserver)
}
showWindowObserver = nil
+ if let restoreDefaultSizeObserver {
+ NotificationCenter.default.removeObserver(
+ restoreDefaultSizeObserver
+ )
+ }
+ restoreDefaultSizeObserver = nil
if let window {
MainWindowFramePersistence.persistIfVisible(window: window)
}
@@ -2118,12 +2129,10 @@ 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,
- isSettingsVisible: settingsVisible
+ isWindowMiniaturized: window.isMiniaturized
)
switch action {
case .show:
@@ -2138,13 +2147,29 @@ struct WindowConfigurator: NSViewRepresentable {
presentMainWindow(window)
}
+ private func restoreDefaultWindowSize() {
+ guard let window,
+ MainWindowFramePersistence.restoreDefaultSize(
+ window: window
+ ) else { return }
+ NotificationCenter.default.post(
+ name: MainWindowCommandNotification.didRestoreDefaultSize,
+ object: window
+ )
+ }
+
private func presentMainWindow(_ window: NSWindow) {
let frontmostApplication = NSWorkspace.shared.frontmostApplication
- if frontmostApplication?.bundleIdentifier
- == CompanionVisibilityPolicy.codexBundleIdentifier {
+ let frontmostBundleIdentifier = frontmostApplication?.bundleIdentifier
+ let companionBundleIdentifier = Bundle.main.bundleIdentifier
+ if codexAvailabilityMonitor.isCodexAvailable,
+ (
+ frontmostBundleIdentifier
+ == CompanionVisibilityPolicy.codexBundleIdentifier
+ || frontmostBundleIdentifier == companionBundleIdentifier
+ ) {
cancelCodexActivationTimeout()
visibilityState.recordManualShow()
- closeSettingsWindowIfNeeded()
updateVisibility(frontmostApplication: frontmostApplication)
return
}
@@ -2240,7 +2265,6 @@ struct WindowConfigurator: NSViewRepresentable {
}
cancelCodexActivationTimeout()
_ = visibilityState.completeCodexActivation(requestID: requestID)
- closeSettingsWindowIfNeeded()
updateVisibility(frontmostApplication: frontmostApplication)
return true
}
@@ -2259,7 +2283,6 @@ struct WindowConfigurator: NSViewRepresentable {
let frontmostApplication = NSWorkspace.shared.frontmostApplication
if frontmostApplication?.bundleIdentifier
== CompanionVisibilityPolicy.codexBundleIdentifier {
- self.closeSettingsWindowIfNeeded()
self.updateVisibility(
frontmostApplication: frontmostApplication
)
@@ -2282,34 +2305,6 @@ struct WindowConfigurator: NSViewRepresentable {
codexActivationTimeoutWorkItem = nil
}
- private func closeSettingsWindowIfNeeded() {
- 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 {
- if let window {
- orderOutMainWindow(window)
- }
- } else {
- DispatchQueue.main.async { [weak self] in
- guard let self, !self.isSettingsVisible else { return }
- self.updateVisibility(
- frontmostApplication: NSWorkspace.shared.frontmostApplication
- )
- }
- }
- }
-
private func applicationDidActivate(
_ application: NSRunningApplication?
) {
@@ -2323,26 +2318,40 @@ struct WindowConfigurator: NSViewRepresentable {
}
cancelCodexActivationTimeout()
_ = visibilityState.completeCodexActivation()
- closeSettingsWindowIfNeeded()
}
updateVisibility(frontmostApplication: application)
}
+ private func codexAvailabilityDidChange(_ isAvailable: Bool) {
+ if !isAvailable, visibilityState.isAwaitingCodexActivation {
+ cancelCodexActivationTimeout()
+ _ = visibilityState.completeCodexActivation()
+ }
+ updateVisibility(
+ frontmostApplication: NSWorkspace.shared.frontmostApplication
+ )
+ }
+
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(
frontmostBundleIdentifier: frontmostBundleIdentifier,
companionBundleIdentifier: companionBundleIdentifier,
- isSettingsVisible: isSettingsVisible
+ isCodexAvailable: codexAvailabilityMonitor.isCodexAvailable
)
let shouldShow = visibilityState.shouldShow(
- automaticVisibilityAllowed: automaticVisibilityAllowed,
- isSettingsVisible: isSettingsVisible
+ automaticVisibilityAllowed: automaticVisibilityAllowed
+ )
+ let action = MainWindowAutomaticPresentationPolicy.action(
+ shouldShow: shouldShow,
+ isApplicationHidden: NSApp.isHidden,
+ isWindowVisible: window.isVisible,
+ isWindowMiniaturized: window.isMiniaturized
)
- if shouldShow {
+ switch action {
+ case .show:
if window.isMiniaturized {
window.deminiaturize(nil)
}
@@ -2350,8 +2359,10 @@ struct WindowConfigurator: NSViewRepresentable {
NSApp.unhideWithoutActivation()
}
MainWindowFramePersistence.showPreservingFrame(window: window)
- } else {
+ case .hide:
orderOutMainWindow(window)
+ case .none:
+ break
}
}
diff --git a/Sources/CodexNotesProbe/MainWindowFramePersistence.swift b/Sources/CodexNotesProbe/MainWindowFramePersistence.swift
index f9935df..51120f3 100644
--- a/Sources/CodexNotesProbe/MainWindowFramePersistence.swift
+++ b/Sources/CodexNotesProbe/MainWindowFramePersistence.swift
@@ -7,6 +7,11 @@ enum MainWindowFramePersistencePlan: Equatable {
}
enum MainWindowFramePersistence {
+ private struct ParsedFrameDescriptor {
+ let windowFrame: NSRect
+ let screenFrame: NSRect
+ }
+
static let autosaveName = "CodexNotes.MainWindow"
static let autosaveDefaultsKey = "NSWindow Frame \(autosaveName)"
@@ -143,6 +148,45 @@ enum MainWindowFramePersistence {
defaults.set(window.frameDescriptor, forKey: autosaveDefaultsKey)
}
+ @MainActor
+ @discardableResult
+ static func restoreDefaultSize(
+ window: NSWindow,
+ visibleFrames suppliedVisibleFrames: [NSRect]? = nil,
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ let trustedFrame = savedWindowFrame(
+ forKey: autosaveDefaultsKey,
+ in: defaults
+ ) ?? window.frame
+ let visibleFrames = suppliedVisibleFrames
+ ?? NSScreen.screens.map(\.visibleFrame)
+ let fallbackVisibleFrame: NSRect?
+ if suppliedVisibleFrames != nil {
+ fallbackVisibleFrame = visibleFrames.first
+ } else {
+ fallbackVisibleFrame = window.screen?.visibleFrame
+ ?? NSScreen.main?.visibleFrame
+ ?? visibleFrames.first
+ }
+ guard let visibleFrame = MainWindowInitialPlacementPolicy.visibleFrame(
+ containingMostOf: trustedFrame,
+ among: visibleFrames
+ ) ?? fallbackVisibleFrame else {
+ return false
+ }
+
+ let restoredFrame = MainWindowInitialPlacementPolicy
+ .frameByRestoringDefaultSize(
+ from: trustedFrame,
+ in: visibleFrame
+ )
+ _ = window.setFrameAutosaveName("")
+ window.setFrame(restoredFrame, display: window.isVisible)
+ persist(window: window, defaults: defaults)
+ return true
+ }
+
static func preferredLegacyFrame(in defaults: UserDefaults) -> String? {
defaults.dictionaryRepresentation()
.compactMap { key, value -> (key: String, frame: String)? in
@@ -182,17 +226,51 @@ enum MainWindowFramePersistence {
return frame
}
+ private static func savedWindowFrame(
+ forKey key: String,
+ in defaults: UserDefaults
+ ) -> NSRect? {
+ guard let frame = defaults.string(forKey: key) else {
+ return nil
+ }
+ return parsedFrameDescriptor(frame)?.windowFrame
+ }
+
private static func isValidFrame(_ frame: String) -> Bool {
+ parsedFrameDescriptor(frame) != nil
+ }
+
+ private static func parsedFrameDescriptor(
+ _ frame: String
+ ) -> ParsedFrameDescriptor? {
let components = frame.split(whereSeparator: { $0.isWhitespace })
guard components.count == 8 else {
- return false
+ return nil
}
let values = components.compactMap { Double($0) }
guard values.count == components.count,
values.allSatisfy(\.isFinite) else {
- return false
+ return nil
+ }
+ guard values[2] > 0,
+ values[3] > 0,
+ values[6] > 0,
+ values[7] > 0 else {
+ return nil
}
- return values[2] > 0 && values[3] > 0 &&
- values[6] > 0 && values[7] > 0
+ return ParsedFrameDescriptor(
+ windowFrame: NSRect(
+ x: values[0],
+ y: values[1],
+ width: values[2],
+ height: values[3]
+ ),
+ screenFrame: NSRect(
+ x: values[4],
+ y: values[5],
+ width: values[6],
+ height: values[7]
+ )
+ )
}
}
diff --git a/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift b/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift
index f168ef9..ca4bbab 100644
--- a/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift
+++ b/Sources/CodexNotesProbe/MainWindowInitialPlacementPolicy.swift
@@ -8,13 +8,19 @@ struct MainWindowDisplayGeometry: Equatable {
}
enum MainWindowInitialPlacementPolicy {
- static let fallbackSize = NSSize(width: 440, height: 680)
- static let widthRatio: CGFloat = 0.29
- static let heightRatio: CGFloat = 0.72
+ // Keep the scene's pre-configuration size stable so existing users do not
+ // briefly see a different frame before their saved window is restored.
+ static let swiftUIBootstrapSize = NSSize(width: 440, height: 680)
+
+ // The compact first-run frame leaves more room for Codex while remaining
+ // comfortably above ContentView's 340 x 520 resizing floor.
+ static let fallbackSize = NSSize(width: 420, height: 640)
+ static let widthRatio: CGFloat = 0.27
+ static let heightRatio: CGFloat = 0.66
static let minimumWidth: CGFloat = 400
- static let maximumWidth: CGFloat = 548
- static let minimumHeight: CGFloat = 640
- static let maximumHeight: CGFloat = 725
+ static let maximumWidth: CGFloat = 480
+ static let minimumHeight: CGFloat = 600
+ static let maximumHeight: CGFloat = 680
static let companionGap: CGFloat = 8
static func preferredSize(forVisibleFrameSize visibleSize: CGSize?) -> NSSize {
@@ -83,6 +89,41 @@ enum MainWindowInitialPlacementPolicy {
return NSRect(x: x, y: y, width: size.width, height: size.height)
}
+ static func frameByRestoringDefaultSize(
+ from currentFrame: NSRect,
+ in visibleFrame: 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)
+ let x = currentFrame.minX.clamped(
+ to: visibleFrame.minX...maximumX
+ )
+ let y = (currentFrame.maxY - size.height).clamped(
+ to: visibleFrame.minY...maximumY
+ )
+ return NSRect(origin: NSPoint(x: x, y: y), size: size)
+ }
+
+ static func visibleFrame(
+ containingMostOf frame: NSRect,
+ among visibleFrames: [NSRect]
+ ) -> NSRect? {
+ visibleFrames
+ .compactMap { visibleFrame -> (frame: NSRect, area: CGFloat)? in
+ let intersection = visibleFrame.intersection(frame)
+ guard !intersection.isNull, !intersection.isEmpty else {
+ return nil
+ }
+ return (
+ visibleFrame,
+ intersection.width * intersection.height
+ )
+ }
+ .max { lhs, rhs in lhs.area < rhs.area }?
+ .frame
+ }
+
static func display(
containingQuartzBounds bounds: CGRect,
among displays: [MainWindowDisplayGeometry]
diff --git a/Sources/CodexNotesProbe/SettingsView.swift b/Sources/CodexNotesProbe/SettingsView.swift
index 80f9593..018b46c 100644
--- a/Sources/CodexNotesProbe/SettingsView.swift
+++ b/Sources/CodexNotesProbe/SettingsView.swift
@@ -38,12 +38,24 @@ enum SettingsGeneralPresentation {
}
}
+enum SettingsWindowLevelPolicy {
+ static let active = NSWindow.Level(
+ rawValue: NSWindow.Level.floating.rawValue + 1
+ )
+}
+
+enum SettingsEditorPresentation {
+ static let controlLabelWidth: CGFloat = 96
+ static let restoreConfirmationDurationNanoseconds: UInt64 = 1_500_000_000
+}
+
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?
+ @State private var windowSizeRestoreConfirmationID: UUID?
@AppStorage(AppLanguagePreference.key)
private var storedLanguage = AppLanguagePreference.defaultValue.rawValue
@AppStorage(EditorFontSizePreference.key)
@@ -172,7 +184,10 @@ struct SettingsView: View {
HStack(spacing: 12) {
Text(L10n.text(.settingsEditorFontSize))
.font(.subheadline.weight(.medium))
- .frame(width: 72, alignment: .leading)
+ .frame(
+ width: SettingsEditorPresentation.controlLabelWidth,
+ alignment: .leading
+ )
Slider(
value: editorFontSize,
@@ -192,7 +207,10 @@ struct SettingsView: View {
HStack(spacing: 12) {
Text(L10n.text(.settingsEditorLineSpacing))
.font(.subheadline.weight(.medium))
- .frame(width: 72, alignment: .leading)
+ .frame(
+ width: SettingsEditorPresentation.controlLabelWidth,
+ alignment: .leading
+ )
Slider(
value: editorLineSpacing,
@@ -209,22 +227,6 @@ struct SettingsView: View {
.accessibilityHidden(true)
}
- Text(L10n.text(.settingsEditorPreviewText))
- .font(.system(size: editorFontSize.wrappedValue, design: .monospaced))
- .lineSpacing(editorLineSpacing.wrappedValue)
- .foregroundStyle(palette.primaryText.color)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(12)
- .background(
- palette.editorBackground.color,
- in: RoundedRectangle(cornerRadius: 9)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 9)
- .stroke(palette.separator.color, lineWidth: 0.7)
- )
- .accessibilityLabel(Text(L10n.text(.settingsEditorPreviewAccessibilityLabel)))
-
editorFooter
}
@@ -335,29 +337,58 @@ struct SettingsView: View {
) { _ in
loginItemService.refresh()
}
+ .onReceive(
+ NotificationCenter.default.publisher(
+ for: MainWindowCommandNotification.didRestoreDefaultSize
+ )
+ ) { _ in
+ windowSizeRestoreConfirmationID = UUID()
+ announceForAccessibility(
+ L10n.text(.settingsEditorWindowSizeRestored)
+ )
+ }
+ .task(id: windowSizeRestoreConfirmationID) {
+ guard let confirmationID = windowSizeRestoreConfirmationID else {
+ return
+ }
+ do {
+ try await Task.sleep(
+ nanoseconds: SettingsEditorPresentation
+ .restoreConfirmationDurationNanoseconds
+ )
+ } catch {
+ return
+ }
+ guard confirmationID == windowSizeRestoreConfirmationID else {
+ return
+ }
+ windowSizeRestoreConfirmationID = nil
+ }
+ .onDisappear {
+ windowSizeRestoreConfirmationID = nil
+ }
}
@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)
+ VStack(alignment: .leading, spacing: 8) {
+ editorWindowResizeHint
+ .fixedSize(horizontal: false, vertical: true)
- HStack {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) {
Spacer()
+ editorRestoreWindowSizeButton
+ .fixedSize(horizontal: true, vertical: false)
+ editorRestoreDefaultsButton
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ VStack(alignment: .trailing, spacing: 6) {
+ editorRestoreWindowSizeButton
editorRestoreDefaultsButton
}
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
@@ -374,6 +405,26 @@ struct SettingsView: View {
.accessibilityElement(children: .combine)
}
+ private var editorRestoreWindowSizeButton: some View {
+ Button {
+ NotificationCenter.default.post(
+ name: MainWindowCommandNotification.restoreDefaultSize,
+ object: nil
+ )
+ } label: {
+ Label(
+ L10n.text(.settingsEditorRestoreWindowSize),
+ systemImage: windowSizeRestoreConfirmationID == nil
+ ? "arrow.counterclockwise"
+ : "checkmark"
+ )
+ }
+ .accessibilityHint(
+ Text(L10n.text(.settingsEditorRestoreWindowSizeAccessibilityHint))
+ )
+ .help(L10n.text(.settingsEditorRestoreWindowSizeAccessibilityHint))
+ }
+
private var editorRestoreDefaultsButton: some View {
Button(L10n.text(.settingsEditorRestoreDefaults)) {
storedEditorFontSize = EditorFontSizePreference.defaultValue
@@ -390,6 +441,17 @@ struct SettingsView: View {
.accessibilityHint(Text(L10n.text(.settingsEditorRestoreDefaultsAccessibilityHint)))
}
+ private func announceForAccessibility(_ message: String) {
+ NSAccessibility.post(
+ element: NSApplication.shared,
+ notification: .announcementRequested,
+ userInfo: [
+ .announcement: message,
+ .priority: NSAccessibilityPriorityLevel.high.rawValue,
+ ]
+ )
+ }
+
private var globalHotKeyControls: some View {
GlobalHotKeyRecorderView(
shortcutDisplayName: globalHotKeyController.currentShortcut == nil
@@ -729,7 +791,9 @@ struct SettingsWindowActivator: NSViewRepresentable {
final class Coordinator {
private weak var window: NSWindow?
private var didBecomeKeyObserver: NSObjectProtocol?
+ private var didResignKeyObserver: NSObjectProtocol?
private var willCloseObserver: NSObjectProtocol?
+ private var originalWindowLevel: NSWindow.Level?
private var isVisible = false
private var isWindowClosing = false
private var isInvalidated = false
@@ -750,36 +814,64 @@ struct SettingsWindowActivator: NSViewRepresentable {
if window.isVisible {
isWindowClosing = false
setVisible(true)
+ if window.isKeyWindow {
+ elevateSettingsWindow(window)
+ }
}
return
}
detach()
self.window = window
+ originalWindowLevel = window.level
isWindowClosing = false
didBecomeKeyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: window,
queue: .main
- ) { [weak self] _ in
+ ) { [weak self, weak window] _ in
+ guard let window else { return }
self?.isWindowClosing = false
self?.setVisible(true)
+ self?.elevateSettingsWindow(window)
+ }
+ didResignKeyObserver = NotificationCenter.default.addObserver(
+ forName: NSWindow.didResignKeyNotification,
+ object: window,
+ queue: .main
+ ) { [weak self, weak window] _ in
+ guard let window else { return }
+ self?.restoreOriginalWindowLevel(window)
}
willCloseObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
- ) { [weak self] _ in
+ ) { [weak self, weak window] _ in
+ guard let window else { return }
self?.isWindowClosing = true
self?.setVisible(false)
+ self?.restoreOriginalWindowLevel(window)
}
guard !isWindowClosing else { return }
setVisible(true)
+ elevateSettingsWindow(window)
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
}
+ private func elevateSettingsWindow(_ window: NSWindow) {
+ guard self.window === window else { return }
+ window.level = SettingsWindowLevelPolicy.active
+ window.orderFront(nil)
+ }
+
+ private func restoreOriginalWindowLevel(_ window: NSWindow) {
+ guard self.window === window else { return }
+ window.level = originalWindowLevel ?? .normal
+ }
+
private func applyTheme(
to window: NSWindow,
appearanceName: NSAppearance.Name?,
@@ -801,12 +893,20 @@ struct SettingsWindowActivator: NSViewRepresentable {
if let didBecomeKeyObserver {
NotificationCenter.default.removeObserver(didBecomeKeyObserver)
}
+ if let didResignKeyObserver {
+ NotificationCenter.default.removeObserver(didResignKeyObserver)
+ }
if let willCloseObserver {
NotificationCenter.default.removeObserver(willCloseObserver)
}
didBecomeKeyObserver = nil
+ didResignKeyObserver = nil
willCloseObserver = nil
+ if let window {
+ restoreOriginalWindowLevel(window)
+ }
window = nil
+ originalWindowLevel = nil
isWindowClosing = false
setVisible(false)
}
diff --git a/Sources/CodexNotesProbe/StatusItemController.swift b/Sources/CodexNotesProbe/StatusItemController.swift
index 952e51d..6a018f8 100644
--- a/Sources/CodexNotesProbe/StatusItemController.swift
+++ b/Sources/CodexNotesProbe/StatusItemController.swift
@@ -20,12 +20,8 @@ enum MainWindowTogglePolicy {
static func action(
isApplicationHidden: Bool,
isWindowVisible: Bool,
- isWindowMiniaturized: Bool,
- isSettingsVisible: Bool = false
+ isWindowMiniaturized: Bool
) -> MainWindowToggleAction {
- if isSettingsVisible {
- return .show
- }
if isApplicationHidden || !isWindowVisible || isWindowMiniaturized {
return .show
}
@@ -33,6 +29,28 @@ enum MainWindowTogglePolicy {
}
}
+enum MainWindowAutomaticPresentationAction: Equatable {
+ case show
+ case hide
+ case none
+}
+
+enum MainWindowAutomaticPresentationPolicy {
+ static func action(
+ shouldShow: Bool,
+ isApplicationHidden: Bool,
+ isWindowVisible: Bool,
+ isWindowMiniaturized: Bool
+ ) -> MainWindowAutomaticPresentationAction {
+ if shouldShow {
+ return isApplicationHidden || !isWindowVisible || isWindowMiniaturized
+ ? .show
+ : .none
+ }
+ return isWindowVisible || isWindowMiniaturized ? .hide : .none
+ }
+}
+
enum MainWindowVisibilityPreference: Equatable {
case automatic
case hidden
@@ -84,10 +102,9 @@ struct MainWindowVisibilityState {
}
func shouldShow(
- automaticVisibilityAllowed: Bool,
- isSettingsVisible: Bool
+ automaticVisibilityAllowed: Bool
) -> Bool {
- guard !isSettingsVisible, !isAwaitingCodexActivation else {
+ guard !isAwaitingCodexActivation else {
return false
}
switch preference {
@@ -120,6 +137,12 @@ enum StatusItemInteractionPolicy {
enum MainWindowCommandNotification {
static let toggle = Notification.Name("CodexNotesToggleMainWindow")
static let show = Notification.Name("CodexNotesShowMainWindow")
+ static let restoreDefaultSize = Notification.Name(
+ "CodexNotesRestoreDefaultMainWindowSize"
+ )
+ static let didRestoreDefaultSize = Notification.Name(
+ "CodexNotesDidRestoreDefaultMainWindowSize"
+ )
static let hiddenUsingCloseButton = Notification.Name(
"CodexNotesMainWindowHiddenUsingCloseButton"
)
diff --git a/Tests/CodexNotesCoreTests/CompanionVisibilityPolicyTests.swift b/Tests/CodexNotesCoreTests/CompanionVisibilityPolicyTests.swift
index 572140c..6451ffa 100644
--- a/Tests/CodexNotesCoreTests/CompanionVisibilityPolicyTests.swift
+++ b/Tests/CodexNotesCoreTests/CompanionVisibilityPolicyTests.swift
@@ -8,7 +8,8 @@ final class CompanionVisibilityPolicyTests: XCTestCase {
XCTAssertTrue(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: "com.openai.codex",
- companionBundleIdentifier: companionBundleIdentifier
+ companionBundleIdentifier: companionBundleIdentifier,
+ isCodexAvailable: true
)
)
}
@@ -17,7 +18,8 @@ final class CompanionVisibilityPolicyTests: XCTestCase {
XCTAssertTrue(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: companionBundleIdentifier,
- companionBundleIdentifier: companionBundleIdentifier
+ companionBundleIdentifier: companionBundleIdentifier,
+ isCodexAvailable: true
)
)
}
@@ -26,7 +28,8 @@ final class CompanionVisibilityPolicyTests: XCTestCase {
XCTAssertFalse(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: "com.google.Chrome",
- companionBundleIdentifier: companionBundleIdentifier
+ companionBundleIdentifier: companionBundleIdentifier,
+ isCodexAvailable: true
)
)
}
@@ -35,27 +38,28 @@ final class CompanionVisibilityPolicyTests: XCTestCase {
XCTAssertFalse(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: nil,
- companionBundleIdentifier: companionBundleIdentifier
+ companionBundleIdentifier: companionBundleIdentifier,
+ isCodexAvailable: true
)
)
}
- func testHidesMainWindowWhileSettingsIsVisible() {
+ func testHidesWhenCodexIsUnavailableEvenIfSettingsIsFrontmost() {
XCTAssertFalse(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: companionBundleIdentifier,
companionBundleIdentifier: companionBundleIdentifier,
- isSettingsVisible: true
+ isCodexAvailable: false
)
)
}
- func testShowsMainWindowAgainAfterSettingsCloses() {
+ func testSettingsFrontmostDoesNotSuppressAnAvailableCodexCompanion() {
XCTAssertTrue(
CompanionVisibilityPolicy.shouldShow(
frontmostBundleIdentifier: companionBundleIdentifier,
companionBundleIdentifier: companionBundleIdentifier,
- isSettingsVisible: false
+ isCodexAvailable: true
)
)
}
diff --git a/Tests/CodexNotesCoreTests/LocalizationTests.swift b/Tests/CodexNotesCoreTests/LocalizationTests.swift
index 7f74959..719f872 100644
--- a/Tests/CodexNotesCoreTests/LocalizationTests.swift
+++ b/Tests/CodexNotesCoreTests/LocalizationTests.swift
@@ -151,11 +151,27 @@ final class LocalizationTests: XCTestCase {
XCTAssertEqual(english.text(.settingsEditorTitle), "Editor")
XCTAssertEqual(
chinese.text(.settingsEditorWindowResizeHint),
- "拖动笔记窗口边缘可调整大小,尺寸会自动记住。"
+ "字号和行距会实时应用到笔记窗口;拖动窗口边缘可调整大小,尺寸会自动记住。"
)
XCTAssertEqual(
english.text(.settingsEditorWindowResizeHint),
- "Drag a note window edge to resize. CodexNotes remembers the size."
+ "Text size and line spacing update the note window live. Drag its edges to resize it; CodexNotes remembers the size."
+ )
+ XCTAssertEqual(
+ chinese.text(.settingsEditorRestoreWindowSize),
+ "恢复笔记窗口默认大小"
+ )
+ XCTAssertEqual(
+ english.text(.settingsEditorRestoreWindowSize),
+ "Restore Default Note Window Size"
+ )
+ XCTAssertEqual(
+ chinese.text(.settingsEditorWindowSizeRestored),
+ "笔记窗口已恢复默认大小"
+ )
+ XCTAssertEqual(
+ english.text(.settingsEditorWindowSizeRestored),
+ "Note window restored to its default size"
)
XCTAssertEqual(chinese.text(.settingsGeneralTitle), "通用")
XCTAssertEqual(english.text(.settingsGeneralTitle), "General")
diff --git a/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift b/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift
index c3f6a74..04a3265 100644
--- a/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift
+++ b/Tests/CodexNotesProbeTests/MainWindowFramePersistenceTests.swift
@@ -276,7 +276,7 @@ final class MainWindowFramePersistenceTests: XCTestCase {
}
XCTAssertEqual(firstPlacementCount, 1)
- XCTAssertEqual(firstWindow.frame.size, NSSize(width: 548, height: 725))
+ XCTAssertEqual(firstWindow.frame.size, NSSize(width: 480, height: 680))
let relaunchedWindow = makeWindow(
frame: testFrame(xOffset: 40, yOffset: 40)
@@ -300,7 +300,113 @@ final class MainWindowFramePersistenceTests: XCTestCase {
XCTAssertEqual(relaunchPlacementCount, 0)
XCTAssertEqual(
relaunchedWindow.frame.size,
- NSSize(width: 548, height: 725)
+ NSSize(width: 480, height: 680)
+ )
+ }
+ }
+
+ func testRestoreDefaultSizeUsesStableHiddenFrameAndPersistsForRelaunch() {
+ withDefaults { defaults in
+ let trustedFrame = NSRect(
+ x: 300,
+ y: 200,
+ width: 452,
+ height: 638
+ )
+ seedStableFrame(trustedFrame, defaults: defaults)
+
+ let hiddenWindow = makeWindow(
+ frame: NSRect(x: 80, y: 90, width: 360, height: 560)
+ )
+ defer { hiddenWindow.orderOut(nil) }
+ XCTAssertFalse(hiddenWindow.isVisible)
+
+ XCTAssertTrue(
+ MainWindowFramePersistence.restoreDefaultSize(
+ window: hiddenWindow,
+ visibleFrames: [
+ NSRect(x: 0, y: 0, width: 2_560, height: 1_409)
+ ],
+ defaults: defaults
+ )
+ )
+ let expectedFrame = NSRect(
+ x: 300,
+ y: 158,
+ width: 480,
+ height: 680
+ )
+ assertFrame(hiddenWindow.frame, equals: expectedFrame)
+ XCTAssertFalse(hiddenWindow.isVisible)
+
+ let relaunchedWindow = makeWindow(
+ frame: testFrame(xOffset: 20, yOffset: 20)
+ )
+ defer { relaunchedWindow.orderOut(nil) }
+ var initialPlacementCount = 0
+ MainWindowFramePersistence.configure(
+ window: relaunchedWindow,
+ defaults: defaults
+ ) {
+ initialPlacementCount += 1
+ }
+
+ XCTAssertEqual(initialPlacementCount, 0)
+ assertFrame(relaunchedWindow.frame, equals: expectedFrame)
+ }
+ }
+
+ func testRestoreDefaultSizeCommandKeepsMainWindowHiddenAndConfirmsSuccess() {
+ withStandardFrameDefaults { defaults in
+ guard let visibleFrame = NSScreen.main?.visibleFrame else {
+ XCTFail("A visible screen is required for the window test")
+ return
+ }
+ let stableFrame = NSRect(
+ x: visibleFrame.minX + 24,
+ y: visibleFrame.minY + 24,
+ width: 452,
+ height: 638
+ )
+ seedStableFrame(stableFrame, defaults: defaults)
+
+ let window = makeWindow(frame: stableFrame)
+ window.identifier = CodexNotesWindowIdentifier.main
+ let coordinator = WindowConfigurator.Coordinator()
+ var restoredWindow: NSWindow?
+ let observer = NotificationCenter.default.addObserver(
+ forName: MainWindowCommandNotification.didRestoreDefaultSize,
+ object: nil,
+ queue: .main
+ ) { notification in
+ restoredWindow = notification.object as? NSWindow
+ }
+ defer {
+ NotificationCenter.default.removeObserver(observer)
+ coordinator.invalidate()
+ window.orderOut(nil)
+ }
+
+ coordinator.attach(to: window, languageRevision: "test")
+ window.orderOut(nil)
+ NotificationCenter.default.post(
+ name: MainWindowCommandNotification.restoreDefaultSize,
+ object: nil
+ )
+
+ XCTAssertTrue(restoredWindow === window)
+ XCTAssertFalse(window.isVisible)
+ XCTAssertEqual(
+ window.frame.size,
+ MainWindowInitialPlacementPolicy.preferredSize(
+ forVisibleFrameSize: visibleFrame.size
+ )
+ )
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ window.frameDescriptor
)
}
}
diff --git a/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift b/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift
index 1125ae0..ff879ac 100644
--- a/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift
+++ b/Tests/CodexNotesProbeTests/MainWindowInitialPlacementPolicyTests.swift
@@ -5,11 +5,11 @@ import XCTest
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)),
+ (CGSize(width: 1_440, height: 875), CGSize(width: 400, height: 600)),
+ (CGSize(width: 1_512, height: 949), CGSize(width: 408, height: 626)),
+ (CGSize(width: 1_728, height: 1_053), CGSize(width: 467, height: 680)),
+ (CGSize(width: 2_560, height: 1_409), CGSize(width: 480, height: 680)),
+ (CGSize(width: 6_016, height: 3_290), CGSize(width: 480, height: 680)),
]
for (visibleSize, expected) in cases {
@@ -28,18 +28,22 @@ final class MainWindowInitialPlacementPolicyTests: XCTestCase {
MainWindowInitialPlacementPolicy.preferredSize(
forVisibleFrameSize: nil
),
- NSSize(width: 440, height: 680)
+ NSSize(width: 420, height: 640)
)
XCTAssertEqual(
MainWindowInitialPlacementPolicy.preferredSize(
forVisibleFrameSize: .zero
),
- NSSize(width: 440, height: 680)
+ NSSize(width: 420, height: 640)
)
XCTAssertEqual(
MainWindowInitialPlacementPolicy.preferredSize(
forVisibleFrameSize: CGSize(width: CGFloat.nan, height: 900)
),
+ NSSize(width: 420, height: 640)
+ )
+ XCTAssertEqual(
+ MainWindowInitialPlacementPolicy.swiftUIBootstrapSize,
NSSize(width: 440, height: 680)
)
}
@@ -60,19 +64,19 @@ final class MainWindowInitialPlacementPolicyTests: XCTestCase {
in: visibleFrame,
codexFrame: NSRect(x: 100, y: 100, width: 700, height: 700)
)
- XCTAssertEqual(right.origin, NSPoint(x: 808, y: 152))
+ XCTAssertEqual(right.origin, NSPoint(x: 808, y: 200))
let left = MainWindowInitialPlacementPolicy.initialFrame(
in: visibleFrame,
codexFrame: NSRect(x: 600, y: 100, width: 700, height: 700)
)
- XCTAssertEqual(left.origin, NSPoint(x: 174, y: 152))
+ XCTAssertEqual(left.origin, NSPoint(x: 192, y: 200))
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))
+ XCTAssertEqual(overlap.origin, NSPoint(x: 932, y: 200))
XCTAssertTrue(visibleFrame.contains(overlap))
}
@@ -81,7 +85,49 @@ final class MainWindowInitialPlacementPolicyTests: XCTestCase {
in: NSRect(x: 200, y: 100, width: 1_440, height: 900),
codexFrame: nil
)
- XCTAssertEqual(frame, NSRect(x: 711, y: 226, width: 418, height: 648))
+ XCTAssertEqual(frame, NSRect(x: 720, y: 250, width: 400, height: 600))
+ }
+
+ func testRestoringDefaultSizePreservesTopLeftAndClampsToVisibleFrame() {
+ let studioDisplay = NSRect(
+ x: 0,
+ y: 0,
+ width: 2_560,
+ height: 1_409
+ )
+ XCTAssertEqual(
+ MainWindowInitialPlacementPolicy.frameByRestoringDefaultSize(
+ from: NSRect(x: 1_975, y: 379, width: 452, height: 638),
+ in: studioDisplay
+ ),
+ NSRect(x: 1_975, y: 337, width: 480, height: 680)
+ )
+
+ XCTAssertEqual(
+ MainWindowInitialPlacementPolicy.frameByRestoringDefaultSize(
+ from: NSRect(x: 1_300, y: 40, width: 200, height: 200),
+ in: NSRect(x: 0, y: 0, width: 1_440, height: 900)
+ ),
+ NSRect(x: 1_040, y: 0, width: 400, height: 600)
+ )
+ }
+
+ func testDefaultSizeSelectsTheVisibleFrameWithTheLargestIntersection() {
+ let left = NSRect(x: -1_920, y: 0, width: 1_920, height: 1_080)
+ let main = NSRect(x: 0, y: 0, width: 1_440, height: 900)
+
+ XCTAssertEqual(
+ MainWindowInitialPlacementPolicy.visibleFrame(
+ containingMostOf: NSRect(
+ x: -1_000,
+ y: 100,
+ width: 1_200,
+ height: 700
+ ),
+ among: [main, left]
+ ),
+ left
+ )
}
func testDisplaySelectionUsesLargestQuartzIntersection() {
diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
index 3d4a485..de12d96 100644
--- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
+++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
@@ -2004,7 +2004,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(range)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let snapshot = try XCTUnwrap(harness.controller.currentSelectionSnapshot())
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
@@ -2048,7 +2051,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(firstRange)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let staleSelectionPill = try XCTUnwrap(
harness.textView.visibleSelectionMovePill
)
@@ -2058,7 +2064,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
staleSelectionPill.performClick(nil)
XCTAssertEqual(moveCount, 0)
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let staleIdentityPill = try XCTUnwrap(
harness.textView.visibleSelectionMovePill
)
@@ -2077,7 +2086,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.textView.selectionMoveConfiguration = enabledConfiguration
harness.textView.setSelectedRange(firstRange)
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let staleDestinationPill = try XCTUnwrap(
harness.textView.visibleSelectionMovePill
)
@@ -2124,7 +2136,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange((original as NSString).range(of: "正文"))
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionToolbar)
XCTAssertFalse(
try XCTUnwrap(harness.textView.visibleSelectionMovePill).isEnabled
@@ -2141,14 +2156,20 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.textView.selectionMoveConfiguration = enabledConfiguration
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.textView.isEditable = false
XCTAssertNil(harness.textView.visibleSelectionMovePill)
harness.textView.isEditable = true
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.textView.insertText(
"字",
@@ -2159,7 +2180,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
let replacementRange = (harness.textView.string as NSString).range(of: "正文")
harness.textView.setSelectedRange(replacementRange)
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.textView.setMarkedText(
"zhengwen",
@@ -2193,13 +2217,19 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(range)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.textView.cancelOperation(nil)
XCTAssertNil(harness.textView.visibleSelectionMovePill)
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.scrollView.contentView.scroll(to: NSPoint(x: 0, y: 40))
harness.scrollView.reflectScrolledClipView(harness.scrollView.contentView)
@@ -2208,7 +2238,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.scrollView.contentView.scroll(to: .zero)
harness.scrollView.reflectScrolledClipView(harness.scrollView.contentView)
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
harness.coordinator.isApplyingExternalText = true
harness.textView.setSelectedRange(
@@ -2220,7 +2253,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
XCTAssertNil(harness.textView.visibleSelectionMovePill)
harness.textView.setSelectedRange(range)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
NotificationCenter.default.post(
name: NSWindow.didResignKeyNotification,
@@ -2240,7 +2276,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.window.reportsKeyWindow = true
harness.textView.selectionDidChangeForMovePill()
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertNotNil(harness.textView.visibleSelectionMovePill)
XCTAssertTrue(harness.window.makeFirstResponder(nil))
XCTAssertNil(harness.textView.visibleSelectionMovePill)
@@ -2286,7 +2325,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange((text as NSString).range(of: "选择正文"))
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let bold = try XCTUnwrap(harness.textView.visibleSelectionToolbarBoldButton)
@@ -2348,7 +2390,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(selectedRange)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let originalToolbar = try XCTUnwrap(
harness.textView.visibleSelectionToolbar
@@ -2429,7 +2474,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: .clear
)
harness.textView.setSelectedRange(selection)
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let bold = try XCTUnwrap(
@@ -2507,7 +2552,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: .separatorColor
)
harness.textView.setSelectedRange(NSRange(location: 0, length: 2))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let surface = try XCTUnwrap(colorComponents(of: toolbar.layer?.backgroundColor))
@@ -2558,7 +2603,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: borderColor
)
harness.textView.setSelectedRange(NSRange(location: 0, length: 2))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let surface = try XCTUnwrap(colorComponents(of: toolbar.layer?.backgroundColor))
@@ -2612,7 +2657,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: .separatorColor
)
harness.textView.setSelectedRange(item.selection)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let bold = try XCTUnwrap(harness.textView.visibleSelectionToolbarBoldButton)
let highlight = try XCTUnwrap(
@@ -2651,7 +2699,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: .separatorColor
)
harness.textView.setSelectedRange(NSRange(location: 0, length: 2))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
XCTAssertEqual(toolbar.frame.size, NSSize(width: 64, height: 34))
@@ -2699,7 +2747,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
borderColor: .separatorColor
)
harness.textView.setSelectedRange(NSRange(location: 0, length: 2))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let lightSurface = try XCTUnwrap(
@@ -2743,7 +2791,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange((text as NSString).range(of: "选择"))
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
let move = try XCTUnwrap(harness.textView.visibleSelectionToolbarMoveButton)
@@ -2760,7 +2811,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
let harness = makeHarness(text: text, textWidth: 360)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(range)
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let toolbar = try XCTUnwrap(harness.textView.visibleSelectionToolbar)
XCTAssertEqual(toolbar.frame.size, NSSize(width: 64, height: 34))
@@ -2775,7 +2826,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
let harness = makeHarness(text: original, textWidth: 420)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(originalRange)
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
try XCTUnwrap(
harness.textView.visibleSelectionToolbarBoldButton
@@ -2841,7 +2892,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
let harness = makeHarness(text: original, textWidth: 360)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange((original as NSString).range(of: "格式"))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
try XCTUnwrap(
harness.textView.visibleSelectionToolbarBoldButton
@@ -2909,7 +2960,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
harness.textView.setSelectedRange(
NSRange(location: 0, length: (text as NSString).length)
)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
let bold = try XCTUnwrap(harness.textView.visibleSelectionToolbarBoldButton)
let highlight = try XCTUnwrap(
@@ -2964,7 +3018,10 @@ final class PlainMarkdownTextViewTests: XCTestCase {
)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange(item.range)
- runMainLoop()
+ assertSelectionToolbarAppears(
+ in: harness.textView,
+ includingMoveButton: true
+ )
XCTAssertFalse(
try XCTUnwrap(
@@ -5123,7 +5180,7 @@ final class PlainMarkdownTextViewTests: XCTestCase {
let harness = makeHarness(text: text, textWidth: 360)
harness.textView.selectionMovePresentationDelay = 0
harness.textView.setSelectedRange((text as NSString).range(of: "这里"))
- runMainLoop()
+ assertSelectionToolbarAppears(in: harness.textView)
let staleBoldButton = try XCTUnwrap(
harness.textView.visibleSelectionToolbarBoldButton
)
@@ -6094,6 +6151,24 @@ final class PlainMarkdownTextViewTests: XCTestCase {
return condition()
}
+ private func assertSelectionToolbarAppears(
+ in textView: CheckboxTextView,
+ includingMoveButton: Bool = false,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ XCTAssertTrue(
+ waitUntil {
+ textView.visibleSelectionToolbar != nil
+ && (!includingMoveButton
+ || textView.visibleSelectionMovePill != nil)
+ },
+ "Selection toolbar did not appear before the timeout.",
+ file: file,
+ line: line
+ )
+ }
+
private func visibleMarkdownLength(
_ markdown: String,
throughUTF16Offset offset: Int
diff --git a/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift b/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift
index 3aa76f1..5f16bfc 100644
--- a/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift
+++ b/Tests/CodexNotesProbeTests/SettingsEditorPresentationTests.swift
@@ -1,26 +1,80 @@
+import AppKit
+import CodexNotesCore
import XCTest
+@testable import CodexNotesProbe
final class SettingsEditorPresentationTests: XCTestCase {
- func testResizeHintUsesAnAdaptiveAccessibleFooter() throws {
+ func testLocalizedEditorLabelsFitTheReservedControlColumn() {
+ let font = NSFont.systemFont(ofSize: 13, weight: .medium)
+
+ for preference in [
+ AppLanguagePreference.simplifiedChinese,
+ AppLanguagePreference.english,
+ ] {
+ let localization = AppLocalization(preference: preference)
+
+ for key in [
+ L10n.Key.settingsEditorFontSize,
+ L10n.Key.settingsEditorLineSpacing,
+ ] {
+ let label = localization.text(key)
+ let width = (label as NSString).size(
+ withAttributes: [.font: font]
+ ).width
+
+ XCTAssertLessThanOrEqual(
+ width,
+ SettingsEditorPresentation.controlLabelWidth,
+ "\(preference.rawValue) label \(label) is wider than the reserved column"
+ )
+ }
+ }
+ }
+
+ func testResizeHintAndIndependentRestoreActionsUseAnAdaptiveFooter() 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)"
+ ".fixedSize(horizontal: false, vertical: true)"
))
- XCTAssertTrue(source.contains("VStack(alignment: .leading, spacing: 8)"))
+ XCTAssertTrue(source.contains("HStack(spacing: 8)"))
+ XCTAssertTrue(source.contains("VStack(alignment: .trailing, spacing: 6)"))
XCTAssertTrue(source.contains(".settingsEditorWindowResizeHint"))
+ XCTAssertFalse(source.contains("settingsEditorPreviewText"))
+ XCTAssertFalse(source.contains("settingsEditorPreviewAccessibilityLabel"))
+ XCTAssertFalse(source.contains("settings.editor.preview_text"))
+ XCTAssertTrue(source.contains("value: editorFontSize"))
+ XCTAssertTrue(source.contains("value: editorLineSpacing"))
+ XCTAssertTrue(source.contains("editorRestoreWindowSizeButton"))
+ XCTAssertTrue(source.contains(".settingsEditorRestoreWindowSize"))
+ XCTAssertTrue(source.contains(
+ "Label(\n L10n.text(.settingsEditorRestoreWindowSize),"
+ ))
+ XCTAssertTrue(source.contains(
+ "MainWindowCommandNotification.restoreDefaultSize"
+ ))
+ XCTAssertTrue(source.contains(
+ "MainWindowCommandNotification.didRestoreDefaultSize"
+ ))
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 windowRestoreRange = try XCTUnwrap(
+ source.range(of: "editorRestoreWindowSizeButton")
+ )
let restoreRange = try XCTUnwrap(source.range(of: "editorRestoreDefaultsButton"))
- XCTAssertLessThan(hintRange.lowerBound, restoreRange.lowerBound)
+ XCTAssertLessThan(hintRange.lowerBound, windowRestoreRange.lowerBound)
+ XCTAssertLessThan(windowRestoreRange.lowerBound, restoreRange.lowerBound)
+ XCTAssertEqual(
+ SettingsEditorPresentation.restoreConfirmationDurationNanoseconds,
+ 1_500_000_000
+ )
}
private func repositorySource(at relativePath: String) throws -> String {
diff --git a/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift b/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift
index 491c12c..afdc793 100644
--- a/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift
+++ b/Tests/CodexNotesProbeTests/SettingsWindowActivatorTests.swift
@@ -51,4 +51,115 @@ final class SettingsWindowActivatorTests: XCTestCase {
XCTAssertTrue(window.isVisible)
XCTAssertEqual(visibilityChanges.last, true)
}
+
+ func testAttachElevatesSettingsAndCloseRestoresOriginalLevel() {
+ let window = makeWindow()
+ let originalLevel = window.level
+ let coordinator = SettingsWindowActivator.Coordinator()
+ defer {
+ coordinator.invalidate()
+ window.orderOut(nil)
+ }
+
+ coordinator.attach(
+ to: window,
+ appearanceName: nil,
+ backgroundColor: .windowBackgroundColor
+ )
+
+ XCTAssertGreaterThan(
+ SettingsWindowLevelPolicy.active.rawValue,
+ NSWindow.Level.floating.rawValue
+ )
+ XCTAssertEqual(window.level, SettingsWindowLevelPolicy.active)
+
+ window.close()
+
+ XCTAssertEqual(window.level, originalLevel)
+ }
+
+ func testResignAndDetachRestoreLevelAndRemoveKeyObservers() {
+ let window = makeWindow()
+ let originalLevel = window.level
+ let coordinator = SettingsWindowActivator.Coordinator()
+ defer { window.orderOut(nil) }
+
+ coordinator.attach(
+ to: window,
+ appearanceName: nil,
+ backgroundColor: .windowBackgroundColor
+ )
+ XCTAssertEqual(window.level, SettingsWindowLevelPolicy.active)
+
+ NotificationCenter.default.post(
+ name: NSWindow.didResignKeyNotification,
+ object: window
+ )
+ XCTAssertEqual(window.level, originalLevel)
+
+ NotificationCenter.default.post(
+ name: NSWindow.didBecomeKeyNotification,
+ object: window
+ )
+ XCTAssertEqual(window.level, SettingsWindowLevelPolicy.active)
+
+ coordinator.detach()
+ XCTAssertEqual(window.level, originalLevel)
+
+ NotificationCenter.default.post(
+ name: NSWindow.didBecomeKeyNotification,
+ object: window
+ )
+ XCTAssertEqual(window.level, originalLevel)
+ }
+
+ func testActiveSettingsStaysAboveFloatingMainWindowOrderedFront() throws {
+ let settingsWindow = makeWindow()
+ let mainWindow = makeWindow()
+ mainWindow.identifier = CodexNotesWindowIdentifier.main
+ mainWindow.level = .floating
+ let coordinator = SettingsWindowActivator.Coordinator()
+ defer {
+ coordinator.invalidate()
+ settingsWindow.orderOut(nil)
+ mainWindow.orderOut(nil)
+ }
+
+ coordinator.attach(
+ to: settingsWindow,
+ appearanceName: nil,
+ backgroundColor: .windowBackgroundColor
+ )
+ mainWindow.orderFrontRegardless()
+
+ XCTAssertEqual(
+ settingsWindow.level,
+ SettingsWindowLevelPolicy.active
+ )
+ XCTAssertGreaterThan(
+ settingsWindow.level.rawValue,
+ mainWindow.level.rawValue
+ )
+
+ let orderedWindows = NSApp.orderedWindows
+ let settingsIndex = try XCTUnwrap(
+ orderedWindows.firstIndex { $0 === settingsWindow }
+ )
+ let mainIndex = try XCTUnwrap(
+ orderedWindows.firstIndex { $0 === mainWindow }
+ )
+ XCTAssertLessThan(settingsIndex, mainIndex)
+ }
+
+ private func makeWindow() -> NSWindow {
+ 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
+ return window
+ }
}
diff --git a/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift b/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift
index 235c876..83135f5 100644
--- a/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift
+++ b/Tests/CodexNotesProbeTests/StatusItemControllerTests.swift
@@ -52,14 +52,46 @@ final class StatusItemControllerTests: XCTestCase {
)
}
- func testVisibleSettingsAlwaysSwitchesBackToMainWindow() {
+ func testAutomaticPresentationLeavesAnAlreadyVisibleWindowAlone() {
XCTAssertEqual(
- action(
+ MainWindowAutomaticPresentationPolicy.action(
+ shouldShow: true,
+ isApplicationHidden: false,
isWindowVisible: true,
- isSettingsVisible: true
+ isWindowMiniaturized: false
+ ),
+ .none
+ )
+ }
+
+ func testAutomaticPresentationShowsAndHidesOnlyWhenNeeded() {
+ XCTAssertEqual(
+ MainWindowAutomaticPresentationPolicy.action(
+ shouldShow: true,
+ isApplicationHidden: false,
+ isWindowVisible: false,
+ isWindowMiniaturized: false
),
.show
)
+ XCTAssertEqual(
+ MainWindowAutomaticPresentationPolicy.action(
+ shouldShow: false,
+ isApplicationHidden: false,
+ isWindowVisible: true,
+ isWindowMiniaturized: false
+ ),
+ .hide
+ )
+ XCTAssertEqual(
+ MainWindowAutomaticPresentationPolicy.action(
+ shouldShow: false,
+ isApplicationHidden: false,
+ isWindowVisible: false,
+ isWindowMiniaturized: false
+ ),
+ .none
+ )
}
func testManualHideOverridesAutomaticVisibilityUntilShowResumesFollowing() {
@@ -67,14 +99,12 @@ final class StatusItemControllerTests: XCTestCase {
XCTAssertEqual(state.preference, .automatic)
XCTAssertTrue(
state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: false
+ automaticVisibilityAllowed: true
)
)
XCTAssertFalse(
state.shouldShow(
- automaticVisibilityAllowed: false,
- isSettingsVisible: false
+ automaticVisibilityAllowed: false
)
)
@@ -82,8 +112,7 @@ final class StatusItemControllerTests: XCTestCase {
XCTAssertEqual(state.preference, .hidden)
XCTAssertFalse(
state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: false
+ automaticVisibilityAllowed: true
)
)
@@ -91,20 +120,12 @@ final class StatusItemControllerTests: XCTestCase {
XCTAssertEqual(state.preference, .automatic)
XCTAssertFalse(
state.shouldShow(
- automaticVisibilityAllowed: false,
- isSettingsVisible: false
+ automaticVisibilityAllowed: false
)
)
XCTAssertTrue(
state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: false
- )
- )
- XCTAssertFalse(
- state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: true
+ automaticVisibilityAllowed: true
)
)
}
@@ -120,8 +141,7 @@ final class StatusItemControllerTests: XCTestCase {
XCTAssertTrue(state.isCurrentCodexActivationRequest(requestID))
XCTAssertFalse(
state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: false
+ automaticVisibilityAllowed: true
)
)
@@ -129,8 +149,7 @@ final class StatusItemControllerTests: XCTestCase {
XCTAssertFalse(state.isAwaitingCodexActivation)
XCTAssertTrue(
state.shouldShow(
- automaticVisibilityAllowed: true,
- isSettingsVisible: false
+ automaticVisibilityAllowed: true
)
)
}
@@ -378,14 +397,12 @@ final class StatusItemControllerTests: XCTestCase {
private func action(
isApplicationHidden: Bool = false,
isWindowVisible: Bool,
- isWindowMiniaturized: Bool = false,
- isSettingsVisible: Bool = false
+ isWindowMiniaturized: Bool = false
) -> MainWindowToggleAction {
MainWindowTogglePolicy.action(
isApplicationHidden: isApplicationHidden,
isWindowVisible: isWindowVisible,
- isWindowMiniaturized: isWindowMiniaturized,
- isSettingsVisible: isSettingsVisible
+ isWindowMiniaturized: isWindowMiniaturized
)
}
}
diff --git a/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift b/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift
new file mode 100644
index 0000000..1e7f5b7
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift
@@ -0,0 +1,212 @@
+import AppKit
+import XCTest
+@testable import CodexNotesProbe
+
+@MainActor
+final class WindowConfiguratorLifecycleTests: XCTestCase {
+ func testSettingsVisibilityNotificationDoesNotHideVisibleMainWindow() async {
+ await preservingStableFrameDefaults {
+ let availability = FakeCodexApplicationAvailabilityMonitor(
+ isCodexAvailable: true
+ )
+ let mainCoordinator = WindowConfigurator.Coordinator(
+ codexAvailabilityMonitor: availability
+ )
+ let settingsCoordinator = SettingsWindowActivator.Coordinator()
+ let mainWindow = makeMainWindow()
+ let settingsWindow = makeSettingsWindow()
+ defer {
+ settingsCoordinator.invalidate()
+ mainCoordinator.detach()
+ settingsWindow.orderOut(nil)
+ mainWindow.orderOut(nil)
+ }
+
+ mainCoordinator.attach(
+ to: mainWindow,
+ languageRevision: "test"
+ )
+ settingsCoordinator.attach(
+ to: settingsWindow,
+ appearanceName: nil,
+ backgroundColor: .windowBackgroundColor
+ )
+ mainWindow.orderFrontRegardless()
+ XCTAssertTrue(mainWindow.isVisible)
+ XCTAssertTrue(settingsWindow.isVisible)
+
+ NotificationCenter.default.post(
+ name: SettingsWindowVisibilityNotification.name,
+ object: nil,
+ userInfo: [
+ SettingsWindowVisibilityNotification.isVisibleKey: true,
+ ]
+ )
+ await drainMainQueue()
+
+ XCTAssertTrue(mainWindow.isVisible)
+ XCTAssertTrue(settingsWindow.isVisible)
+ }
+ }
+
+ func testAvailabilityLossHidesMainWindowAndDetachStopsMonitoring() {
+ preservingStableFrameDefaults {
+ let availability = FakeCodexApplicationAvailabilityMonitor(
+ isCodexAvailable: true
+ )
+ let coordinator = WindowConfigurator.Coordinator(
+ codexAvailabilityMonitor: availability
+ )
+ let window = makeMainWindow()
+ defer { window.orderOut(nil) }
+
+ coordinator.attach(to: window, languageRevision: "test")
+ coordinator.attach(to: window, languageRevision: "test")
+
+ XCTAssertEqual(availability.startCount, 1)
+ XCTAssertTrue(availability.hasChangeHandler)
+
+ window.orderFrontRegardless()
+ XCTAssertTrue(window.isVisible)
+
+ availability.update(false)
+
+ XCTAssertFalse(window.isVisible)
+
+ window.orderFrontRegardless()
+ XCTAssertTrue(window.isVisible)
+
+ coordinator.detach()
+
+ XCTAssertEqual(availability.stopCount, 1)
+ XCTAssertFalse(availability.hasChangeHandler)
+
+ availability.update(false)
+
+ XCTAssertTrue(window.isVisible)
+ }
+ }
+
+ func testInvalidateStopsMonitoringAndRejectsASecondAttach() {
+ preservingStableFrameDefaults {
+ let availability = FakeCodexApplicationAvailabilityMonitor(
+ isCodexAvailable: false
+ )
+ let coordinator = WindowConfigurator.Coordinator(
+ codexAvailabilityMonitor: availability
+ )
+ let window = makeMainWindow()
+ defer { window.orderOut(nil) }
+
+ coordinator.attach(to: window, languageRevision: "test")
+ XCTAssertEqual(availability.startCount, 1)
+
+ coordinator.invalidate()
+
+ XCTAssertEqual(availability.stopCount, 1)
+ XCTAssertFalse(availability.hasChangeHandler)
+
+ coordinator.attach(to: window, languageRevision: "test-again")
+
+ XCTAssertEqual(availability.startCount, 1)
+ XCTAssertEqual(availability.stopCount, 1)
+ }
+ }
+
+ private func makeMainWindow() -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 100, y: 100, width: 420, height: 620),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.identifier = CodexNotesWindowIdentifier.main
+ window.alphaValue = 0
+ return window
+ }
+
+ private func makeSettingsWindow() -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 560, y: 100, width: 480, height: 640),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.isReleasedWhenClosed = false
+ window.alphaValue = 0
+ return window
+ }
+
+ private func preservingStableFrameDefaults(_ body: () -> Void) {
+ let defaults = UserDefaults.standard
+ let key = MainWindowFramePersistence.autosaveDefaultsKey
+ let originalValue = defaults.object(forKey: key)
+ defaults.removeObject(forKey: key)
+ defer {
+ if let originalValue {
+ defaults.set(originalValue, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ body()
+ }
+
+ private func preservingStableFrameDefaults(
+ _ body: () async -> Void
+ ) async {
+ let defaults = UserDefaults.standard
+ let key = MainWindowFramePersistence.autosaveDefaultsKey
+ let originalValue = defaults.object(forKey: key)
+ defaults.removeObject(forKey: key)
+ defer {
+ if let originalValue {
+ defaults.set(originalValue, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ await body()
+ }
+
+ private func drainMainQueue() async {
+ await withCheckedContinuation { continuation in
+ DispatchQueue.main.async {
+ continuation.resume()
+ }
+ }
+ }
+}
+
+@MainActor
+private final class FakeCodexApplicationAvailabilityMonitor:
+ CodexApplicationAvailabilityObserving
+{
+ private(set) var isCodexAvailable: Bool
+ private(set) var startCount = 0
+ private(set) var stopCount = 0
+ private var changeHandler: ((Bool) -> Void)?
+
+ var hasChangeHandler: Bool {
+ changeHandler != nil
+ }
+
+ init(isCodexAvailable: Bool) {
+ self.isCodexAvailable = isCodexAvailable
+ }
+
+ func start(onChange: @escaping (Bool) -> Void) {
+ startCount += 1
+ changeHandler = onChange
+ }
+
+ func stop() {
+ stopCount += 1
+ changeHandler = nil
+ }
+
+ func update(_ isCodexAvailable: Bool) {
+ self.isCodexAvailable = isCodexAvailable
+ changeHandler?(isCodexAvailable)
+ }
+}
diff --git a/scripts/package-app.sh b/scripts/package-app.sh
index 8651ab0..167a225 100755
--- a/scripts/package-app.sh
+++ b/scripts/package-app.sh
@@ -9,6 +9,7 @@ APP_INFO_PLIST="$PROJECT_ROOT/AppBundle/Info.plist"
APP_ICON="$PROJECT_ROOT/AppBundle/Resources/CodexNotes.icns"
APP_ICON_BUILD_SCRIPT="$PROJECT_ROOT/scripts/build-app-icon.sh"
LOCALIZATION_VERIFY_SCRIPT="$PROJECT_ROOT/scripts/verify-localizations.sh"
+UNIVERSAL_APP_VERIFY_SCRIPT="$PROJECT_ROOT/scripts/verify-universal-app.sh"
INSTALL_DIRECTORY="${CODEX_NOTES_INSTALL_DIRECTORY:-$HOME/Applications}"
INSTALL_LOCAL="${CODEX_NOTES_INSTALL_LOCAL:-0}"
ALLOW_WEAK_LOCAL_IDENTITY="${CODEX_NOTES_ALLOW_WEAK_LOCAL_IDENTITY:-0}"
@@ -35,26 +36,77 @@ if [[ "$ALLOW_WEAK_LOCAL_IDENTITY" == "1" && "$INSTALL_LOCAL" != "1" ]]; then
fi
STAGING_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/codex-notes-probe.XXXXXX")"
-trap 'rm -rf "$STAGING_DIRECTORY"' EXIT
-BUILD_DIRECTORY="$STAGING_DIRECTORY/build"
+ARM64_BUILD_DIRECTORY="$(mktemp -d /tmp/codex-notes-arm64.XXXXXX)"
+X86_64_BUILD_DIRECTORY="$(mktemp -d /tmp/codex-notes-x86_64.XXXXXX)"
+
+cleanup() {
+ rm -rf \
+ "$STAGING_DIRECTORY" \
+ "$ARM64_BUILD_DIRECTORY" \
+ "$X86_64_BUILD_DIRECTORY"
+}
+trap cleanup EXIT
+
+verify_thin_binary() {
+ local binary_path="$1"
+ local expected_architecture="$2"
+ local actual_architectures
+
+ if [[ ! -f "$binary_path" ]]; then
+ echo "缺少 $expected_architecture 构建产物:$binary_path" >&2
+ exit 1
+ fi
+ actual_architectures="$(/usr/bin/lipo -archs "$binary_path")"
+ if [[ "$actual_architectures" != "$expected_architecture" ]]; then
+ echo \
+ "意外的 $expected_architecture 输入架构:$actual_architectures" \
+ >&2
+ exit 1
+ fi
+}
zsh "$APP_ICON_BUILD_SCRIPT" >/dev/null
zsh "$LOCALIZATION_VERIFY_SCRIPT"
-swift build \
+/usr/bin/xcrun swift build \
--package-path "$PROJECT_ROOT" \
- --scratch-path "$BUILD_DIRECTORY" \
+ --scratch-path "$ARM64_BUILD_DIRECTORY" \
-c release \
+ --arch arm64 \
--product CodexNotesProbe
-BIN_DIRECTORY="$(swift build \
+ARM64_BIN_DIRECTORY="$(/usr/bin/xcrun swift build \
--package-path "$PROJECT_ROOT" \
- --scratch-path "$BUILD_DIRECTORY" \
+ --scratch-path "$ARM64_BUILD_DIRECTORY" \
-c release \
+ --arch arm64 \
+ --product CodexNotesProbe \
--show-bin-path)"
+/usr/bin/xcrun swift build \
+ --package-path "$PROJECT_ROOT" \
+ --scratch-path "$X86_64_BUILD_DIRECTORY" \
+ -c release \
+ --arch x86_64 \
+ --product CodexNotesProbe
+X86_64_BIN_DIRECTORY="$(/usr/bin/xcrun swift build \
+ --package-path "$PROJECT_ROOT" \
+ --scratch-path "$X86_64_BUILD_DIRECTORY" \
+ -c release \
+ --arch x86_64 \
+ --product CodexNotesProbe \
+ --show-bin-path)"
+
+ARM64_BINARY="$ARM64_BIN_DIRECTORY/CodexNotesProbe"
+X86_64_BINARY="$X86_64_BIN_DIRECTORY/CodexNotesProbe"
+verify_thin_binary "$ARM64_BINARY" arm64
+verify_thin_binary "$X86_64_BINARY" x86_64
+
STAGED_APP="$STAGING_DIRECTORY/$APP_NAME"
mkdir -p "$STAGED_APP/Contents/MacOS" "$STAGED_APP/Contents/Resources"
-cp "$BIN_DIRECTORY/CodexNotesProbe" "$STAGED_APP/Contents/MacOS/CodexNotesProbe"
+/usr/bin/lipo -create \
+ "$ARM64_BINARY" \
+ "$X86_64_BINARY" \
+ -output "$STAGED_APP/Contents/MacOS/CodexNotesProbe"
cp "$APP_INFO_PLIST" "$STAGED_APP/Contents/Info.plist"
if [[ ! -f "$APP_ICON" ]]; then
echo "缺少应用图标:$APP_ICON" >&2
@@ -66,12 +118,26 @@ RESOURCE_BUNDLE_NAMES=(
"CodexNotesProbe_CodexNotesCore.bundle"
)
for RESOURCE_BUNDLE_NAME in "${RESOURCE_BUNDLE_NAMES[@]}"; do
- RESOURCE_BUNDLE="$BIN_DIRECTORY/$RESOURCE_BUNDLE_NAME"
- if [[ ! -d "$RESOURCE_BUNDLE" ]]; then
- echo "缺少应用资源包:$RESOURCE_BUNDLE" >&2
+ ARM64_RESOURCE_BUNDLE="$ARM64_BIN_DIRECTORY/$RESOURCE_BUNDLE_NAME"
+ X86_64_RESOURCE_BUNDLE="$X86_64_BIN_DIRECTORY/$RESOURCE_BUNDLE_NAME"
+ if [[ ! -d "$ARM64_RESOURCE_BUNDLE" ]]; then
+ echo "缺少 arm64 应用资源包:$ARM64_RESOURCE_BUNDLE" >&2
+ exit 1
+ fi
+ if [[ ! -d "$X86_64_RESOURCE_BUNDLE" ]]; then
+ echo "缺少 x86_64 应用资源包:$X86_64_RESOURCE_BUNDLE" >&2
+ exit 1
+ fi
+ if ! /usr/bin/diff -qr \
+ "$ARM64_RESOURCE_BUNDLE" \
+ "$X86_64_RESOURCE_BUNDLE"
+ then
+ echo "两种架构生成的资源包不一致:$RESOURCE_BUNDLE_NAME" >&2
exit 1
fi
- ditto "$RESOURCE_BUNDLE" "$STAGED_APP/Contents/Resources/$RESOURCE_BUNDLE_NAME"
+ ditto \
+ "$ARM64_RESOURCE_BUNDLE" \
+ "$STAGED_APP/Contents/Resources/$RESOURCE_BUNDLE_NAME"
done
chmod 755 "$STAGED_APP/Contents/MacOS/CodexNotesProbe"
@@ -79,7 +145,7 @@ zsh "$LOCALIZATION_VERIFY_SCRIPT" "$STAGED_APP"
xattr -cr "$STAGED_APP" 2>/dev/null || true
codesign --force --deep --sign - "$STAGED_APP"
-codesign --verify --deep --strict --verbose=2 "$STAGED_APP"
+zsh "$UNIVERSAL_APP_VERIFY_SCRIPT" "$STAGED_APP"
mkdir -p "$OUTPUT_DIRECTORY"
if [[ -e "$ARCHIVE_PATH" ]]; then
@@ -104,7 +170,7 @@ if [[ "$INSTALL_LOCAL" == "1" ]]; then
--requirements "$LOCAL_INSTALL_REQUIREMENT" \
"$INSTALLED_APP"
fi
- codesign --verify --deep --strict --verbose=2 "$INSTALLED_APP"
+ zsh "$UNIVERSAL_APP_VERIFY_SCRIPT" "$INSTALLED_APP"
if [[ "$LEGACY_APP" != "$INSTALLED_APP" && -e "$LEGACY_APP" ]]; then
rm -rf "$LEGACY_APP"
diff --git a/scripts/release-notarized.sh b/scripts/release-notarized.sh
index 387c6d4..c4be7c0 100755
--- a/scripts/release-notarized.sh
+++ b/scripts/release-notarized.sh
@@ -3,15 +3,38 @@ set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
INFO_PLIST="$PROJECT_ROOT/AppBundle/Info.plist"
+UNIVERSAL_APP_VERIFIER="$PROJECT_ROOT/scripts/verify-universal-app.sh"
DIST_DIRECTORY="$PROJECT_ROOT/dist"
VERSION="$(plutil -extract CFBundleShortVersionString raw -o - "$INFO_PLIST")"
BUILD="$(plutil -extract CFBundleVersion raw -o - "$INFO_PLIST")"
SOURCE_ARCHIVE="$DIST_DIRECTORY/CodexNotes.zip"
-FINAL_ARCHIVE="$DIST_DIRECTORY/CodexNotes-v${VERSION}-macOS-arm64.zip"
+FINAL_ARCHIVE="$DIST_DIRECTORY/CodexNotes-v${VERSION}-macOS-universal.zip"
CHECKSUM_PATH="$FINAL_ARCHIVE.sha256"
SIGNING_IDENTITY="${CODEX_NOTES_SIGNING_IDENTITY:-}"
NOTARY_PROFILE="${CODEX_NOTES_NOTARY_PROFILE:-}"
+verify_developer_id_runtime() {
+ local app_path="$1"
+ local architecture
+ local signature_details
+
+ codesign --verify --deep --strict --all-architectures --verbose=2 "$app_path"
+ for architecture in arm64 x86_64; do
+ if ! signature_details="$(codesign -d --architecture "$architecture" --verbose=4 "$app_path" 2>&1)"; then
+ echo "无法读取 ${architecture} slice 的签名信息。" >&2
+ return 1
+ fi
+ if ! grep -F "Authority=Developer ID Application:" <<<"$signature_details" >/dev/null; then
+ echo "${architecture} slice 不是 Developer ID Application 签名。" >&2
+ return 1
+ fi
+ if ! grep -F "Runtime Version" <<<"$signature_details" >/dev/null; then
+ echo "${architecture} slice 未启用 Hardened Runtime。" >&2
+ return 1
+ fi
+ done
+}
+
if [[ -z "$SIGNING_IDENTITY" ]]; then
echo "缺少 CODEX_NOTES_SIGNING_IDENTITY。" >&2
exit 1
@@ -20,6 +43,10 @@ if [[ -z "$NOTARY_PROFILE" ]]; then
echo "缺少 CODEX_NOTES_NOTARY_PROFILE。" >&2
exit 1
fi
+if [[ ! -f "$UNIVERSAL_APP_VERIFIER" ]]; then
+ echo "缺少 Universal 2 验证脚本:$UNIVERSAL_APP_VERIFIER" >&2
+ exit 1
+fi
if ! git -C "$PROJECT_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "正式发布必须从 Git 仓库中的已提交源码构建。" >&2
exit 1
@@ -60,18 +87,8 @@ codesign \
--timestamp \
--sign "$SIGNING_IDENTITY" \
"$APP_PATH"
-codesign --verify --deep --strict --verbose=2 "$APP_PATH"
-SIGNATURE_DETAILS="$(codesign -d --verbose=4 "$APP_PATH" 2>&1)"
-if ! grep -F "Runtime Version" <<<"$SIGNATURE_DETAILS" >/dev/null; then
- echo "签名未启用 Hardened Runtime。" >&2
- exit 1
-fi
-
-ARCHITECTURES="$(lipo -archs "$APP_PATH/Contents/MacOS/CodexNotesProbe")"
-if [[ "$ARCHITECTURES" != "arm64" ]]; then
- echo "意外的发布架构:$ARCHITECTURES" >&2
- exit 1
-fi
+zsh "$UNIVERSAL_APP_VERIFIER" "$APP_PATH"
+verify_developer_id_runtime "$APP_PATH"
NOTARY_ARCHIVE="$STAGING_DIRECTORY/CodexNotes-notary.zip"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$NOTARY_ARCHIVE"
@@ -99,7 +116,8 @@ ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$FINAL_ARCHIVE"
VERIFY_DIRECTORY="$STAGING_DIRECTORY/verify"
ditto -x -k "$FINAL_ARCHIVE" "$VERIFY_DIRECTORY"
VERIFIED_APP="$VERIFY_DIRECTORY/CodexNotes.app"
-codesign --verify --deep --strict --verbose=2 "$VERIFIED_APP"
+zsh "$UNIVERSAL_APP_VERIFIER" "$VERIFIED_APP"
+verify_developer_id_runtime "$VERIFIED_APP"
xcrun stapler validate "$VERIFIED_APP"
spctl --assess --type exec --verbose=4 "$VERIFIED_APP"
if command -v syspolicy_check >/dev/null 2>&1; then
diff --git a/scripts/verify-localizations.sh b/scripts/verify-localizations.sh
index 83c2058..257f8fc 100755
--- a/scripts/verify-localizations.sh
+++ b/scripts/verify-localizations.sh
@@ -38,14 +38,49 @@ diff -u "$TEMP_DIRECTORY/declared.keys" "$TEMP_DIRECTORY/zh-Hans.keys"
diff -u "$TEMP_DIRECTORY/zh-Hans.keys" "$TEMP_DIRECTORY/en.keys"
diff -u "$TEMP_DIRECTORY/zh-Hans.tokens" "$TEMP_DIRECTORY/en.tokens"
-if rg -n '[一-龥]' \
- "$PROJECT_ROOT/Sources/CodexNotesCore" \
- "$PROJECT_ROOT/Sources/CodexNotesProbe" \
- "$PROJECT_ROOT/Sources/CodexNotesProbeCheck" \
- -g '*.swift'; then
- echo "生产 Swift 源码仍含中文文案,请迁移到 Localizable.strings。" >&2
+SWIFT_SOURCE_FILES=(
+ "$PROJECT_ROOT/Sources/CodexNotesCore"/**/*.swift(N)
+ "$PROJECT_ROOT/Sources/CodexNotesProbe"/**/*.swift(N)
+ "$PROJECT_ROOT/Sources/CodexNotesProbeCheck"/**/*.swift(N)
+)
+if (( ${#SWIFT_SOURCE_FILES[@]} == 0 )); then
+ echo "未找到可验证的 Swift 源码。" >&2
exit 1
fi
+HAN_SCAN_STATUS=0
+# Older BSD grep versions treat a multibyte character range according to
+# collation order and can mistake symbols such as em dashes and ⌘ for Han.
+/usr/bin/perl -CSDA -e '
+ use strict;
+ use warnings;
+ my $found_han = 0;
+ for my $path (@ARGV) {
+ open my $handle, "<:encoding(UTF-8)", $path
+ or die "Cannot open $path: $!\n";
+ my $line_number = 0;
+ while (my $line = <$handle>) {
+ $line_number += 1;
+ if ($line =~ /\p{Han}/) {
+ print "$path:$line_number:$line";
+ $found_han = 1;
+ }
+ }
+ close $handle or die "Cannot close $path: $!\n";
+ }
+ exit($found_han ? 0 : 1);
+' "${SWIFT_SOURCE_FILES[@]}" || HAN_SCAN_STATUS=$?
+case "$HAN_SCAN_STATUS" in
+ 0)
+ echo "生产 Swift 源码仍含中文文案,请迁移到 Localizable.strings。" >&2
+ exit 1
+ ;;
+ 1)
+ ;;
+ *)
+ echo "无法扫描生产 Swift 源码(退出码 $HAN_SCAN_STATUS)。" >&2
+ exit "$HAN_SCAN_STATUS"
+ ;;
+esac
if [[ $# -gt 0 ]]; then
APP_PATH="$1"
@@ -53,11 +88,16 @@ if [[ $# -gt 0 ]]; then
echo "应用不存在:$APP_PATH" >&2
exit 1
fi
+ CORE_RESOURCE_BUNDLE="$APP_PATH/Contents/Resources/CodexNotesProbe_CodexNotesCore.bundle"
+ if [[ ! -d "$CORE_RESOURCE_BUNDLE" ]]; then
+ echo "应用缺少 Core 资源包:$APP_PATH" >&2
+ exit 1
+ fi
for LANGUAGE in "${LANGUAGES[@]}"; do
SOURCE_STRINGS="$RESOURCE_ROOT/$LANGUAGE.lproj/Localizable.strings"
- APP_STRINGS="$(find \
- "$APP_PATH/Contents/Resources" \
- -path "*/CodexNotesProbe_CodexNotesCore.bundle/*/$LANGUAGE.lproj/Localizable.strings" \
+ APP_STRINGS="$(/usr/bin/find \
+ "$CORE_RESOURCE_BUNDLE" \
+ -ipath "*/$LANGUAGE.lproj/Localizable.strings" \
-type f \
-print \
-quit)"
diff --git a/scripts/verify-universal-app.sh b/scripts/verify-universal-app.sh
new file mode 100755
index 0000000..58ee6fe
--- /dev/null
+++ b/scripts/verify-universal-app.sh
@@ -0,0 +1,109 @@
+#!/bin/zsh
+set -euo pipefail
+
+if [[ "$#" -ne 1 ]]; then
+ echo "用法:zsh scripts/verify-universal-app.sh /path/to/CodexNotes.app" >&2
+ exit 64
+fi
+
+APP_PATH="$1"
+INFO_PLIST="$APP_PATH/Contents/Info.plist"
+
+if [[ ! -d "$APP_PATH" ]]; then
+ echo "应用不存在:$APP_PATH" >&2
+ exit 1
+fi
+if [[ ! -f "$INFO_PLIST" ]]; then
+ echo "应用缺少 Info.plist:$INFO_PLIST" >&2
+ exit 1
+fi
+
+EXECUTABLE_NAME="$(plutil -extract CFBundleExecutable raw -o - "$INFO_PLIST")"
+MINIMUM_SYSTEM_VERSION="$(
+ plutil -extract LSMinimumSystemVersion raw -o - "$INFO_PLIST"
+)"
+EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME"
+
+if [[ ! -f "$EXECUTABLE_PATH" ]]; then
+ echo "应用缺少主程序:$EXECUTABLE_PATH" >&2
+ exit 1
+fi
+
+ARCHITECTURES="$(/usr/bin/lipo -archs "$EXECUTABLE_PATH")"
+ARCHITECTURE_COUNT="$(
+ /usr/bin/awk '{ print NF }' <<<"$ARCHITECTURES"
+)"
+if [[ "$ARCHITECTURE_COUNT" != "2" ]]; then
+ echo "主程序必须恰好包含两个架构,实际为:$ARCHITECTURES" >&2
+ exit 1
+fi
+if ! /usr/bin/lipo "$EXECUTABLE_PATH" -verify_arch arm64; then
+ echo "主程序缺少 arm64 架构:$ARCHITECTURES" >&2
+ exit 1
+fi
+if ! /usr/bin/lipo "$EXECUTABLE_PATH" -verify_arch x86_64; then
+ echo "主程序缺少 x86_64 架构:$ARCHITECTURES" >&2
+ exit 1
+fi
+
+normalize_version() {
+ local raw_version="$1"
+
+ /usr/bin/awk -v version="$raw_version" 'BEGIN {
+ count = split(version, parts, ".")
+ while (count > 1 && parts[count] == 0) {
+ count--
+ }
+ normalized = parts[1]
+ for (part_index = 2; part_index <= count; part_index++) {
+ normalized = normalized "." parts[part_index]
+ }
+ print normalized
+ }'
+}
+
+EXPECTED_MINIMUM_VERSION="$(normalize_version "$MINIMUM_SYSTEM_VERSION")"
+for ARCHITECTURE in arm64 x86_64; do
+ BUILD_DETAILS="$(
+ /usr/bin/vtool \
+ -arch "$ARCHITECTURE" \
+ -show-build \
+ "$EXECUTABLE_PATH"
+ )"
+ PLATFORM="$(
+ /usr/bin/awk '$1 == "platform" { print $2; exit }' \
+ <<<"$BUILD_DETAILS"
+ )"
+ SLICE_MINIMUM_VERSION="$(
+ /usr/bin/awk '$1 == "minos" { print $2; exit }' \
+ <<<"$BUILD_DETAILS"
+ )"
+ if [[ "$PLATFORM" != "MACOS" || -z "$SLICE_MINIMUM_VERSION" ]]; then
+ echo "无法读取 $ARCHITECTURE 切片的 macOS 最低版本。" >&2
+ exit 1
+ fi
+
+ NORMALIZED_SLICE_MINIMUM_VERSION="$(
+ normalize_version "$SLICE_MINIMUM_VERSION"
+ )"
+ if [[ \
+ "$NORMALIZED_SLICE_MINIMUM_VERSION" \
+ != "$EXPECTED_MINIMUM_VERSION" \
+ ]]; then
+ echo \
+ "$ARCHITECTURE 切片最低版本 $SLICE_MINIMUM_VERSION 与 Info.plist 的 $MINIMUM_SYSTEM_VERSION 不一致。" \
+ >&2
+ exit 1
+ fi
+done
+
+codesign \
+ --verify \
+ --all-architectures \
+ --deep \
+ --strict \
+ --verbose=2 \
+ "$APP_PATH"
+
+echo \
+ "Universal App 验证通过:$ARCHITECTURES,macOS $MINIMUM_SYSTEM_VERSION+"