diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 3db9b7b..fe44ec8 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -12,6 +12,11 @@ struct MarkeeApp: App { } .defaultSize(width: 1000, height: 800) .commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + Updater.shared.checkForUpdatesMenuAction() + } + } CommandGroup(after: .newItem) { Divider() Button("Install Command Line Tool…") { @@ -60,6 +65,15 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .openInEditor, object: nil) } .keyboardShortcut("E", modifiers: [.command, .option]) + Divider() + Button("Copy Markdown Source") { + NotificationCenter.default.post(name: .copyMarkdownSource, object: nil) + } + .keyboardShortcut("C", modifiers: [.command, .shift]) + Button("Reveal in Finder") { + NotificationCenter.default.post(name: .revealInFinder, object: nil) + } + .keyboardShortcut("R", modifiers: [.command, .shift]) } CommandGroup(replacing: .printItem) { Button("Print…") { @@ -98,6 +112,8 @@ extension Notification.Name { static let findNext = Notification.Name("MarkeeFindNext") static let findPrevious = Notification.Name("MarkeeFindPrevious") static let reloadFile = Notification.Name("MarkeeReloadFile") + static let copyMarkdownSource = Notification.Name("MarkeeCopyMarkdownSource") + static let revealInFinder = Notification.Name("MarkeeRevealInFinder") } final class AppDelegate: NSObject, NSApplicationDelegate { @@ -109,6 +125,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } + func applicationDidFinishLaunching(_ notification: Notification) { + Updater.shared.checkOnLaunch() + } + static func installCLI() { guard let bundleCLI = Bundle.main.url(forResource: "cli/markee", withExtension: nil) else { NSSound.beep(); return diff --git a/Sources/Markee/MarkeeTitlebar.swift b/Sources/Markee/MarkeeTitlebar.swift index f70698b..7fb202a 100644 --- a/Sources/Markee/MarkeeTitlebar.swift +++ b/Sources/Markee/MarkeeTitlebar.swift @@ -30,7 +30,7 @@ struct MarkeeTitlebar: View { Button(action: onToggleOutline) { Image(systemName: "sidebar.left") .font(.system(size: 14, weight: .regular)) - .foregroundStyle(Color.secondary) + .foregroundStyle(toggleIconColor) .frame(width: 24, height: 24) .contentShape(Rectangle()) } @@ -73,6 +73,18 @@ struct MarkeeTitlebar: View { }) } + /// Explicit appearance-aware color for the outline-toggle icon, matching + /// the titlebar's own `NSColor`-backed color treatment. + private var toggleIconColor: Color { + Color(nsColor: NSColor(name: nil) { appearance in + if appearance.bestMatch(from: [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua]) != nil { + return NSColor(red: 0x8c/255.0, green: 0x8e/255.0, blue: 0x97/255.0, alpha: 1) + } else { + return NSColor(red: 0x6a/255.0, green: 0x6c/255.0, blue: 0x74/255.0, alpha: 1) + } + }) + } + private var titlebarTopColor: Color { Color(nsColor: NSColor(name: nil) { appearance in if appearance.bestMatch(from: [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua]) != nil { diff --git a/Sources/Markee/MarkeeWebView.swift b/Sources/Markee/MarkeeWebView.swift new file mode 100644 index 0000000..003e8fd --- /dev/null +++ b/Sources/Markee/MarkeeWebView.swift @@ -0,0 +1,39 @@ +import WebKit + +/// WKWebView subclass that appends Markee's own items to the native +/// right-click context menu. WKWebView owns its context menu, so SwiftUI's +/// `.contextMenu` cannot reach it — we extend `willOpenMenu` instead. +final class MarkeeWebView: WKWebView { + /// Weak to avoid a retain cycle: PreviewController owns this webView. + weak var controller: PreviewController? + + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { + super.willOpenMenu(menu, with: event) + + // No isKeyWindow guard needed: a right-click targets this exact + // window directly (unlike the broadcast-to-all-windows menu commands). + menu.addItem(.separator()) + + let copyItem = NSMenuItem( + title: "Copy Markdown Source", + action: #selector(copyMarkdownSourceAction), + keyEquivalent: "") + copyItem.target = self + menu.addItem(copyItem) + + let revealItem = NSMenuItem( + title: "Reveal in Finder", + action: #selector(revealInFinderAction), + keyEquivalent: "") + revealItem.target = self + menu.addItem(revealItem) + } + + @objc private func copyMarkdownSourceAction() { + controller?.copyMarkdownSource() + } + + @objc private func revealInFinderAction() { + controller?.revealInFinder() + } +} diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 8accc0c..c0902c8 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -40,7 +40,7 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle @Published var findQuery: String = "" @Published var findNotFound: Bool = false - let webView: WKWebView + let webView: MarkeeWebView let bundleHandler = BundleSchemeHandler() let docHandler: DocSchemeHandler @@ -69,9 +69,11 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle config.setURLSchemeHandler(bundleHandler, forURLScheme: BundleSchemeHandler.scheme) config.setURLSchemeHandler(docHandler, forURLScheme: DocSchemeHandler.scheme) - self.webView = WKWebView(frame: .zero, configuration: config) + self.webView = MarkeeWebView(frame: .zero, configuration: config) super.init() + self.webView.controller = self + userContent.add(self, name: "markee") self.webView.navigationDelegate = self self.webView.allowsBackForwardNavigationGestures = false @@ -116,6 +118,12 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle NotificationCenter.default.addObserver( self, selector: #selector(handleReload), name: .reloadFile, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleCopyMarkdownSource), + name: .copyMarkdownSource, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleRevealInFinder), + name: .revealInFinder, object: nil) } deinit { @@ -235,6 +243,38 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle loadFromDisk(reason: "manual") } + /// ⌘⇧C — copy the file's raw Markdown to the clipboard. Reads fresh from + /// disk via the renderer's read path so the clipboard never holds a stale + /// snapshot. Public so MarkeeWebView's context menu can call it directly. + func copyMarkdownSource() { + let source: String + do { + source = try Self.readFileWithFallback(at: fileURL) + } catch { + self.errorBanner = "Couldn't read \(fileURL.lastPathComponent): \(error.localizedDescription)" + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(source, forType: .string) + } + + /// ⌘⇧R — reveal the current file in Finder. Public so MarkeeWebView's + /// context menu can call it directly. + func revealInFinder() { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + @objc private func handleCopyMarkdownSource() { + guard webView.window?.isKeyWindow == true else { return } + copyMarkdownSource() + } + + @objc private func handleRevealInFinder() { + guard webView.window?.isKeyWindow == true else { return } + revealInFinder() + } + /// ⌘⇧G — mirror of handleFindNext, searching backward. @objc private func handleFindPrevious() { guard webView.window?.isKeyWindow == true else { return } diff --git a/Sources/Markee/PreviewView.swift b/Sources/Markee/PreviewView.swift index ae75328..71c9249 100644 --- a/Sources/Markee/PreviewView.swift +++ b/Sources/Markee/PreviewView.swift @@ -93,7 +93,11 @@ private struct PreviewContent: View { private func configureWindow(_ window: NSWindow) { window.titlebarAppearsTransparent = true window.titleVisibility = .hidden - window.styleMask.insert(.fullSizeContentView) + // Only assign styleMask when it actually changes — reassigning it + // forces an NSThemeFrame rebuild. This runs on every becomeKey. + if !window.styleMask.contains(.fullSizeContentView) { + window.styleMask.insert(.fullSizeContentView) + } window.isMovableByWindowBackground = false } } diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift new file mode 100644 index 0000000..05b0265 --- /dev/null +++ b/Sources/Markee/Updater.swift @@ -0,0 +1,403 @@ +import Foundation + +/// A dotted numeric version (e.g. "0.4.0"), tolerant of a leading "v" and of a +/// pre-release/build suffix. Used only to compare the running app against the +/// latest GitHub release. +struct AppVersion: Comparable, Sendable { + let components: [Int] + + init?(_ string: String) { + var s = string.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("v") || s.hasPrefix("V") { s.removeFirst() } + // Drop any pre-release suffix ("1.0.0-rc1" -> "1.0.0"). Harmless: + // the updater only reads /releases/latest, which excludes pre-releases. + if let dash = s.firstIndex(of: "-") { s = String(s[..= 0 else { return nil } + nums.append(n) + } + self.components = nums + } + + static func == (lhs: AppVersion, rhs: AppVersion) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for i in 0.. Bool { + let count = max(lhs.components.count, rhs.components.count) + for i in 0.. currentVersion else { + if userInitiated { presentUpToDate() } + return + } + + if !userInitiated, + UserDefaults.standard.string(forKey: Self.skippedVersionKey) == release.tagName { + return + } + + presentUpdateAvailable(release) + } + + nonisolated static func fetchLatestRelease(repo: String) async throws -> GitHubRelease { + var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repo)/releases/latest")!) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw UpdaterError.badResponse + } + guard let release = GitHubRelease(json: data) else { + throw UpdaterError.unparseable + } + return release + } + + // MARK: - UI + + func presentUpdateAvailable(_ release: GitHubRelease) { + let alert = NSAlert() + alert.messageText = "A new version of Markee is available" + var info = "Markee \(release.tagName) is available — you have \(Self.currentVersionString)." + if !release.notes.isEmpty { + let notes = release.notes.count > 500 + ? String(release.notes.prefix(500)) + "…" + : release.notes + info += "\n\n" + notes + } + alert.informativeText = info + alert.addButton(withTitle: "Update Now") + alert.addButton(withTitle: "Remind Me Later") + alert.addButton(withTitle: "Skip This Version") + + switch alert.runModal() { + case .alertFirstButtonReturn: + Task { await installUpdate(release) } + case .alertThirdButtonReturn: + UserDefaults.standard.set(release.tagName, forKey: Self.skippedVersionKey) + default: + break // Remind Me Later — nothing persisted; the next check re-offers. + } + } + + func presentUpToDate() { + let alert = NSAlert() + alert.messageText = "You're up to date" + alert.informativeText = "Markee \(Self.currentVersionString) is the latest version." + alert.runModal() + } + + func presentError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Couldn't check for updates" + alert.informativeText = message + alert.runModal() + } + + /// Used when self-replace can't proceed (read-only location, bad download). + /// Sends the user to the release page to update by hand. + func presentManualFallback(_ release: GitHubRelease, reason: String) { + let alert = NSAlert() + alert.messageText = "Update Markee manually" + alert.informativeText = "\(reason)\n\nOpen the download page to get \(release.tagName) in your browser." + alert.addButton(withTitle: "Open Download Page") + alert.addButton(withTitle: "Cancel") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(release.pageURL) + } + } + + // MARK: - Download / stage + + /// Run a subprocess to completion; throw if it exits non-zero. + nonisolated static func runProcess(_ launchPath: String, _ args: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: launchPath) + process.arguments = args + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw UpdaterError.subprocessFailed } + } + + /// Download `Markee.app.zip`, unzip it with `ditto`, and validate the + /// result. Returns the URL of the staged, validated `Markee.app`. Runs off + /// the main actor — the unzip is blocking work. + nonisolated static func downloadAndStage(_ release: GitHubRelease) async throws -> URL { + let (tempZip, response) = try await URLSession.shared.download(from: release.zipURL) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw UpdaterError.downloadFailed + } + + let work = FileManager.default.temporaryDirectory + .appendingPathComponent("markee-update-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + let zipURL = work.appendingPathComponent("Markee.app.zip") + try FileManager.default.moveItem(at: tempZip, to: zipURL) + + // `ditto` unzips reliably, preserving the bundle's symlinks/structure. + try runProcess("/usr/bin/ditto", ["-x", "-k", zipURL.path, work.path]) + + let bundle = work.appendingPathComponent("Markee.app") + let exec = bundle.appendingPathComponent("Contents/MacOS/Markee") + guard FileManager.default.isExecutableFile(atPath: exec.path) else { + throw UpdaterError.invalidBundle + } + let infoPlist = bundle.appendingPathComponent("Contents/Info.plist") + guard let info = NSDictionary(contentsOf: infoPlist), + let versionString = info["CFBundleShortVersionString"] as? String, + let parsed = AppVersion(versionString), + parsed == release.version else { + throw UpdaterError.invalidBundle + } + return bundle + } + + // MARK: - Install + + func installUpdate(_ release: GitHubRelease) async { + let installPath = Bundle.main.bundlePath + let parent = (installPath as NSString).deletingLastPathComponent + guard FileManager.default.isWritableFile(atPath: parent) else { + presentManualFallback(release, reason: "Markee can't replace itself from this location.") + return + } + + let progress = UpdateProgressPanel() + progress.show() + + let stagedBundle: URL + do { + stagedBundle = try await Self.downloadAndStage(release) + } catch { + progress.close() + presentManualFallback(release, reason: error.localizedDescription) + return + } + + progress.setMessage("Installing update…") + do { + try Self.stripQuarantine(stagedBundle) + try Self.launchSwapHelper(newBundle: stagedBundle, installPath: installPath) + } catch { + progress.close() + presentManualFallback(release, reason: error.localizedDescription) + return + } + + // The helper waits for this process to exit, then swaps and relaunches. + NSApp.terminate(nil) + } + + nonisolated static func stripQuarantine(_ bundle: URL) throws { + try runProcess("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle.path]) + } + + /// Write a detached shell helper that waits for this process to quit, swaps + /// the bundle in place (keeping a `.old` backup until success), and + /// relaunches. The helper outlives this process by design — not waited on. + nonisolated static func launchSwapHelper(newBundle: URL, installPath: String) throws { + let script = """ + #!/bin/bash + PID="$1"; NEW="$2"; DEST="$3" + trap 'rm -f "$0"' EXIT + WAITED=0 + while kill -0 "$PID" 2>/dev/null; do + sleep 0.2 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge 300 ]; then break; fi + done + BACKUP="${DEST}.old" + rm -rf "$BACKUP" + if ! mv "$DEST" "$BACKUP"; then + open "$DEST" 2>/dev/null || true + exit 1 + fi + if /usr/bin/ditto "$NEW" "$DEST"; then + rm -rf "$BACKUP" + rm -rf "$(dirname "$NEW")" + open "$DEST" + else + rm -rf "$DEST" + if ! mv "$BACKUP" "$DEST"; then + osascript -e "display alert \\"Markee update failed\\" message \\"Could not restore Markee. Your previous version is saved at: $BACKUP\\"" 2>/dev/null || true + exit 2 + fi + open "$DEST" + exit 1 + fi + """ + let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("markee-swap-\(UUID().uuidString).sh") + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + scriptURL.path, + String(ProcessInfo.processInfo.processIdentifier), + newBundle.path, + installPath, + ] + try process.run() + // Intentionally not waited on — it must outlive this process. + } +} diff --git a/Sources/Markee/WindowAccessor.swift b/Sources/Markee/WindowAccessor.swift index f9917c6..2e473d7 100644 --- a/Sources/Markee/WindowAccessor.swift +++ b/Sources/Markee/WindowAccessor.swift @@ -1,18 +1,27 @@ import SwiftUI import AppKit -/// SwiftUI helper that surfaces the hosting `NSWindow` once it's attached. -/// Use as a `.background(...)` modifier. The closure runs once per window -/// attachment. +/// SwiftUI helper that surfaces the hosting `NSWindow` and keeps the +/// integrated-titlebar configuration applied to it. +/// +/// `onAttach` sets `titlebarAppearsTransparent`, `titleVisibility`, and +/// `.fullSizeContentView`. AppKit resets `titlebarAppearsTransparent` back to +/// `false` as part of window reactivation — and does so a beat *after* the +/// `didBecomeKey` / `didBecomeMain` notifications fire, so re-applying on +/// those notifications is clobbered milliseconds later. Instead, the +/// coordinator observes `titlebarAppearsTransparent` directly with KVO and +/// re-runs `onAttach` whenever the property is knocked back to `false`. KVO +/// change callbacks are synchronous, so the property is corrected within the +/// same runloop turn — before the window draws — leaving no visible flicker. struct WindowAccessor: NSViewRepresentable { let onAttach: (NSWindow) -> Void + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { - if let window = view.window { - onAttach(window) - } + context.coordinator.attach(to: view.window, onAttach: onAttach) } return view } @@ -20,8 +29,37 @@ struct WindowAccessor: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { // If the view wasn't yet in a window during makeNSView, retry on update. DispatchQueue.main.async { - if let window = nsView.window { - onAttach(window) + context.coordinator.attach(to: nsView.window, onAttach: onAttach) + } + } + + /// Owns the KVO guard that keeps the titlebar configuration durable across + /// window reactivation. Internal (not private) so it can be unit-tested. + @MainActor + final class Coordinator { + private weak var observedWindow: NSWindow? + private var transparencyGuard: NSKeyValueObservation? + + /// Apply `onAttach` to `window` now, and re-apply it whenever AppKit + /// resets `titlebarAppearsTransparent` back to `false`. + func attach(to window: NSWindow?, onAttach: @escaping (NSWindow) -> Void) { + guard let window else { return } + onAttach(window) + + guard observedWindow !== window else { return } + observedWindow = window + // Assigning a new observation releases (and invalidates) any + // previous one, so re-attaching to a different window is safe. + // The guard captures this call's `onAttach`; for Markee that + // closure only writes window chrome, so holding the first one is + // equivalent to holding any later one. + transparencyGuard = window.observe( + \.titlebarAppearsTransparent, options: [.new] + ) { observed, change in + // React only to AppKit knocking it false; our own re-apply + // sets it true, which re-fires KVO and stops here. + guard change.newValue == false else { return } + onAttach(observed) } } } diff --git a/Tests/MarkeeTests/UpdaterTests.swift b/Tests/MarkeeTests/UpdaterTests.swift new file mode 100644 index 0000000..9653b71 --- /dev/null +++ b/Tests/MarkeeTests/UpdaterTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Markee + +final class AppVersionTests: XCTestCase { + func test_parsesDottedNumbers() { + XCTAssertEqual(AppVersion("0.4.0")?.components, [0, 4, 0]) + } + + func test_toleratesLeadingV() { + XCTAssertEqual(AppVersion("v1.2.3")?.components, [1, 2, 3]) + } + + func test_dropsPrereleaseSuffix() { + XCTAssertEqual(AppVersion("0.5.0-beta")?.components, [0, 5, 0]) + } + + func test_newerComparesGreater() { + XCTAssertTrue(AppVersion("0.5.0")! > AppVersion("0.4.0")!) + XCTAssertTrue(AppVersion("0.4.1")! > AppVersion("0.4.0")!) + XCTAssertTrue(AppVersion("1.0.0")! > AppVersion("0.9.9")!) + } + + func test_missingTrailingComponentsTreatedAsZero() { + XCTAssertEqual(AppVersion("0.4"), AppVersion("0.4.0")) + XCTAssertFalse(AppVersion("0.4")! > AppVersion("0.4.0")!) + } + + func test_rejectsNonNumeric() { + XCTAssertNil(AppVersion("not-a-version")) + XCTAssertNil(AppVersion("")) + XCTAssertNil(AppVersion("1.x.0")) + } +} + +final class GitHubReleaseTests: XCTestCase { + private func payload(includeZip: Bool) -> Data { + let asset = includeZip + ? """ + { "name": "Markee.app.zip", + "browser_download_url": "https://example.com/dl/Markee.app.zip" } + """ + : """ + { "name": "SomethingElse.txt", + "browser_download_url": "https://example.com/dl/other.txt" } + """ + return Data(""" + { + "tag_name": "v0.5.0", + "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", + "body": "Release notes here.", + "assets": [ \(asset) ] + } + """.utf8) + } + + func test_parsesValidPayload() { + let release = GitHubRelease(json: payload(includeZip: true)) + XCTAssertNotNil(release) + XCTAssertEqual(release?.tagName, "v0.5.0") + XCTAssertEqual(release?.version, AppVersion("0.5.0")) + XCTAssertEqual(release?.zipURL.absoluteString, "https://example.com/dl/Markee.app.zip") + XCTAssertEqual(release?.notes, "Release notes here.") + XCTAssertEqual(release?.pageURL.absoluteString, + "https://github.com/sethbang/markee/releases/tag/v0.5.0") + } + + func test_nilWhenZipAssetMissing() { + XCTAssertNil(GitHubRelease(json: payload(includeZip: false))) + } + + func test_nilWhenMalformedJSON() { + XCTAssertNil(GitHubRelease(json: Data("not json".utf8))) + } + + func test_notesEmptyWhenBodyMissing() { + let json = Data(""" + { + "tag_name": "v0.5.0", + "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", + "assets": [ + { "name": "Markee.app.zip", + "browser_download_url": "https://example.com/dl/Markee.app.zip" } + ] + } + """.utf8) + let release = GitHubRelease(json: json) + XCTAssertNotNil(release) + XCTAssertEqual(release?.notes, "") + } +} diff --git a/Tests/MarkeeTests/WindowAccessorTests.swift b/Tests/MarkeeTests/WindowAccessorTests.swift new file mode 100644 index 0000000..37b0d4c --- /dev/null +++ b/Tests/MarkeeTests/WindowAccessorTests.swift @@ -0,0 +1,48 @@ +import XCTest +import AppKit +@testable import Markee + +@MainActor +final class WindowAccessorTests: XCTestCase { + func test_attachAppliesConfigurationImmediately() { + let window = NSWindow() + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { _ in applyCount += 1 } + XCTAssertEqual(applyCount, 1) + } + + func test_restoresTitlebarTransparencyWhenAppKitClobbersIt() { + let window = NSWindow() + window.titlebarAppearsTransparent = true + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { w in + w.titlebarAppearsTransparent = true + applyCount += 1 + } + XCTAssertEqual(applyCount, 1) + + // Reproduce the bug: AppKit resets this during window reactivation. + window.titlebarAppearsTransparent = false + + XCTAssertTrue(window.titlebarAppearsTransparent, + "the KVO guard must restore titlebar transparency") + XCTAssertEqual(applyCount, 2, "onAttach must re-run when transparency is clobbered") + } + + func test_ignoresTransparencyChangesOnOtherWindows() { + let window = NSWindow() + let other = NSWindow() + other.titlebarAppearsTransparent = true + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { w in + w.titlebarAppearsTransparent = true + applyCount += 1 + } + + other.titlebarAppearsTransparent = false + XCTAssertEqual(applyCount, 1, "another window's change must not trigger re-apply") + } +}