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