From 84edacccf4f4703c9164bc2203331b415c96e85b Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 14 Oct 2026 00:56:18 +0200 Subject: [PATCH 01/21] fix(GenesisHub, hub, sessions): #512 followup - relative folder origins are skipped, and a click ends the end chase --- src/hub/lib/prs.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/hub/lib/prs.test.ts b/src/hub/lib/prs.test.ts index f0d51b24ab..02fb7dd7ae 100644 --- a/src/hub/lib/prs.test.ts +++ b/src/hub/lib/prs.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { join } from "node:path"; -import type { CommandRunner } from "@genesiscz/utils/git/origins"; +import { type CommandRunner, isLocalRemoteUrl } from "@genesiscz/utils/git/origins"; import { TestRepo } from "@genesiscz/utils/git/test-repo"; import { SafeJSON } from "@genesiscz/utils/json"; import { setupStorageSandbox } from "@genesiscz/utils/storage/test-sandbox"; @@ -196,6 +196,55 @@ describe("hubPrs / hubPr", () => { await expect(hubPr({ ref: "https://github.com/o/r/pull/7", runner: failing })).rejects.toThrow(PrRefError); }); }); +describe("isLocalRemoteUrl", () => { + const local = [ + "/Users/dev/repo.git", + "~/repo.git", + "~user/repo.git", + "../repo.git", + "./repo.git", + "repos/source.git", + "source.git", + "file:///Users/dev/repo.git", + "file://localhost/Users/dev/repo.git", + "file:/Users/dev/repo.git", + "C:\\repo.git", + "C:/repo.git", + "C:repo.git", + "\\\\server\\share\\repo.git", + "//server/share/repo.git", + "\\repo.git", + "repos\\source.git", + ".\\repo.git", + "./foo:bar", + " /abs/repo.git ", + ]; + const remote = [ + "https://github.com/o/r.git", + "http://gitlab.example/g/r.git", + "ssh://git@github.com/o/r.git", + "ssh://git@github.com:2222/o/r.git", + "git://github.com/o/r.git", + "ftp://example.com/repo.git", + "git@github.com:o/r.git", + "github.com:o/r.git", + "git@gitlab.internal.example:group/repo.git", + "foo:bar", + "", + ]; + + it("treats folder paths as local, including relative paths and Windows drive or UNC paths", () => { + expect(local.map((url) => ({ url, local: isLocalRemoteUrl(url) }))).toEqual( + local.map((url) => ({ url, local: true })) + ); + }); + + it("leaves URL schemes and scp-like remotes to a forge driver", () => { + expect(remote.map((url) => ({ url, local: isLocalRemoteUrl(url) }))).toEqual( + remote.map((url) => ({ url, local: false })) + ); + }); +}); describe("missingCommits", () => { it("names the commits the repository lacks and none it holds", async () => { From 76937915498fb26fe60a45d074adf67b5bf49608 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 16:04:04 +0200 Subject: [PATCH 02/21] fix(GenesisHub, GenesisKit, permissions): the vault recipient check inspects processes that start meanwhile instead of denying the key, so a busy session no longer fails usage polls --- .../Sources/Host/VaultRecipient.swift | 26 +++++++++++++++---- .../GenesisTools/Tests/VaultUnlockTests.swift | 23 ++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift index 133aa6ec6c..1f064f9df1 100644 --- a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift +++ b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift @@ -211,15 +211,31 @@ enum VaultRecipient { } } + /// Inspects every user process, then each process that appeared meanwhile, until two lists in a row + /// add nothing. A busy session starts processes all the time, so one strict before/after pair denied + /// the key many times a day (82 on 2026-10-19). Exits cannot introduce a new reader; a process that + /// left the list without exiting changed uid, and a list that never settles still denies. + static func settledTables(rounds: Int, list: () throws -> Set, inspect: (pid_t) -> ProcessFDs, + exited: (pid_t) -> Bool) rethrows -> [ProcessFDs]? { + var seen = try list() + var tables = seen.sorted().map(inspect) + for _ in 0.. String? { do { let output = try stdoutEndpoint() guard output.connected else { return "vault stdout must be a connected pipe or Unix-domain socket" } - let pids = try userPids() - let tables = pids.sorted().map { processFDs($0, output: output) } - let currentPids = try userPids() - // Exits cannot introduce a new reader; new processes or uid changes still require a fresh request. - guard currentPids.isSubset(of: pids), pids.subtracting(currentPids).allSatisfy(hasExited) else { + guard let tables = try settledTables(rounds: 16, list: userPids, + inspect: { processFDs($0, output: output) }, exited: hasExited) else { return "the user's process list changed during vault recipient inspection" } guard try stdoutEndpoint() == output else { return "vault stdout changed during recipient inspection" } diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index 9b12bfbb6f..4f10c29234 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -53,6 +53,29 @@ final class VaultUnlockTests: XCTestCase { } } + func testVaultRecipientInspectsProcessesThatStartDuringInspection() { + let inspect = { (pid: pid_t) in VaultRecipient.ProcessFDs(pid: pid, endpoints: []) } + var lists: [Set] = [[1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4]] + let settled = VaultRecipient.settledTables(rounds: 16, list: { lists.removeFirst() }, inspect: inspect, + exited: { _ in false }) + XCTAssertEqual(settled?.map(\.pid), [1, 2, 3, 4], "a process that starts meanwhile is inspected, not a denial") + + var exits: [Set] = [[1, 2], [1], [1]] + XCTAssertEqual(VaultRecipient.settledTables(rounds: 16, list: { exits.removeFirst() }, inspect: inspect, + exited: { $0 == 2 })?.map(\.pid), [1, 2]) + + var uidChange: [Set] = [[1, 2], [1]] + XCTAssertNil(VaultRecipient.settledTables(rounds: 16, list: { uidChange.removeFirst() }, inspect: inspect, + exited: { _ in false }), "a process that left without exiting denies") + + var next: pid_t = 10 + let neverSettles = VaultRecipient.settledTables(rounds: 4, list: { () -> Set in + next += 1 + return Set(10...next) + }, inspect: inspect, exited: { _ in false }) + XCTAssertNil(neverSettles, "a list that keeps growing past the rounds still denies") + } + func testVaultRecipientSkipsOnlyExitedOrPermissionDeniedSystemProcesses() { let identity = VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, executable: "/bin/client") let output = VaultRecipient.Endpoint.pipe(handle: 10, peer: 20) From f860d1b79d35793dce38cd31504817d6fbb2a185 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 19:09:51 +0200 Subject: [PATCH 03/21] feat(GenesisKit, permissions): Option+Tab (macOS Move focus to next window) moves across every GenesisTools window, hub, reviews, settings and markdown, while the faces stay separate processes with one Dock tile --- .../Sources/Host/App/GenesisToolsApp.swift | 5 +- .../Sources/Host/App/WindowCycle.swift | 141 ++++++++++++++++++ .../Parts/Features/FeaturesEntry.swift | 1 + .../Sources/Parts/Hub/Hub/HubWindow.swift | 1 + .../Parts/Hub/Review/ReviewWindow.swift | 1 + .../Sources/Parts/Markdown/MarkdownFace.swift | 2 + .../GenesisTools/Tests/WindowCycleTests.swift | 55 +++++++ 7 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift create mode 100644 src/macos/GenesisTools/Tests/WindowCycleTests.swift diff --git a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift index df1f1a369c..d6a895d86b 100644 --- a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift +++ b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift @@ -145,7 +145,10 @@ final class GenesisAppDelegate: NSObject, NSApplicationDelegate { } quitAfterNotificationClick = false - MainActor.assumeIsolated { DockTile.keep(.window) } + MainActor.assumeIsolated { + DockTile.keep(.window) + WindowCycle.install() + } let controller = NSHostingController(rootView: RootView()) let window = NSWindow(contentViewController: controller) diff --git a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift new file mode 100644 index 0000000000..186531008c --- /dev/null +++ b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift @@ -0,0 +1,141 @@ +import AppKit +import CoreGraphics + +/// macOS "Move focus to next window" (Keyboard Shortcuts, symbolic hotkey 27: ⌘` by default, ⌥⇥ on +/// Martin's Mac) across every GenesisTools window. AppKit maps the key event to that hotkey in the +/// frontmost process and calls `-[NSWindow _cycleWindows:]` on its key window (AppKit's +/// `_NSKeyboardUIHandleSymbolicHotKey`, read from its disassembly on macOS 26.3, 2026-10-19), so it +/// only ever reaches the windows of that one process. The faces are separate processes (the hub, +/// the review windows, the settings and permissions windows, the markdown viewer), and only the hub +/// owns the Dock tile (App/DockTile.swift), so the shortcut never left the window it started in. +/// +/// Each window face installs this. Its local key monitor catches the user's own binding of hotkey 27 +/// (Shift reverses it, as in AppKit) before AppKit does, picks the next GenesisTools window in a fixed +/// order (WindowServer window numbers, ascending, so repeated presses visit every window rather than +/// toggling two), and raises it: itself when the window is its own, else by handing activation to +/// the face that owns it and asking it, through a distributed notification, to raise that window. +/// The processes and the single Dock tile stay as they are. With the shortcut turned off in System +/// Settings, nothing is caught. +@MainActor +package enum WindowCycle { + package struct Shortcut: Equatable { + package let keyCode: UInt16 + package let modifiers: NSEvent.ModifierFlags + + /// macOS's default when the user never changed it: ⌘` (kVK_ANSI_Grave). + package static let standard = Shortcut(keyCode: 50, modifiers: .command) + } + + package struct Candidate: Equatable { + package let pid: pid_t + package let number: Int + } + + private static let raiseName = Notification.Name("com.genesiscz.genesistools.windowCycle.raise") + private static let relevant: NSEvent.ModifierFlags = [.command, .option, .control, .shift] + private static var monitor: Any? + private static var observer: NSObjectProtocol? + + /// Once per window face, after `NSApplication.shared` exists. A snapshot or bench run may skip it. + package static func install() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + nonisolated(unsafe) let key = event + return MainActor.assumeIsolated { handle(key) } ? nil : event + } + observer = DistributedNotificationCenter.default().addObserver(forName: raiseName, object: nil, queue: .main) { note in + guard let pid = note.userInfo?["pid"] as? Int, pid == Int(getpid()), + let number = note.userInfo?["window"] as? Int else { return } + MainActor.assumeIsolated { raise(number, reason: "asked by another face") } + } + } + + /// Hotkey 27 as the user set it (`com.apple.symbolichotkeys`); nil when it is turned off. + package static func shortcut(from hotkeys: [String: Any]?) -> Shortcut? { + guard let entry = hotkeys?["27"] as? [String: Any] else { return .standard } + if let enabled = entry["enabled"] as? Bool, !enabled { return nil } + guard let value = entry["value"] as? [String: Any], let parameters = value["parameters"] as? [Int], + parameters.count >= 3, let keyCode = UInt16(exactly: parameters[1]), parameters[2] >= 0 else { return .standard } + return Shortcut(keyCode: keyCode, modifiers: NSEvent.ModifierFlags(rawValue: UInt(parameters[2])).intersection(relevant)) + } + + /// Forward, backward (the binding plus Shift), or nil when the event is not the shortcut. + package static func direction(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, shortcut: Shortcut) -> Bool? { + guard keyCode == shortcut.keyCode else { return nil } + let pressed = modifiers.intersection(relevant) + if pressed == shortcut.modifiers { return false } + if !shortcut.modifiers.contains(.shift), pressed == shortcut.modifiers.union(.shift) { return true } + return nil + } + + /// The windows a press may move to: normal-level, visible, of a real size, owned by `pids`. A + /// snapshot window (alpha 0), a panel above the normal level and a tooltip-sized helper are left out. + package static func candidates(_ windows: [[String: Any]], pids: Set) -> [Candidate] { + windows.compactMap { info in + guard let owner = info[kCGWindowOwnerPID as String] as? Int, pids.contains(pid_t(owner)), + (info[kCGWindowLayer as String] as? Int) == 0, + let number = info[kCGWindowNumber as String] as? Int else { return nil } + if let alpha = info[kCGWindowAlpha as String] as? Double, alpha < 0.05 { return nil } + if let bounds = info[kCGWindowBounds as String] as? NSDictionary, + let rect = CGRect(dictionaryRepresentation: bounds), rect.width < 160 || rect.height < 100 { + return nil + } + return Candidate(pid: pid_t(owner), number: number) + } + } + + /// The window after `current` in ascending window-number order (before it when `backwards`), + /// wrapping around; nil when no other window is there. + package static func next(after current: Int, in candidates: [Candidate], backwards: Bool) -> Candidate? { + let others = candidates.filter { $0.number != current }.sorted { $0.number < $1.number } + guard !others.isEmpty else { return nil } + if backwards { + return others.last { $0.number < current } ?? others.last + } + return others.first { $0.number > current } ?? others.first + } + + private static func handle(_ event: NSEvent) -> Bool { + // Plain typing never reads the preferences; a binding without ⌘, ⌥ or ⌃ is left to AppKit. + guard !event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return false } + let hotkeys = UserDefaults(suiteName: "com.apple.symbolichotkeys")?.dictionary(forKey: "AppleSymbolicHotKeys") + guard let shortcut = shortcut(from: hotkeys), + let backwards = direction(keyCode: event.keyCode, modifiers: event.modifierFlags, shortcut: shortcut), + let key = NSApp.keyWindow else { return false } + let pids = Set(NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") + .map(\.processIdentifier)).union([getpid()]) + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] + guard let target = next(after: key.windowNumber, in: candidates(list, pids: pids), backwards: backwards) else { + // One window only: AppKit's own cycle has nothing to do either. + return false + } + HubPerf.log("windowCycle: \(backwards ? "back" : "next") from window \(key.windowNumber) to window \(target.number) of pid \(target.pid)") + if target.pid == getpid() { + raise(target.number, reason: "own window") + return true + } + guard let app = NSRunningApplication(processIdentifier: target.pid) else { return false } + // The owner orders its window front; this face, active now, hands it the activation. A yield alone + // left the other face behind on about half the presses (five presses on one pair, 2026-10-19 18:46). + DistributedNotificationCenter.default().postNotificationName( + raiseName, object: nil, userInfo: ["pid": Int(target.pid), "window": target.number], deliverImmediately: true) + NSApp.yieldActivation(to: app) + if !app.activate(from: NSRunningApplication.current, options: []) { + HubPerf.log("windowCycle: macOS refused to activate pid \(target.pid)") + } + return true + } + + private static func raise(_ number: Int, reason: String) { + guard let window = NSApp.window(withWindowNumber: number) else { + HubPerf.log("windowCycle: window \(number) is gone (\(reason))") + return + } + let shown = window.sheetParent ?? window + if shown.isMiniaturized { + shown.deminiaturize(nil) + } + shown.makeKeyAndOrderFront(nil) + NSApp.activate() + } +} diff --git a/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift b/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift index e7b39bc7c3..4432bfcf89 100644 --- a/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift +++ b/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift @@ -266,6 +266,7 @@ package func runFeatures(_ args: [String] = []) -> Never { // A notification click that runs something hands focus back to the app it was clicked over. BrowserURLForwarder.shared.trackOtherApps() installNotificationClicksForWindowFace() + MainActor.assumeIsolated { WindowCycle.install() } app.setActivationPolicy(.accessory) app.run() withExtendedLifetime(delegate) {} diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift index e96b56eabd..01f71e172f 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -221,6 +221,7 @@ package func runHub(_ args: [String]) -> Never { installBrowserURLForwarder() if !request.isScripted { installNotificationClicksForWindowFace() + MainActor.assumeIsolated { WindowCycle.install() } } let model = HubModel(wantedSession: wantedSession, tab: tab) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift index 92f5118e07..45a095eaac 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift @@ -90,6 +90,7 @@ package func runReview(_ args: [String]) -> Never { installBrowserURLForwarder() if snapshotPath == nil { installNotificationClicksForWindowFace() + MainActor.assumeIsolated { WindowCycle.install() } } MainActor.assumeIsolated { AppMainMenu.install() } diff --git a/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift b/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift index 2545701a73..51b8f49029 100644 --- a/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift +++ b/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift @@ -49,6 +49,8 @@ package func runMarkdown(_ args: [String]) -> Never { MarkdownDocumentWindowController.shared.show(paths: request.paths, focus: request.focus, diff: request.diff) } } + _ = NSApplication.shared + WindowCycle.install() MarkdownViewerApp.run(MarkdownViewerAppConfiguration( appName: "GenesisTools", paths: paths, diff --git a/src/macos/GenesisTools/Tests/WindowCycleTests.swift b/src/macos/GenesisTools/Tests/WindowCycleTests.swift new file mode 100644 index 0000000000..2b57f1fc89 --- /dev/null +++ b/src/macos/GenesisTools/Tests/WindowCycleTests.swift @@ -0,0 +1,55 @@ +import AppKit +import XCTest +@testable import GenesisToolsHost + +@MainActor +final class WindowCycleTests: XCTestCase { + /// `com.apple.symbolichotkeys` as `defaults read` shows it: [character, key code, modifier flags]. + private func hotkey(_ parameters: [Int], enabled: Bool = true) -> [String: Any] { + ["27": ["enabled": enabled, "value": ["parameters": parameters, "type": "standard"]]] + } + + func testTheShortcutIsTheUsersBindingOfHotkey27OrTheSystemDefault() { + XCTAssertEqual(WindowCycle.shortcut(from: hotkey([65535, 48, 524288])), .init(keyCode: 48, modifiers: .option)) + XCTAssertEqual(WindowCycle.shortcut(from: nil), .standard, "never changed: ⌘`") + XCTAssertEqual(WindowCycle.shortcut(from: [:]), .standard) + XCTAssertNil(WindowCycle.shortcut(from: hotkey([65535, 48, 524288], enabled: false)), "turned off: nothing is caught") + } + + func testShiftReversesTheShortcutAndAnyOtherKeyIsLeftAlone() { + let optionTab = WindowCycle.Shortcut(keyCode: 48, modifiers: .option) + XCTAssertEqual(WindowCycle.direction(keyCode: 48, modifiers: [.option], shortcut: optionTab), false) + XCTAssertEqual(WindowCycle.direction(keyCode: 48, modifiers: [.option, .shift], shortcut: optionTab), true) + XCTAssertEqual(WindowCycle.direction(keyCode: 48, modifiers: [.option, .capsLock, .numericPad], shortcut: optionTab), false) + XCTAssertNil(WindowCycle.direction(keyCode: 48, modifiers: [.command], shortcut: optionTab), "⌘⇥ is the app switcher") + XCTAssertNil(WindowCycle.direction(keyCode: 48, modifiers: [], shortcut: optionTab), "a plain Tab moves focus in the window") + XCTAssertNil(WindowCycle.direction(keyCode: 50, modifiers: [.option], shortcut: optionTab)) + } + + func testOnlyVisibleNormalWindowsOfTheFacesAreCandidates() { + func window(_ number: Int, pid: Int, layer: Int = 0, alpha: Double = 1, size: CGSize = CGSize(width: 900, height: 600)) -> [String: Any] { + [kCGWindowNumber as String: number, kCGWindowOwnerPID as String: pid, kCGWindowLayer as String: layer, + kCGWindowAlpha as String: alpha, + kCGWindowBounds as String: CGRect(origin: .zero, size: size).dictionaryRepresentation as NSDictionary] + } + let list = [ + window(10, pid: 1), window(11, pid: 2), window(12, pid: 3), + window(13, pid: 1, layer: 3), window(14, pid: 2, alpha: 0), window(15, pid: 2, size: CGSize(width: 40, height: 20)), + ] + XCTAssertEqual(WindowCycle.candidates(list, pids: [1, 2]), [.init(pid: 1, number: 10), .init(pid: 2, number: 11)]) + } + + func testRepeatedPressesVisitEveryWindowInAFixedOrderAcrossProcesses() { + let hub = WindowCycle.Candidate(pid: 1, number: 30) + let review = WindowCycle.Candidate(pid: 2, number: 12) + let settings = WindowCycle.Candidate(pid: 3, number: 51) + let all = [hub, review, settings] + XCTAssertEqual(WindowCycle.next(after: 12, in: all, backwards: false), hub) + XCTAssertEqual(WindowCycle.next(after: 30, in: all, backwards: false), settings) + XCTAssertEqual(WindowCycle.next(after: 51, in: all, backwards: false), review, "wraps around") + XCTAssertEqual(WindowCycle.next(after: 12, in: all, backwards: true), settings) + XCTAssertEqual(WindowCycle.next(after: 30, in: all, backwards: true), review) + XCTAssertEqual(WindowCycle.next(after: 40, in: all, backwards: false), settings, "a key window not in the list still has a place") + XCTAssertNil(WindowCycle.next(after: 30, in: [hub], backwards: false), "one window: nothing to move to") + } +} From 7676eec86729465ff13c7b6737fe4f9adcad7371 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 23:49:05 +0200 Subject: [PATCH 04/21] fix(GenesisKit, permissions): Option+Tab follows the last-focused order as Cmd+Tab does (a held modifier steps through a frozen list, the release puts the landed window first), and Cmd+Tab back to the hub shows the window that was key when GenesisTools lost the front --- .../Sources/Host/App/WindowCycle.swift | 265 +++++++++++++++--- .../GenesisTools/Tests/WindowCycleTests.swift | 52 +++- 2 files changed, 264 insertions(+), 53 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift index 186531008c..841b678f19 100644 --- a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift +++ b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift @@ -10,12 +10,25 @@ import CoreGraphics /// owns the Dock tile (App/DockTile.swift), so the shortcut never left the window it started in. /// /// Each window face installs this. Its local key monitor catches the user's own binding of hotkey 27 -/// (Shift reverses it, as in AppKit) before AppKit does, picks the next GenesisTools window in a fixed -/// order (WindowServer window numbers, ascending, so repeated presses visit every window rather than -/// toggling two), and raises it: itself when the window is its own, else by handing activation to -/// the face that owns it and asking it, through a distributed notification, to raise that window. +/// (Shift reverses it, as in AppKit) before AppKit does and raises the next GenesisTools window: +/// itself when the window is its own, else by handing activation to the face that owns it and asking +/// it, through a distributed notification, to raise that window. +/// +/// The order is the one Cmd+Tab uses (Martin, 2026-10-19: "the order it was last switched to"). Every +/// face broadcasts each window that becomes key, so every face keeps the same most-recently-used +/// list. The first press freezes that list, each press while the modifier stays held steps through +/// it, and the release puts the landed window first. So one press and a release goes back to the +/// previous window, and a held modifier reaches every window, where a live list would toggle two. +/// The frozen list travels with the raise request, because the next press arrives in the face that +/// owns the landed window. +/// +/// Cmd+Tab knows only the Dock-tile owner (the review and settings faces are `.accessory`), so +/// Cmd+Tab back to GenesisTools activates the hub. When the tile owner becomes active by Cmd+Tab +/// (the last input was a modifier release, not a click or a typed key), it hands activation on to +/// the window that was key when GenesisTools lost the front. +/// /// The processes and the single Dock tile stay as they are. With the shortcut turned off in System -/// Settings, nothing is caught. +/// Settings, no key is caught. @MainActor package enum WindowCycle { package struct Shortcut: Equatable { @@ -31,23 +44,87 @@ package enum WindowCycle { package let number: Int } + /// One run of presses while the modifier stays held: the list frozen at the first press and the + /// window the last press landed on. + package struct Session: Equatable { + package let order: [Candidate] + package let index: Int + /// The modifiers whose release ends the run. + package let modifiers: NSEvent.ModifierFlags + + package var landed: Candidate { order[index] } + + /// The first press: `order` starts with the key window. Nil when no other window is there. + package static func start(_ order: [Candidate], backwards: Bool, modifiers: NSEvent.ModifierFlags) -> Session? { + guard order.count > 1 else { return nil } + return Session(order: order, index: backwards ? order.count - 1 : 1, modifiers: modifiers) + } + + package func step(backwards: Bool) -> Session { + Session(order: order, index: (index + (backwards ? order.count - 1 : 1)) % order.count, modifiers: modifiers) + } + } + private static let raiseName = Notification.Name("com.genesiscz.genesistools.windowCycle.raise") + private static let focusName = Notification.Name("com.genesiscz.genesistools.windowCycle.focused") private static let relevant: NSEvent.ModifierFlags = [.command, .option, .control, .shift] - private static var monitor: Any? - private static var observer: NSObjectProtocol? + private static let recentLimit = 64 + private static var installed = false + private static var tokens: [Any] = [] + /// Most recently focused first, over every face. + private static var recent: [Candidate] = [] + private static var session: Session? + /// The window this face raised for a run of presses: its focus is not a choice, so it does not move the list. + private static var cycleRaise: Int? + private static var lastCycleRaise = Date.distantPast + /// `recent` as it was when another app took the front; the tile owner reads it on Cmd+Tab back. + private static var leftFrom: [Candidate]? /// Once per window face, after `NSApplication.shared` exists. A snapshot or bench run may skip it. package static func install() { - guard monitor == nil else { return } - monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard !installed else { return } + installed = true + recent = currentCandidates() + let keys = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in nonisolated(unsafe) let key = event return MainActor.assumeIsolated { handle(key) } ? nil : event } - observer = DistributedNotificationCenter.default().addObserver(forName: raiseName, object: nil, queue: .main) { note in - guard let pid = note.userInfo?["pid"] as? Int, pid == Int(getpid()), - let number = note.userInfo?["window"] as? Int else { return } - MainActor.assumeIsolated { raise(number, reason: "asked by another face") } + let flags = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { event in + let modifiers = event.modifierFlags + MainActor.assumeIsolated { released(modifiers) } + return event } + tokens += [keys, flags].compactMap { $0 } + let distributed = DistributedNotificationCenter.default() + tokens.append(distributed.addObserver(forName: raiseName, object: nil, queue: .main) { note in + nonisolated(unsafe) let info = note.userInfo + MainActor.assumeIsolated { raiseRequested(info) } + }) + tokens.append(distributed.addObserver(forName: focusName, object: nil, queue: .main) { note in + guard let pid = note.userInfo?["pid"] as? Int, pid != Int(getpid()), + let number = note.userInfo?["window"] as? Int else { return } + MainActor.assumeIsolated { + recent = promote(Candidate(pid: pid_t(pid), number: number), in: recent) + // A window of another face took focus: GenesisTools is in use, so a Cmd+Tab back is not pending. + leftFrom = nil + } + }) + tokens.append(NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main) { note in + guard let window = note.object as? NSWindow else { return } + let number = window.windowNumber + MainActor.assumeIsolated { becameKey(number) } + }) + tokens.append(NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { note in + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + let ours = app?.bundleIdentifier == Bundle.main.bundleIdentifier + MainActor.assumeIsolated { + if !ours { leftFrom = recent } + } + }) + tokens.append(NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { _ in + MainActor.assumeIsolated { becameActive() } + }) } /// Hotkey 27 as the user set it (`com.apple.symbolichotkeys`); nil when it is turned off. @@ -68,8 +145,8 @@ package enum WindowCycle { return nil } - /// The windows a press may move to: normal-level, visible, of a real size, owned by `pids`. A - /// snapshot window (alpha 0), a panel above the normal level and a tooltip-sized helper are left out. + /// The windows a press may move to, front to back: normal-level, visible, of a real size, owned by + /// `pids`. A snapshot window (alpha 0), a panel above the normal level and a tooltip-sized helper are left out. package static func candidates(_ windows: [[String: Any]], pids: Set) -> [Candidate] { windows.compactMap { info in guard let owner = info[kCGWindowOwnerPID as String] as? Int, pids.contains(pid_t(owner)), @@ -84,15 +161,31 @@ package enum WindowCycle { } } - /// The window after `current` in ascending window-number order (before it when `backwards`), - /// wrapping around; nil when no other window is there. - package static func next(after current: Int, in candidates: [Candidate], backwards: Bool) -> Candidate? { - let others = candidates.filter { $0.number != current }.sorted { $0.number < $1.number } - guard !others.isEmpty else { return nil } - if backwards { - return others.last { $0.number < current } ?? others.last - } - return others.first { $0.number > current } ?? others.first + /// `window` first, then the rest of `recent` without it. + package static func promote(_ window: Candidate, in recent: [Candidate]) -> [Candidate] { + Array(([window] + recent.filter { $0.number != window.number }).prefix(recentLimit)) + } + + /// The list a first press freezes: the key window, then the windows on screen in the order they were + /// last focused, then any window never seen focused, front to back. + package static func order(_ candidates: [Candidate], recent: [Candidate], key: Candidate) -> [Candidate] { + let known = recent.filter { item in candidates.contains { $0.number == item.number } } + let unseen = candidates.filter { item in !known.contains { $0.number == item.number } } + return [key] + (known + unseen).filter { $0.number != key.number } + } + + /// The window to show on Cmd+Tab back: the first window of `leftFrom` still on screen, when it is not + /// the tile owner's own (AppKit already brought those forward). + package static func restoreTarget(_ leftFrom: [Candidate], candidates: [Candidate], own: pid_t) -> Candidate? { + guard let window = leftFrom.first(where: { item in candidates.contains { $0.number == item.number } }), + window.pid != own else { return nil } + return window + } + + /// Cmd+Tab activates on the release of ⌘, so a modifier change is the newest input. A click (the Dock, + /// a window, a notification, a link in a browser) or a typed key (a launcher, a terminal) is newer otherwise. + package static func cameByCommandTab(sinceFlags: Double, sinceMouse: Double, sinceKey: Double) -> Bool { + sinceFlags < 1 && sinceFlags < sinceMouse && sinceFlags < sinceKey } private static func handle(_ event: NSEvent) -> Bool { @@ -102,36 +195,126 @@ package enum WindowCycle { guard let shortcut = shortcut(from: hotkeys), let backwards = direction(keyCode: event.keyCode, modifiers: event.modifierFlags, shortcut: shortcut), let key = NSApp.keyWindow else { return false } - let pids = Set(NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") - .map(\.processIdentifier)).union([getpid()]) - let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] - guard let target = next(after: key.windowNumber, in: candidates(list, pids: pids), backwards: backwards) else { + let list = currentCandidates() + var next: Session + if let current = session, current.landed.number == key.windowNumber { + next = current.step(backwards: backwards) + } else { + let frozen = order(list, recent: recent, key: Candidate(pid: getpid(), number: key.windowNumber)) // One window only: AppKit's own cycle has nothing to do either. - return false + guard let first = Session.start(frozen, backwards: backwards, modifiers: shortcut.modifiers) else { return false } + next = first } - HubPerf.log("windowCycle: \(backwards ? "back" : "next") from window \(key.windowNumber) to window \(target.number) of pid \(target.pid)") - if target.pid == getpid() { - raise(target.number, reason: "own window") - return true + // A window closed during the run: step over it. + var skipped = 0 + while skipped < next.order.count, !list.contains(where: { $0.number == next.landed.number }) { + next = next.step(backwards: backwards) + skipped += 1 } - guard let app = NSRunningApplication(processIdentifier: target.pid) else { return false } - // The owner orders its window front; this face, active now, hands it the activation. A yield alone - // left the other face behind on about half the presses (five presses on one pair, 2026-10-19 18:46). - DistributedNotificationCenter.default().postNotificationName( - raiseName, object: nil, userInfo: ["pid": Int(target.pid), "window": target.number], deliverImmediately: true) + HubPerf.log("windowCycle: \(backwards ? "back" : "next") from window \(key.windowNumber) to window \(next.landed.number) of pid \(next.landed.pid) (\(next.index + 1) of \(next.order.count))") + go(to: next) + return true + } + + private static func go(to next: Session) { + lastCycleRaise = Date() + if next.landed.pid == getpid() { + session = next + raise(next.landed.number, reason: "own window", cycling: true) + return + } + session = nil + handOff(to: next.landed, session: next) + } + + /// Asks the face that owns `window` to raise it, then gives it the activation. The owner orders its + /// window front; this face, active now, hands it the activation. A yield alone left the other face + /// behind on about half the presses (five presses on one pair, 2026-10-19 18:46). + private static func handOff(to window: Candidate, session: Session?) { + guard let app = NSRunningApplication(processIdentifier: window.pid) else { return } + var info: [String: Any] = ["pid": Int(window.pid), "window": window.number] + if let session { + info["order"] = session.order.flatMap { [Int($0.pid), $0.number] } + info["index"] = session.index + info["modifiers"] = Int(session.modifiers.rawValue) + } + DistributedNotificationCenter.default().postNotificationName(raiseName, object: nil, userInfo: info, deliverImmediately: true) NSApp.yieldActivation(to: app) if !app.activate(from: NSRunningApplication.current, options: []) { - HubPerf.log("windowCycle: macOS refused to activate pid \(target.pid)") + HubPerf.log("windowCycle: macOS refused to activate pid \(window.pid)") } - return true } - private static func raise(_ number: Int, reason: String) { + private static func raiseRequested(_ info: [AnyHashable: Any]?) { + guard let pid = info?["pid"] as? Int, pid == Int(getpid()), let number = info?["window"] as? Int else { return } + lastCycleRaise = Date() + leftFrom = nil + guard let flat = info?["order"] as? [Int], let index = info?["index"] as? Int, let raw = info?["modifiers"] as? Int, + flat.count.isMultiple(of: 2), index < flat.count / 2 else { + raise(number, reason: "asked by another face", cycling: false) + return + } + let order = stride(from: 0, to: flat.count, by: 2).map { Candidate(pid: pid_t(flat[$0]), number: flat[$0 + 1]) } + let modifiers = NSEvent.ModifierFlags(rawValue: UInt(raw)) + // The modifier went up while the activation was on its way: this face never sees that release. + let held = NSEvent.ModifierFlags(rawValue: UInt(CGEventSource.flagsState(.combinedSessionState).rawValue)) + guard held.contains(modifiers) else { + session = nil + raise(number, reason: "asked by another face, run ended", cycling: false) + return + } + session = Session(order: order, index: index, modifiers: modifiers) + raise(number, reason: "asked by another face", cycling: true) + } + + /// The modifier of a run went up: the landed window becomes the most recent one everywhere. + private static func released(_ modifiers: NSEvent.ModifierFlags) { + guard let current = session, !modifiers.intersection(relevant).contains(current.modifiers) else { return } + session = nil + noteFocus(current.landed) + } + + private static func becameKey(_ number: Int) { + if cycleRaise == number { + cycleRaise = nil + return + } + noteFocus(Candidate(pid: getpid(), number: number)) + } + + private static func noteFocus(_ window: Candidate) { + recent = promote(window, in: recent) + DistributedNotificationCenter.default().postNotificationName( + focusName, object: nil, userInfo: ["pid": Int(window.pid), "window": window.number], deliverImmediately: true) + } + + /// The tile owner came to the front. On Cmd+Tab back, show the window that was key when GenesisTools left. + private static func becameActive() { + guard let left = leftFrom else { return } + leftFrom = nil + guard NSApp.activationPolicy() == .regular, Date().timeIntervalSince(lastCycleRaise) > 1.5 else { return } + let since = { (type: CGEventType) in CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: type) } + let mouse = min(since(.leftMouseDown), since(.rightMouseDown), since(.otherMouseDown)) + guard cameByCommandTab(sinceFlags: since(.flagsChanged), sinceMouse: mouse, sinceKey: since(.keyDown)), + let target = restoreTarget(left, candidates: currentCandidates(), own: getpid()) else { return } + HubPerf.log("windowCycle: Cmd+Tab back, showing window \(target.number) of pid \(target.pid)") + handOff(to: target, session: nil) + } + + private static func currentCandidates() -> [Candidate] { + let pids = Set(NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") + .map(\.processIdentifier)).union([getpid()]) + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] + return candidates(list, pids: pids) + } + + private static func raise(_ number: Int, reason: String, cycling: Bool) { guard let window = NSApp.window(withWindowNumber: number) else { HubPerf.log("windowCycle: window \(number) is gone (\(reason))") return } let shown = window.sheetParent ?? window + cycleRaise = cycling ? shown.windowNumber : nil if shown.isMiniaturized { shown.deminiaturize(nil) } diff --git a/src/macos/GenesisTools/Tests/WindowCycleTests.swift b/src/macos/GenesisTools/Tests/WindowCycleTests.swift index 2b57f1fc89..fd9dbbf5a4 100644 --- a/src/macos/GenesisTools/Tests/WindowCycleTests.swift +++ b/src/macos/GenesisTools/Tests/WindowCycleTests.swift @@ -39,17 +39,45 @@ final class WindowCycleTests: XCTestCase { XCTAssertEqual(WindowCycle.candidates(list, pids: [1, 2]), [.init(pid: 1, number: 10), .init(pid: 2, number: 11)]) } - func testRepeatedPressesVisitEveryWindowInAFixedOrderAcrossProcesses() { - let hub = WindowCycle.Candidate(pid: 1, number: 30) - let review = WindowCycle.Candidate(pid: 2, number: 12) - let settings = WindowCycle.Candidate(pid: 3, number: 51) - let all = [hub, review, settings] - XCTAssertEqual(WindowCycle.next(after: 12, in: all, backwards: false), hub) - XCTAssertEqual(WindowCycle.next(after: 30, in: all, backwards: false), settings) - XCTAssertEqual(WindowCycle.next(after: 51, in: all, backwards: false), review, "wraps around") - XCTAssertEqual(WindowCycle.next(after: 12, in: all, backwards: true), settings) - XCTAssertEqual(WindowCycle.next(after: 30, in: all, backwards: true), review) - XCTAssertEqual(WindowCycle.next(after: 40, in: all, backwards: false), settings, "a key window not in the list still has a place") - XCTAssertNil(WindowCycle.next(after: 30, in: [hub], backwards: false), "one window: nothing to move to") + private let hub = WindowCycle.Candidate(pid: 1, number: 30) + private let review = WindowCycle.Candidate(pid: 2, number: 12) + private let settings = WindowCycle.Candidate(pid: 3, number: 51) + private let markdown = WindowCycle.Candidate(pid: 4, number: 70) + + func testTheFrozenListIsTheKeyWindowThenTheLastFocusedThenTheUnseen() { + let onScreen = [markdown, settings, hub, review] + let recent = [review, WindowCycle.Candidate(pid: 2, number: 99), hub, settings] + XCTAssertEqual(WindowCycle.order(onScreen, recent: recent, key: review), [review, hub, settings, markdown], + "a closed window (99) drops out; one never focused (markdown) comes last") + XCTAssertEqual(WindowCycle.promote(settings, in: recent).prefix(2), [settings, review]) + } + + func testAHeldModifierVisitsEveryWindowAndOnePressGoesBackToThePreviousWindow() { + let frozen = [review, hub, settings, markdown] + let first = WindowCycle.Session.start(frozen, backwards: false, modifiers: .option) + XCTAssertEqual(first?.landed, hub, "one press: the previous window") + XCTAssertEqual(first?.step(backwards: false).landed, settings) + XCTAssertEqual(first?.step(backwards: false).step(backwards: false).step(backwards: false).landed, review, "wraps around") + XCTAssertEqual(WindowCycle.Session.start(frozen, backwards: true, modifiers: .option)?.landed, markdown) + XCTAssertNil(WindowCycle.Session.start([review], backwards: false, modifiers: .option), "one window: nothing to move to") + + // Martin's case: three presses held, then a release, then a new run starts from the landed window. + var recent = [review, hub, settings, markdown] + var run = WindowCycle.Session.start(WindowCycle.order(frozen, recent: recent, key: review), backwards: false, modifiers: .option)! + for _ in 1..<3 { run = run.step(backwards: false) } + XCTAssertEqual(run.landed, markdown) + recent = WindowCycle.promote(run.landed, in: recent) + let next = WindowCycle.Session.start(WindowCycle.order(frozen, recent: recent, key: markdown), backwards: false, modifiers: .option) + XCTAssertEqual(next?.landed, review, "the window the run started from, not the first by number") + } + + func testCmdTabBackShowsTheWindowThatWasKeyWhenGenesisToolsLeft() { + let onScreen = [hub, review, settings] + XCTAssertEqual(WindowCycle.restoreTarget([markdown, settings, hub], candidates: onScreen, own: 1), settings, + "the markdown window closed meanwhile") + XCTAssertNil(WindowCycle.restoreTarget([hub, review], candidates: onScreen, own: 1), "the hub's own window: AppKit shows it") + XCTAssertTrue(WindowCycle.cameByCommandTab(sinceFlags: 0.05, sinceMouse: 4, sinceKey: 0.2)) + XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 2, sinceMouse: 4, sinceKey: 0.1), "a typed key (a launcher)") + XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 0.3, sinceMouse: 0.02, sinceKey: 5), "a click (Dock, link, notification)") } } From a80025f9e89628f0e6a8116339c8b8269a97dc7b Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:19:12 +0200 Subject: [PATCH 05/21] feat(GenesisKit): a changed or added image in a diff shows its picture (before and after side by side), loaded only when it comes near the viewport, instead of a bare (binary file) line --- .../GenesisKit/Diff/Model/DiffModel.swift | 30 ++++- .../Diff/Render/PierreWebDiffRenderer.swift | 105 +++++++++++++++++- .../Diff/Source/GitWorkingTreeSource.swift | 8 ++ .../Diff/GitFileDiffTests.swift | 32 ++++++ .../GenesisTools/web/diff-viewer/main.ts | 74 +++++++++++- 5 files changed, 245 insertions(+), 4 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 3e3eaff760..241898c7aa 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -18,6 +18,8 @@ public struct DiffFile: Codable, Identifiable, Hashable { public var newContents: String? /// Binary or too large to show; renderers show a placeholder instead of lines. public var skipped: String? + /// A skipped file that is an image: where its two sides' bytes are, so a renderer can show the picture. + public var image: DiffImage? public var name: String { (path as NSString).lastPathComponent @@ -28,7 +30,7 @@ public struct DiffFile: Codable, Identifiable, Hashable { return dir.isEmpty ? "" : dir } - public init(id: String, path: String, oldPath: String? = nil, status: Status, additions: Int, deletions: Int, oldContents: String? = nil, newContents: String? = nil, skipped: String? = nil) { + public init(id: String, path: String, oldPath: String? = nil, status: Status, additions: Int, deletions: Int, oldContents: String? = nil, newContents: String? = nil, skipped: String? = nil, image: DiffImage? = nil) { self.id = id self.path = path self.oldPath = oldPath @@ -38,6 +40,32 @@ public struct DiffFile: Codable, Identifiable, Hashable { self.oldContents = oldContents self.newContents = newContents self.skipped = skipped + self.image = image + } +} + +/// The two sides of an image in a diff. Nil for the side a file does not have (added: no old, deleted: no new). +public struct DiffImage: Codable, Hashable { + public enum Source: Codable, Hashable { + /// A file on disk (the working tree). + case file(String) + /// `git -C cat-file blob `, the object as `:` (an empty rev is the index). + case blob(repo: String, object: String) + } + + public var old: Source? + public var new: Source? + + public init(old: Source?, new: Source?) { + self.old = old + self.new = new + } + + /// What WebKit draws. SVG is text, so it never reaches here as a skipped file. + public static let extensions: Set = ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "avif", "bmp", "ico", "tif", "tiff"] + + public static func isImage(_ path: String) -> Bool { + extensions.contains((path as NSString).pathExtension.lowercased()) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index 64b3246990..7e5e857a77 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -350,6 +350,13 @@ private struct BridgeFile: Encodable { public let newContents: String? /// Changes when either side's text changes; the page keeps the parsed diff of a file whose key it has. public let key: String + /// An image file's two sides as `genesis-diff://viewer/_image/` URLs; the page shows the pictures. + public let image: BridgeImage? + + public struct BridgeImage: Encodable { + public let old: String? + public let new: String? + } public init(_ file: DiffFile) { id = file.id @@ -363,7 +370,9 @@ private struct BridgeFile: Encodable { hasher.combine(file.oldPath) hasher.combine(oldContents) hasher.combine(newContents) + hasher.combine(file.image) key = String(hasher.finalize(), radix: 36) + image = file.image.map { BridgeImage(old: $0.old.map(DiffImageRegistry.url), new: $0.new.map(DiffImageRegistry.url)) } } } @@ -502,11 +511,73 @@ extension PierreWebDiffRenderer { } } -/// Serves `PierreWebDiffRenderer.viewerRoot`. +/// The image sources the page may ask for, by token. A token names its source for the whole process, so a page +/// that kept an old URL still gets its picture. +enum DiffImageRegistry { + static let prefix = "/_image/" + private static let lock = NSLock() + private static var sources: [String: DiffImage.Source] = [:] + + static func url(_ source: DiffImage.Source) -> String { + var hasher = Hasher() + hasher.combine(source) + let token = String(UInt(bitPattern: hasher.finalize()), radix: 36) + lock.withLock { sources[token] = source } + return "\(PierreWebDiffRenderer.scheme)://viewer\(prefix)\(token)" + } + + static func source(_ token: String) -> DiffImage.Source? { + lock.withLock { sources[token] } + } + + /// The bytes and the path whose extension names their type. Runs git or reads the disk, so off the main thread. + static func load(_ source: DiffImage.Source) -> (data: Data, path: String)? { + switch source { + case .file(let path): + return (try? Data(contentsOf: URL(fileURLWithPath: path))).map { ($0, path) } + case .blob(let repo, let object): + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", repo, "cat-file", "blob", object] + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return (data, object) + } + } + + static func mimeType(for path: String) -> String { + switch (path as NSString).pathExtension.lowercased() { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "heic": return "image/heic" + case "heif": return "image/heif" + case "avif": return "image/avif" + case "bmp": return "image/bmp" + case "ico": return "image/x-icon" + case "tif", "tiff": return "image/tiff" + default: return "application/octet-stream" + } + } +} + +/// Serves `PierreWebDiffRenderer.viewerRoot`, and the diff's images under `DiffImageRegistry.prefix`. private final class DiffViewerSchemeHandler: NSObject, WKURLSchemeHandler { private let root = PierreWebDiffRenderer.viewerRoot + /// Tasks WebKit stopped (the image row scrolled away): they must get no more calls. + private var stopped = Set() public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + if let url = urlSchemeTask.request.url, url.path.hasPrefix(DiffImageRegistry.prefix) { + serveImage(url, task: urlSchemeTask) + return + } guard let url = urlSchemeTask.request.url, let root else { urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) return @@ -530,7 +601,37 @@ private final class DiffViewerSchemeHandler: NSObject, WKURLSchemeHandler { urlSchemeTask.didFinish() } - public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + stopped.insert(ObjectIdentifier(urlSchemeTask)) + } + + private func serveImage(_ url: URL, task: WKURLSchemeTask) { + let id = ObjectIdentifier(task) + stopped.remove(id) + guard let source = DiffImageRegistry.source(String(url.path.dropFirst(DiffImageRegistry.prefix.count))) else { + task.didFailWithError(URLError(.fileDoesNotExist)) + return + } + Task.detached(priority: .userInitiated) { + let loaded = DiffImageRegistry.load(source) + await MainActor.run { + guard self.stopped.remove(id) == nil else { return } + guard let loaded else { + task.didFailWithError(URLError(.fileDoesNotExist)) + return + } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": DiffImageRegistry.mimeType(for: loaded.path), "Content-Length": String(loaded.data.count)] + )! + task.didReceive(response) + task.didReceive(loaded.data) + task.didFinish() + } + } + } private static func mimeType(for ext: String) -> String { switch ext { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index 9d8d382362..f1d9a1e408 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -341,6 +341,14 @@ public struct GitWorkingTreeSource { } } + if file.skipped != nil, DiffImage.isImage(entry.path) { + file.image = DiffImage( + old: entry.status == .added ? nil : .blob(repo: repo.path, object: "\(plan.old):\(entry.oldPath ?? entry.path)"), + new: entry.status == .deleted ? nil + : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(repo.appendingPathComponent(entry.path).path) + ) + } + if let count = counts[entry.path] { file.additions = count.additions file.deletions = count.deletions diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index f51906462d..18e24c10ae 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -15,6 +15,38 @@ final class GitFileDiffTests: XCTestCase { return String(decoding: result.stdout, as: UTF8.self).trimmed } + /// A changed and an added PNG carry their sides' sources, and the page's image URLs serve those bytes. + func testAnImageInTheDiffNamesItsSidesAndTheirBytesAreServed() throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-image-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repo) } + let before = Data([0x89, 0x50, 0x4E, 0x47, 0, 1, 2]) + let after = Data([0x89, 0x50, 0x4E, 0x47, 0, 3, 4, 5]) + _ = try git(["init", "-q"], in: repo) + try before.write(to: repo.appendingPathComponent("logo.png")) + _ = try git(["add", "logo.png"], in: repo) + _ = try git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "logo"], in: repo) + try after.write(to: repo.appendingPathComponent("logo.png")) + try after.write(to: repo.appendingPathComponent("new.png")) + try Data([0, 1]).write(to: repo.appendingPathComponent("blob.bin")) + + let files = try GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files + let changed = try XCTUnwrap(files.first { $0.path == "logo.png" }?.image) + let added = try XCTUnwrap(files.first { $0.path == "new.png" }?.image) + XCTAssertNil(files.first { $0.path == "blob.bin" }?.image, "a binary file that is no image has no picture") + XCTAssertNil(added.old, "an added image has no before side") + + let load = { (source: DiffImage.Source?) in try XCTUnwrap(source.flatMap(DiffImageRegistry.load)).data } + XCTAssertEqual(try load(changed.old), before, "the before side comes from HEAD") + XCTAssertEqual(try load(changed.new), after, "the after side is the working-tree file") + XCTAssertEqual(try load(added.new), after) + + let url = try XCTUnwrap(URL(string: DiffImageRegistry.url(try XCTUnwrap(changed.old)))) + XCTAssertTrue(url.path.hasPrefix(DiffImageRegistry.prefix)) + XCTAssertEqual(DiffImageRegistry.source(String(url.path.dropFirst(DiffImageRegistry.prefix.count))), changed.old) + XCTAssertEqual(DiffImageRegistry.mimeType(for: "a/B.PNG"), "image/png") + } + func testUntrackedColorContrastsWithBothPageSchemes() throws { for (name, background) in [(NSAppearance.Name.aqua, 1.0), (.darkAqua, 0.0)] { let appearance = try XCTUnwrap(NSAppearance(named: name)) diff --git a/src/macos/GenesisTools/web/diff-viewer/main.ts b/src/macos/GenesisTools/web/diff-viewer/main.ts index 1cf181561d..98a34880d0 100644 --- a/src/macos/GenesisTools/web/diff-viewer/main.ts +++ b/src/macos/GenesisTools/web/diff-viewer/main.ts @@ -40,6 +40,14 @@ interface BridgeFile { newContents: string | null; /** Changes when either side's text changes: an unchanged file keeps its parsed diff and its item. */ key: string; + /** An image file's two sides as URLs the page's own scheme serves; null for a side the file lacks. */ + image?: BridgeImage | null; +} + +/** Swift leaves a nil side out, so a missing side is undefined here. */ +interface BridgeImage { + old?: string | null; + new?: string | null; } /** What the page keeps of a file once its diff is parsed: the text lives in the parsed diff only. */ @@ -159,7 +167,10 @@ interface Composer { body: string; } -type AnnotationMeta = { kind: "comments"; comments: BridgeComment[] } | { kind: "composer"; composer: Composer }; +type AnnotationMeta = + | { kind: "comments"; comments: BridgeComment[] } + | { kind: "composer"; composer: Composer } + | { kind: "image"; image: BridgeImage }; /** The part of pierre's per-item callback context this page reads; both item kinds carry an id. */ interface ItemContext { @@ -637,6 +648,9 @@ host.addEventListener("contextmenu", (event) => { post({ type: "header.menu", fileId: file.id, selection: document.getSelection()?.toString() ?? "" }); }); +/** Image files by id: their one line carries the picture instead of the "(binary file)" note alone. */ +const imagesByFile = new Map(); + function annotationsFor(fileId: string): DiffLineAnnotation[] { const grouped = new Map(); @@ -660,6 +674,11 @@ function annotationsFor(fileId: string): DiffLineAnnotation[] { lineNumber: group[0].endLine, metadata: { kind: "comments", comments: group }, })); + const image = imagesByFile.get(fileId); + + if (image) { + annotations.unshift({ side: "additions", lineNumber: 1, metadata: { kind: "image", image } }); + } if (composer && composer.fileId === fileId) { annotations.push({ @@ -676,6 +695,12 @@ function annotationsFor(fileId: string): DiffLineAnnotation[] { const parsed = new Map(); function toItem(file: BridgeFile): CodeViewItem { + if (file.image) { + imagesByFile.set(file.id, file.image); + } else { + imagesByFile.delete(file.id); + } + const cached = parsed.get(file.id); const existing = viewer.getItem(file.id); @@ -2327,6 +2352,49 @@ function renderComposer(current: Composer): HTMLElement { return card; } +/** + * An image file's picture, before and after side by side when both exist. CodeView builds an annotation only near + * the viewport, and `loading="lazy"` waits for the viewport again, so a diff of many images loads only what is seen. + * The box has a fixed height: the row never changes height when the picture arrives. + */ +function renderImage(image: BridgeImage): HTMLElement { + const box = element( + "div", + "display:flex;gap:12px;margin:6px 12px 10px 12px;-webkit-user-select:none;user-select:none" + ); + const sides: Array<[string, string | null | undefined]> = [ + ["Before", image.old], + ["After", image.new], + ]; + const shown = sides.flatMap(([label, url]) => (typeof url === "string" ? [{ label, url }] : [])); + + for (const { label, url } of shown) { + const panel = element("div", "flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:4px"); + + if (shown.length > 1) { + panel.appendChild(element("div", css.dim, label)); + } + + const frame = element( + "div", + "height:220px;border:1px solid rgba(255,255,255,.10);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;background:repeating-conic-gradient(#202226 0% 25%,#17181b 0% 50%) 50%/16px 16px" + ); + const picture = element("img", "max-width:100%;max-height:100%;object-fit:contain"); + picture.loading = "lazy"; + picture.decoding = "async"; + picture.alt = label; + picture.addEventListener("error", () => { + frame.replaceChildren(element("div", css.dim, "This image cannot be shown")); + }); + picture.src = url; + frame.appendChild(picture); + panel.appendChild(frame); + box.appendChild(panel); + } + + return box; +} + function renderAnnotation(meta: AnnotationMeta | undefined): HTMLElement | undefined { if (!meta) { return undefined; @@ -2336,6 +2404,10 @@ function renderAnnotation(meta: AnnotationMeta | undefined): HTMLElement | undef return renderComposer(meta.composer); } + if (meta.kind === "image") { + return renderImage(meta.image); + } + const card = element("div", css.card); meta.comments.forEach((comment, index) => { if (index > 0) { From 89802fae317de8b8f3a48c354f01125e13adba4b Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:27:32 +0200 Subject: [PATCH 06/21] feat(GenesisKit): an SVG in a diff shows as code with a Show image switch in its header that swaps the code for its before and after pictures --- .../GenesisKit/Diff/Model/DiffModel.swift | 9 ++- .../Diff/Render/PierreWebDiffRenderer.swift | 2 + .../Diff/Source/GitWorkingTreeSource.swift | 2 +- .../Diff/GitDiffViewLayoutTests.swift | 63 +++++++++++++++ .../Diff/GitFileDiffTests.swift | 4 + .../GenesisTools/web/diff-viewer/main.ts | 81 ++++++++++++++++--- 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 241898c7aa..81e7d75071 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -18,7 +18,8 @@ public struct DiffFile: Codable, Identifiable, Hashable { public var newContents: String? /// Binary or too large to show; renderers show a placeholder instead of lines. public var skipped: String? - /// A skipped file that is an image: where its two sides' bytes are, so a renderer can show the picture. + /// A skipped file that is an image, or any SVG: where its two sides' bytes are, so a renderer can show the + /// picture (an SVG shows as code until the reader asks for the picture). public var image: DiffImage? public var name: String { @@ -61,12 +62,16 @@ public struct DiffImage: Codable, Hashable { self.new = new } - /// What WebKit draws. SVG is text, so it never reaches here as a skipped file. + /// The binary formats WebKit draws. SVG is text: it has code to show, so it is asked for by `isSVG`. public static let extensions: Set = ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "avif", "bmp", "ico", "tif", "tiff"] public static func isImage(_ path: String) -> Bool { extensions.contains((path as NSString).pathExtension.lowercased()) } + + public static func isSVG(_ path: String) -> Bool { + (path as NSString).pathExtension.lowercased() == "svg" + } } public struct DiffViewOptions: Codable, Equatable { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index 7e5e857a77..f540e3d250 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -562,6 +562,8 @@ enum DiffImageRegistry { case "bmp": return "image/bmp" case "ico": return "image/x-icon" case "tif", "tiff": return "image/tiff" + // Shown through , where an SVG's scripts never run. + case "svg": return "image/svg+xml" default: return "application/octet-stream" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index f1d9a1e408..88a8152846 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -341,7 +341,7 @@ public struct GitWorkingTreeSource { } } - if file.skipped != nil, DiffImage.isImage(entry.path) { + if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path) { file.image = DiffImage( old: entry.status == .added ? nil : .blob(repo: repo.path, object: "\(plan.old):\(entry.oldPath ?? entry.path)"), new: entry.status == .deleted ? nil diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitDiffViewLayoutTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitDiffViewLayoutTests.swift index c31e1159a0..9211eb9490 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitDiffViewLayoutTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitDiffViewLayoutTests.swift @@ -48,6 +48,69 @@ final class GitDiffViewLayoutTests: XCTestCase { return seen } + /// Runs `script` over the page and every pierre shadow root (`roots` is the list of them) and returns its string. + private func query(_ web: WKWebView, _ script: String) async throws -> String { + let walk = """ + (() => { const roots = [document]; const walk = (root) => root.querySelectorAll('*').forEach(el => { + if (el.shadowRoot) { roots.push(el.shadowRoot); walk(el.shadowRoot); } }); walk(document); \(script) })() + """ + return (try await web.evaluateJavaScript(walk) as? String) ?? "" + } + + private func poll(_ web: WKWebView, _ script: String, until done: (String) -> Bool) async throws -> String { + var seen = "" + for _ in 0..<50 { + try await Task.sleep(nanoseconds: 200_000_000) + seen = try await query(web, script) + if done(seen) { break } + } + return seen + } + + /// An SVG shows as code with a "Show image" switch in its header; the switch puts its picture in place of the code. + func testAnSVGShowsAsCodeUntilItsHeaderSwitchShowsThePicture() async throws { + let dir = NSHomeDirectory() + "/Applications/GenesisTools.app/Contents/Resources/diff-viewer" + try XCTSkipUnless(FileManager.default.fileExists(atPath: dir + "/index.html"), "no installed diff page") + setenv("GENESIS_DIFF_VIEWER_DIR", dir, 1) + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("gk-svg-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: folder) } + let svg = { (fill: String) in + "\n" + } + let oldPath = folder.appendingPathComponent("old.svg").path + let newPath = folder.appendingPathComponent("icon.svg").path + try svg("red").write(toFile: oldPath, atomically: true, encoding: .utf8) + try svg("blue").write(toFile: newPath, atomically: true, encoding: .utf8) + let file = DiffFile(id: "icon.svg", path: "icon.svg", status: .modified, additions: 1, deletions: 1, + oldContents: svg("red"), newContents: svg("blue"), + image: DiffImage(old: .file(oldPath), new: .file(newPath))) + let hosting = NSHostingView(rootView: GitDiffView(files: [file], options: DiffViewOptions(diffStyle: .split, wrap: true, fontSize: 12))) + hosting.frame = NSRect(x: 0, y: 0, width: 900, height: 600) + let window = NSWindow(contentRect: hosting.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = hosting + defer { window.contentView = nil } + hosting.layoutSubtreeIfNeeded() + let web = try XCTUnwrap(webView(in: hosting)) + web.configuration.userContentController.addUserScript(WKUserScript( + source: "window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); window.cancelAnimationFrame = (id) => clearTimeout(id);", + injectionTime: .atDocumentStart, forMainFrameOnly: true + )) + try await Task.sleep(nanoseconds: 1_000_000_000) + web.reload() + + let state = "const button = roots.flatMap(r => [...r.querySelectorAll('button')]).find(b => /Show (image|code)/.test(b.textContent));" + + " const imgs = roots.flatMap(r => [...r.querySelectorAll('img')]);" + + " return (button ? button.textContent : 'none') + '|' + imgs.length + '|' + imgs.filter(i => i.complete && i.naturalWidth > 0).length;" + let before = try await poll(web, state) { $0.hasPrefix("Show image") } + XCTAssertEqual(before, "Show image|0|0", "code first: the switch offers the picture and no picture shows") + + _ = try await query(web, "roots.flatMap(r => [...r.querySelectorAll('button')]).find(b => b.textContent === 'Show image')?.click(); return '';") + let after = try await poll(web, state) { $0 == "Show code|2|2" } + XCTAssertEqual(after, "Show code|2|2", "after the switch: before and after pictures, both loaded through the page's scheme") + } + func testTheLayoutChoiceReachesThePage() async throws { let dir = NSHomeDirectory() + "/Applications/GenesisTools.app/Contents/Resources/diff-viewer" try XCTSkipUnless(FileManager.default.fileExists(atPath: dir + "/index.html"), "no installed diff page") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 18e24c10ae..1e8596b43c 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -29,12 +29,16 @@ final class GitFileDiffTests: XCTestCase { try after.write(to: repo.appendingPathComponent("logo.png")) try after.write(to: repo.appendingPathComponent("new.png")) try Data([0, 1]).write(to: repo.appendingPathComponent("blob.bin")) + try "\n".write(to: repo.appendingPathComponent("icon.svg"), atomically: true, encoding: .utf8) let files = try GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files let changed = try XCTUnwrap(files.first { $0.path == "logo.png" }?.image) let added = try XCTUnwrap(files.first { $0.path == "new.png" }?.image) XCTAssertNil(files.first { $0.path == "blob.bin" }?.image, "a binary file that is no image has no picture") XCTAssertNil(added.old, "an added image has no before side") + let svg = try XCTUnwrap(files.first { $0.path == "icon.svg" }) + XCTAssertNotNil(svg.newContents, "an SVG keeps its code") + XCTAssertEqual(svg.image?.new, .file(repo.appendingPathComponent("icon.svg").path), "and names its picture too") let load = { (source: DiffImage.Source?) in try XCTUnwrap(source.flatMap(DiffImageRegistry.load)).data } XCTAssertEqual(try load(changed.old), before, "the before side comes from HEAD") diff --git a/src/macos/GenesisTools/web/diff-viewer/main.ts b/src/macos/GenesisTools/web/diff-viewer/main.ts index 98a34880d0..96758c9752 100644 --- a/src/macos/GenesisTools/web/diff-viewer/main.ts +++ b/src/macos/GenesisTools/web/diff-viewer/main.ts @@ -355,6 +355,45 @@ function foldChevron(path: string): HTMLElement { return chevron; } +/** The SVG's switch between its code and its picture, after its name in the header. Nothing for other files. */ +function svgModeButton(path: string): HTMLElement | undefined { + // From the image map, not `files`: a batch's headers draw before its files join that list. + const file = [...imagesByFile.values()].find((entry) => entry.svg?.path === path)?.svg; + + if (!file) { + return undefined; + } + + const asImage = svgAsImage.has(file.id); + const button = element( + "button", + "margin-left:8px;font:11px -apple-system,sans-serif;color:rgba(255,255,255,.75);background:transparent;border:1px solid rgba(255,255,255,.15);border-radius:999px;padding:1px 8px;cursor:pointer;-webkit-user-select:none;user-select:none", + asImage ? "Show code" : "Show image" + ); + button.title = asImage ? "Show this SVG's code diff" : "Show this SVG as a picture, before and after"; + button.addEventListener("click", (event) => { + event.stopPropagation(); + toggleSvgImage(file.id); + }); + return button; +} + +function toggleSvgImage(fileId: string): void { + const svg = imagesByFile.get(fileId)?.svg; + + if (!svg) { + return; + } + + if (svgAsImage.has(fileId)) { + svgAsImage.delete(fileId); + } else { + svgAsImage.add(fileId); + } + + viewer.updateItem(toItem(svg)); +} + /** * Folding a file read from its middle keeps its header at the top of the pane; without it the rows * below moved up under the reader, who landed in a different file ("it should scroll to the top of the @@ -478,6 +517,9 @@ function viewOptions(): CodeViewOptions { renderHeaderPrefix(fileDiff) { return foldChevron(fileDiff.name); }, + renderHeaderFilenameSuffix(fileDiff) { + return svgModeButton(fileDiff.name); + }, }; } @@ -648,8 +690,22 @@ host.addEventListener("contextmenu", (event) => { post({ type: "header.menu", fileId: file.id, selection: document.getSelection()?.toString() ?? "" }); }); -/** Image files by id: their one line carries the picture instead of the "(binary file)" note alone. */ -const imagesByFile = new Map(); +/** + * Image files by id: their one line carries the picture instead of the "(binary file)" note alone. An SVG keeps its + * file here too, because it shows as code until the reader asks for the picture (`toggleSvgImage`). + */ +const imagesByFile = new Map(); +/** SVG files the reader switched to their picture, by id. Code is the default. */ +const svgAsImage = new Set(); + +function isSvg(path: string): boolean { + return path.toLowerCase().endsWith(".svg"); +} + +/** An SVG shown as its picture: one line under the header, with the picture under it, as a binary image has. */ +function svgShownAsImage(fileId: string): boolean { + return svgAsImage.has(fileId) && imagesByFile.get(fileId)?.svg != null; +} function annotationsFor(fileId: string): DiffLineAnnotation[] { const grouped = new Map(); @@ -674,10 +730,10 @@ function annotationsFor(fileId: string): DiffLineAnnotation[] { lineNumber: group[0].endLine, metadata: { kind: "comments", comments: group }, })); - const image = imagesByFile.get(fileId); + const entry = imagesByFile.get(fileId); - if (image) { - annotations.unshift({ side: "additions", lineNumber: 1, metadata: { kind: "image", image } }); + if (entry && (!entry.svg || svgShownAsImage(fileId))) { + annotations.unshift({ side: "additions", lineNumber: 1, metadata: { kind: "image", image: entry.image } }); } if (composer && composer.fileId === fileId) { @@ -696,24 +752,29 @@ const parsed = new Map(); function toItem(file: BridgeFile): CodeViewItem { if (file.image) { - imagesByFile.set(file.id, file.image); + imagesByFile.set(file.id, { image: file.image, svg: isSvg(file.path) ? file : null }); } else { imagesByFile.delete(file.id); } + // An SVG shown as its picture is a one-line file of its own, so its code rows give way to the picture. + const asImage = svgShownAsImage(file.id); + const key = asImage ? `${file.key}:image` : file.key; const cached = parsed.get(file.id); const existing = viewer.getItem(file.id); // Same text, same item: CodeView keeps its layout and its rendered rows (annotations follow setComments). - if (cached?.key === file.key && existing?.type === "diff") { + if (cached?.key === key && existing?.type === "diff") { return existing; } - let fileDiff = cached?.key === file.key ? cached.fileDiff : undefined; + let fileDiff = cached?.key === key ? cached.fileDiff : undefined; if (!fileDiff) { - fileDiff = parseFileDiff(file); - parsed.set(file.id, { key: file.key, fileDiff }); + fileDiff = parseFileDiff( + asImage ? { ...file, key, oldContents: null, newContents: "(SVG shown as its picture)\n" } : file + ); + parsed.set(file.id, { key, fileDiff }); } return { From ae958e76e87743c98cd65ad87c2fc098b5205a5c Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:42:00 +0200 Subject: [PATCH 07/21] fix(GenesisKit): the markdown file tree re-roots on a folder double-click, search rows get the file context menu, and Copy Path has the default Ctrl+Shift+C --- .../Sources/GenesisKit/Files/FileTree.swift | 36 ++++++++++++------- .../Views/MarkdownDocumentToolbar.swift | 5 ++- .../Markdown/Views/MarkdownFileTreeView.swift | 25 +++++++++++++ .../Window/MarkdownDocumentWindow.swift | 5 +++ .../Window/MarkdownDocumentsModel.swift | 6 ++++ .../Markdown/Window/MarkdownKeymap.swift | 2 ++ .../Markdown/Window/MarkdownViewerApp.swift | 1 + .../Tests/GenesisKitTests/FileTreeTests.swift | 8 +++++ .../Markdown/MarkdownKeymapTests.swift | 7 ++++ .../Markdown/MarkdownViewerAppTests.swift | 4 +++ .../Tests/GenesisKitTests/PathMenuTests.swift | 12 +++++++ 11 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift index 7f4b409ca0..73a344d704 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift @@ -590,6 +590,23 @@ public struct FileTreeView: View { } } +/// One click on a tree row. A double-click is a second button action with `clickCount >= 2`. +/// A tap-count gesture never sees it: the first click expands or collapses the folder, that +/// replaces the row, and the gesture counting the clicks is gone with it. +enum FileTreeClick: Equatable { + case toggleFolder + case setRoot + case openFile + case keepFile + + static func forRow(isFolder: Bool, clickCount: Int) -> FileTreeClick { + if clickCount >= 2 { + return isFolder ? .setRoot : .keepFile + } + return isFolder ? .toggleFolder : .openFile + } +} + /// One row. Equal while its row value, selection, icon, visual style, pulse and actions' revision are. struct FileTreeRowView: View, Equatable { let row: FileTreeRow @@ -644,11 +661,16 @@ struct FileTreeRowView: View, Equatable { let _ = RenderProbe.hit("tree.row") let metrics = style.metrics Button { - if row.isFolder { + switch FileTreeClick.forRow(isFolder: row.isFolder, clickCount: NSApp.currentEvent?.clickCount ?? 1) { + case .toggleFolder: model.reduceMotion = reduceMotion GenMotion.perform(expanding: !row.expanded, reduceMotion: reduceMotion) { model.toggle(row.id) } - } else { + case .setRoot: + actions.setRoot(row.url) + case .openFile: actions.open(row.url) + case .keepFile: + actions.openKept?(row.url) } } label: { HStack(spacing: metrics.spacing) { @@ -694,16 +716,6 @@ struct FileTreeRowView: View, Equatable { .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - // The first click opens or closes the folder at once; the second click of a double-click - // re-roots the tree there (the open and close in between are undone by the new root). On a file - // the first click opened it already; the second keeps it (`openKept`). - .simultaneousGesture(TapGesture(count: 2).onEnded { - if row.isFolder { - actions.setRoot(row.url) - } else { - actions.openKept?(row.url) - } - }) .contextMenu { if let menu = actions.menu { configuredMenu(menu) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift index 2c87e7b5d2..7dd7c126fd 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift @@ -220,9 +220,8 @@ struct MarkdownDocumentToolbar: View { openInObsidian() } groupDivider - toolButton("doc.on.doc", label: "Copy Path", tooltip: "Copy the file path", id: "markdown-copy-path", labels: labels) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(doc.url.path, forType: .string) + toolButton("doc.on.doc", label: "Copy Path", tooltip: keymap.hint("Copy the file path", "markdown.file.copyPath"), id: "markdown-copy-path", labels: labels) { + model.copySelectedPath() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift index 903c8cbeba..d38bc32d6c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift @@ -818,11 +818,36 @@ private struct MarkdownFileTreeResultRow: View { .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .contextMenu { + let url = URL(fileURLWithPath: hit.path) + PathActionsMenu(entries: MarkdownSearchResultMenu.entries( + url: url, + actions: model.pathActions(for: .file), + handlers: model.pathHandlers(for: url, kind: .file), + open: action + )) + } .instantTooltip(hit.path) .accessibilityIdentifier("markdown-files-result") } } +/// The right-click menu of a search hit: the same file verbs as a row in the listing, with Open first. +@MainActor +enum MarkdownSearchResultMenu { + static func entries( + url: URL, + actions: [PathAction], + handlers: PathMenuHandlers, + environment: PathMenuEnvironment? = nil, + open: @escaping () -> Void + ) -> [PathMenuEntry] { + let leading = [PathMenuItem(id: "open", title: "Open", systemImage: "doc.text", run: open)] + let verbs = PathMenu.entries(for: url, kind: .file, actions: actions, handlers: handlers, environment: environment ?? .system) + return leading.map(PathMenuEntry.item) + (verbs.isEmpty ? [] : [.divider(id: "divider-leading")]) + verbs + } +} + /// The tree's scroll view, set by `MarkdownTreeTable`. A class, so setting it does not re-render the tree. It owns the /// one glide that may write that scroll view's origin. final class MarkdownTreeScrollBox { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift index 513ac48300..4a85c1eb2c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift @@ -164,6 +164,11 @@ public final class MarkdownDocumentWindowController: NSWindowController, NSWindo model.saveSelected() } + /// File → Copy Path (⌃⇧C). The same path a file row's menu copies. + @objc public func copyPath(_ sender: Any?) { + model.copySelectedPath() + } + /// File → Go to Location… (⇧⌘G). @objc public func goToLocation(_ sender: Any?) { show(paths: [], focus: true) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift index 88092f21f7..3fa0779fcb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift @@ -724,6 +724,12 @@ final class MarkdownDocumentsModel: ObservableObject { select(docs[next].id) } + /// ⌃⇧C, the toolbar, and a file row's Copy Path: the open note's path. + func copySelectedPath() { + guard let path = selected?.url.path else { return } + Clipboard.copy(path, what: "path") + } + /// ⌘S on the selected tab. A refused save surfaces through the banner, /// which is already on screen whenever `diskChange` is set. func saveSelected() { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift index 11f10ebea5..0d23f755ab 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift @@ -38,6 +38,7 @@ public enum MarkdownKeymap { KeymapCommand("markdown.file.save", "Save", group: "Files", keys: [.character("s", .command)]), KeymapCommand("markdown.file.reload", "Reload from Disk", group: "Files", keys: [.character("r", .command)]), KeymapCommand("markdown.file.reveal", "Reveal in Finder", group: "Files", keys: [.character("r", [.shift, .command])]), + KeymapCommand("markdown.file.copyPath", "Copy Path", group: "Files", keys: [.character("c", [.control, .shift])]), KeymapCommand("markdown.file.goto", "Go to Location…", group: "Files", keys: [.character("g", [.shift, .command])]), KeymapCommand("markdown.file.pdf", "Save as PDF…", group: "Files", keys: [.character("s", [.shift, .command])]), KeymapCommand("markdown.file.print", "Print…", group: "Files", keys: [.character("p", .command)]), @@ -134,6 +135,7 @@ public enum MarkdownKeymap { ("markdown.file.reveal", { if let url = model.selected?.url { NSWorkspace.shared.activateFileViewerSelecting([url]) } }), + ("markdown.file.copyPath", { model.copySelectedPath() }), ("markdown.file.goto", { model.goToOpen = true }), ("markdown.view.files", { withAnimation(GenAnimation.quick) { model.showFiles.toggle() } }), ("markdown.view.outline", { withAnimation(GenAnimation.quick) { model.showOutline.toggle() } }), diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift index 644514a13b..d6a971ac3c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift @@ -227,6 +227,7 @@ public enum MarkdownViewerMenu { keyed(file, "Go to Location…", #selector(MarkdownDocumentWindowController.goToLocation(_:)), "markdown.file.goto", keymap: keymap) file.addItem(.separator()) keyed(file, "Save", #selector(MarkdownDocumentWindowController.saveSelected(_:)), "markdown.file.save", keymap: keymap) + keyed(file, "Copy Path", #selector(MarkdownDocumentWindowController.copyPath(_:)), "markdown.file.copyPath", keymap: keymap) menu.addItem(submenu(file, title: "File")) let edit = NSMenu(title: "Edit") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift index 7c51925d77..719d628474 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift @@ -55,6 +55,14 @@ final class FileTreeTests: XCTestCase { XCTAssertTrue(view(style) == view(sameFont), "font values, not closure identities, decide equality") } + func testDoubleClickOnAFolderSetsTheRootAndASingleClickToggles() { + XCTAssertEqual(FileTreeClick.forRow(isFolder: true, clickCount: 1), .toggleFolder) + XCTAssertEqual(FileTreeClick.forRow(isFolder: true, clickCount: 2), .setRoot) + XCTAssertEqual(FileTreeClick.forRow(isFolder: true, clickCount: 3), .setRoot) + XCTAssertEqual(FileTreeClick.forRow(isFolder: false, clickCount: 1), .openFile) + XCTAssertEqual(FileTreeClick.forRow(isFolder: false, clickCount: 2), .keepFile) + } + func testListingPutsFoldersFirstInFinderOrderAndSkipsHiddenFiles() { let names = FileTreeListing.entries(of: root.path).map(\.name) XCTAssertEqual(names, ["Alpha", "beta", "note 2.md", "note 10.md"]) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownKeymapTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownKeymapTests.swift index 891612b134..e494e968d3 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownKeymapTests.swift @@ -35,6 +35,13 @@ final class MarkdownKeymapTests: XCTestCase { KeyStroke(keyCode: code, character: character, modifiers: modifiers) } + func testCopyPathIsControlShiftC() { + let keys = Keymap(library: MarkdownKeymap.library, store: nil, layout: TableKeyLayout.us) + let chord = keys.chord(for: "markdown.file.copyPath") + XCTAssertEqual(chord, .character("c", [.control, .shift])) + XCTAssertEqual(chord?.label(layout: TableKeyLayout.us), "⌃⇧C") + } + func testTheViewersKeysHaveNoConflictOnACzechOrAUSKeyboard() { XCTAssertEqual(Keymap(library: MarkdownKeymap.library, store: nil, layout: czech).conflicts(), []) XCTAssertEqual(Keymap(library: MarkdownKeymap.library, store: nil, layout: TableKeyLayout.us).conflicts(), []) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift index 2b8bbb5a05..f647614dde 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift @@ -12,6 +12,10 @@ final class MarkdownViewerAppTests: XCTestCase { XCTAssertEqual(closeTab?.keyEquivalent, "w") XCTAssertEqual(closeTab?.keyEquivalentModifierMask, [.command]) XCTAssertEqual(closeTab?.action, #selector(MarkdownDocumentWindowController.closeSelectedTab(_:))) + let copyPath = file?.items.first { $0.title == "Copy Path" } + XCTAssertEqual(copyPath?.keyEquivalent, "c") + XCTAssertEqual(copyPath?.keyEquivalentModifierMask, [.control, .shift]) + XCTAssertEqual(copyPath?.action, #selector(MarkdownDocumentWindowController.copyPath(_:))) let closeWindow = file?.items.first { $0.title == "Close Window" } XCTAssertEqual(closeWindow?.keyEquivalent, "w") XCTAssertEqual(closeWindow?.keyEquivalentModifierMask, [.command, .shift]) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PathMenuTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PathMenuTests.swift index 2d15c4acfa..a9b7f9c67e 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/PathMenuTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PathMenuTests.swift @@ -41,6 +41,18 @@ final class PathMenuTests: XCTestCase { ]) } + func testASearchHitOffersTheSameFileMenuAsAListingRow() { + let env = environment() + let verbs = outline(file, .file, PathAction.fileDefaults, everyHandler, env) + var opened = false + let entries = MarkdownSearchResultMenu.entries( + url: file, actions: PathAction.fileDefaults, handlers: everyHandler, environment: env + ) { opened = true } + XCTAssertEqual(PathMenuEntry.outline(entries), ["Open", "-"] + verbs) + if case .item(let open) = entries[0] { open.run() } + XCTAssertTrue(opened) + } + func testAFileOffersItsAppObsidianAndTheChangesWhenTheHostHearsThem() { XCTAssertEqual(outline(file, .file, PathAction.fileDefaults, everyHandler, environment()), [ "Show in Sidebar", "-", From 32e50f4bf4a8100eed42a44d4e83d8fb77e035e5 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:21:22 +0200 Subject: [PATCH 08/21] fix(GenesisKit, posture): a calibration records which request placed its shoulders, and a held 3D reading needs a fresh one to agree A debug recording (2026-10-19 22:28:57, Home) judged level shoulders tilted for 6.8 s and could never see a lean: the Home baseline held 2D shoulders, the live readings were 3D ones (a third narrower, at another tilt), and one 3D reading was reused for up to 4 s. - PoseSample.shoulderSource (body2D / body3D) and shoulderAge (seconds since the request ran). The Vision body engine fills both; the debug log carries them. - PostureBaseline.shoulderSource, recorded by PostureCalibrationSession. A baseline with shoulder measures whose source differs from the live one, or is unknown (files saved before this), leaves every shoulder measure out: those faults are unjudged and PostureScore.shoulderSourceMismatch asks for a new calibration. The face request then runs on every frame, as the face is all that judges. - PostureShoulderGate: a shoulder fault from the throttled 3D request turns bad only when two fresh 3D readings in a row are bad on their own. Until then its signal is held (neither bad nor clear) and the engine reads the shoulders again on the next frame (readShouldersOnNextFrame). - The debug log lists held signals and the mismatch. --- src/macos/GenesisKit/README.md | 11 +- .../GenesisKitPosture/Pose/PoseEngine.swift | 20 +- .../GenesisKitPosture/Pose/PoseSample.swift | 28 ++- .../Posture/PostureCalibration.swift | 14 +- .../Posture/PostureCore.swift | 43 +++- .../Posture/PostureDebugRecorder.swift | 3 +- .../Posture/PostureMetrics.swift | 9 + .../Posture/PostureScorer.swift | 90 ++++++- .../Posture/PostureShoulderGate.swift | 44 ++++ .../PostureShoulderSourceTests.swift | 237 ++++++++++++++++++ 10 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift create mode 100644 src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift diff --git a/src/macos/GenesisKit/README.md b/src/macos/GenesisKit/README.md index 0b576e524f..7af9c305bc 100644 --- a/src/macos/GenesisKit/README.md +++ b/src/macos/GenesisKit/README.md @@ -6396,6 +6396,8 @@ One reading of the user's pose. A `Codable`, `Equatable`, `Sendable` struct with - `headphonePitch: Double? = nil`: AirPods pitch in radians, positive nodding down. - `imageAspect: Double = 4/3`: what the overlay needs to match an aspect-fill preview. - `lowLight: Bool = false`: too dark to judge. +- `shoulderSource: PoseShoulderSource? = nil`: the request behind the shoulders and the neck, `.body2D` or `.body3D`. Nil without shoulders. +- `shoulderAge: TimeInterval? = nil`: seconds since that request ran, 0 on the frame it ran (`hasFreshShoulders`). A throttled 3D reading is reused for several seconds. Members: `joint(_:) -> PoseJointPoint?` returns a joint above `minimumConfidence` (0.3, Dorso's and Apple's gate); `hasPerson` is true for a confident nose, a face, both shoulders or AirPods motion. The shoulders count so that a head bent out of the frame reads as a slouch, not as away. @@ -6408,7 +6410,7 @@ Members: `joint(_:) -> PoseJointPoint?` returns a joint above `minimumConfidence Vision 2D body pose (`VNDetectHumanBodyPoseRequest`, `preferBackgroundProcessing`). It keeps the nearest person: the widest confident shoulders, else the most confident nose. - `faceDetection: FaceDetection = .fallback`: `.fallback` runs face rectangles on the same frame when the body has no confident nose or misses a shoulder (a seated upper body sometimes yields no body, which is why Dorso has the same fallback); `.always` runs both on every frame (use it while calibrating, so the baseline also holds the face measures); `.never`. Change it only from the queue that calls `analyze`. -- `body3D: Body3D = .throttled`, `body3DInterval: TimeInterval = 4`: the shoulders and the neck come from `VNDetectHumanBodyPose3DRequest` instead of the 2D request, which often finds no body for a person close to a laptop camera (real frames, 2026-10-14). The two place the shoulders differently (3D about a third narrower), so a reading never mixes them. 3D costs about 80 ms of CPU a frame against 7 ms for 2D plus face: `.throttled` runs it at most once per interval and keeps its shoulders for two intervals; `.always` runs it on every frame (calibration); `.never` keeps the 2D shoulders. +- `body3D: Body3D = .throttled`, `body3DInterval: TimeInterval = 4`: the shoulders and the neck come from `VNDetectHumanBodyPose3DRequest` instead of the 2D request, which often finds no body for a person close to a laptop camera (real frames, 2026-10-14). The two place the shoulders differently (3D about a third narrower), so a reading never mixes them. 3D costs about 80 ms of CPU a frame against 7 ms for 2D plus face: `.throttled` runs it at most once per interval and keeps its shoulders for two intervals; `.always` runs it on every frame (calibration); `.never` keeps the 2D shoulders. Each sample says which request placed its shoulders and how old they are (`shoulderSource`, `shoulderAge`). `readShouldersOnNextFrame()` makes the next `analyze` run the 3D request even when it is not due. #### `VisionFaceEngine` @@ -6486,7 +6488,8 @@ A fault reads its first available measure in that order, so body joints win over The calibrated upright posture: the upright `metrics` (median), the `tolerance` each measure may always drift (noise, looking around the screen: the spread of the upright readings, never below the measure's minimum), and the `span`, the drift that counts as one whole slouch (from the slouch sample when there was one). `Codable`. -- `static calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = []) -> PostureBaseline?`: nil with fewer than `minimumUprightReadings` (4, Dorso) usable readings. +- `static calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = [], shoulderSource: PoseShoulderSource? = nil) -> PostureBaseline?`: nil with fewer than `minimumUprightReadings` (4, Dorso) usable readings. `PostureCalibrationSession.baseline()` passes the source most of its upright samples had. +- `shoulderSource: PoseShoulderSource?`: the request that placed the shoulders while calibrating; nil in a file saved before 2026-10-20. `shoulderSourceDiffers(from:)` is true when the baseline has shoulder measures (`PostureMeasure.usesShoulders`) and its source is not the live one or is unknown: the 2D and 3D requests place the shoulders differently, so such a baseline cannot judge live shoulders. Defaults without a slouch sample: span 0.15 and minimum tolerance 0.05 for `faceWidth` and `eyeSpanRatio` (Dorso's forward-head numbers), span 0.3 rad and tolerance 0.15 rad for pitch (Dorso's AirPods numbers); the other measures use proposals in the same units (head height span 0.2 shoulder widths, shoulder tilt 0.17 rad). @@ -6500,7 +6503,9 @@ Scores a reading against a baseline. Pure. Per fault: bad above `tolerance + deadZone × span`, clear again below `0.85 × tolerance + 0.7 × deadZone × span` (Dorso exits at 0.7 of its threshold), severity `(drift − enter) / (span × (1 − deadZone))` clamped to 0...1. The reading takes the worst fault (Dorso takes the max). Shoulders in view with no nose and no face, against a baseline with a head height, score as a full head drop (severity 1): the head went below the frame. -`score(_:against:) -> PostureScore` gives `signals` (one `PostureSignal` per fault, with drift, thresholds and severity), `severity`, `strength`, `isBad`, `isClear` and `worst`, the fault to name in the UI ("Head drop"), so the score is never opaque. +`score(_:against:shoulderSource:) -> PostureScore` gives `signals` (one `PostureSignal` per fault, with drift, thresholds and severity), `severity`, `strength`, `isBad`, `isClear` and `worst`, the fault to name in the UI ("Head drop"), so the score is never opaque. When the baseline's shoulder source differs from `shoulderSource` (the live one; nil skips the check), every measure that needs the shoulders is left out, the faults only they measure go to `unjudged`, and `shoulderSourceMismatch` is true: the UI asks for a new calibration. + +The monitor lets a shoulder fault from the throttled 3D request turn bad only when two fresh 3D readings in a row are bad, each scored without the smoother. Until the second one agrees, the signal is `held` (neither bad nor clear, severity 0, listed in `PostureScore.held`) and the engine reads the shoulders again on the next frame. A fresh reading that is no longer bad lets the fault go at once. Without this, one wrong 3D reading held a tilt for 6.8 s (debug recording 2026-10-19). `PostureSmoother(window: 1, maxCount: 5)` takes the median of the readings of the last second; Dorso averages 5 readings, and a time window keeps the lag the same at 2 and 10 fps. diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseEngine.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseEngine.swift index cecc709ee0..a796c25aaa 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseEngine.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseEngine.swift @@ -107,8 +107,26 @@ public final class VisionBodyPoseEngine: FramePoseEngine { cached: cachedShoulders, face: face) } joints = Self.trustedJoints(joints, current: current, cachedShoulders: cachedShoulders, face: face) + let shoulders = Self.shoulderStamp(joints, body3D: body3D, readAt: last3D?.time, time: frame.time) return PoseSample(time: frame.time, engine: kind, joints: joints, face: face, - imageAspect: frame.imageAspect, lowLight: frame.isLowLight) + imageAspect: frame.imageAspect, lowLight: frame.isLowLight, + shoulderSource: shoulders?.source, shoulderAge: shoulders?.age) + } + + /// Makes the next `analyze` run the 3D request even when the throttle says it is not due, so a shoulder fault + /// seen on a held reading is confirmed by a fresh one. Call it from the queue that calls `analyze`. + public func readShouldersOnNextFrame() { + last3DAttempt = nil + } + + /// Which request placed the reading's shoulders and how many seconds ago it ran; nil without both shoulders. + /// With `body3D` on, the shoulders only ever come from the 3D candidates fetched at `readAt`. + static func shoulderStamp(_ joints: [PoseJoint: PoseJointPoint], body3D: Body3D, readAt: TimeInterval?, + time: TimeInterval) -> (source: PoseShoulderSource, age: TimeInterval)? { + guard joints[.leftShoulder] != nil, joints[.rightShoulder] != nil else { return nil } + guard body3D != .never else { return (.body2D, 0) } + guard let readAt else { return nil } + return (.body3D, max(0, time - readAt)) } /// Replaces 2D shoulders with a matching projected 3D candidate, refreshing the candidates when due. diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseSample.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseSample.swift index 6cb588341f..bc07ee2071 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseSample.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Pose/PoseSample.swift @@ -55,6 +55,15 @@ public enum PoseEngineKind: String, CaseIterable, Codable, Sendable { case headphones } +/// Which Vision request placed the shoulders and the neck. The two place them differently (3D about a third +/// narrower, at another tilt), so a baseline is only comparable with readings from the same request. +public enum PoseShoulderSource: String, CaseIterable, Codable, Sendable { + /// `VNDetectHumanBodyPoseRequest`. + case body2D + /// `VNDetectHumanBodyPose3DRequest`, projected into the image. + case body3D +} + /// One reading of the user's pose. Engines fill what they can see; every field is optional except the /// time and the engine. public struct PoseSample: Equatable, Codable, Sendable { @@ -71,10 +80,16 @@ public struct PoseSample: Equatable, Codable, Sendable { public var imageAspect: Double /// True when the frame was too dark to judge. A dark frame is "unknown", never "away". public var lowLight: Bool + /// The request behind the shoulders and the neck. Nil when the sample has no shoulders, or when the engine does + /// not say (test samples). + public var shoulderSource: PoseShoulderSource? + /// Seconds since the request behind the shoulders ran: 0 when it ran on this frame. A throttled 3D request is + /// reused for several seconds, so this tells a held reading from a fresh one. + public var shoulderAge: TimeInterval? public init(time: TimeInterval, engine: PoseEngineKind, joints: [PoseJoint: PoseJointPoint] = [:], face: PoseFace? = nil, headphonePitch: Double? = nil, imageAspect: Double = 4.0 / 3.0, - lowLight: Bool = false) { + lowLight: Bool = false, shoulderSource: PoseShoulderSource? = nil, shoulderAge: TimeInterval? = nil) { self.time = time self.engine = engine self.joints = joints @@ -82,8 +97,13 @@ public struct PoseSample: Equatable, Codable, Sendable { self.headphonePitch = headphonePitch self.imageAspect = imageAspect self.lowLight = lowLight + self.shoulderSource = shoulderSource + self.shoulderAge = shoulderAge } + /// Whether the shoulders were read on this frame rather than held from an earlier 3D request. + public var hasFreshShoulders: Bool { shoulderAge == 0 } + /// The lowest confidence a joint needs to count. Dorso and Apple's own samples gate at 0.3. public static let minimumConfidence = 0.3 @@ -112,7 +132,7 @@ extension Dictionary where Key == PoseJoint, Value == PoseJointPoint { extension PoseSample { private enum CodingKeys: String, CodingKey { - case time, engine, joints, face, headphonePitch, imageAspect, lowLight + case time, engine, joints, face, headphonePitch, imageAspect, lowLight, shoulderSource, shoulderAge } public init(from decoder: Decoder) throws { @@ -125,6 +145,8 @@ extension PoseSample { headphonePitch = try container.decodeIfPresent(Double.self, forKey: .headphonePitch) imageAspect = try container.decodeIfPresent(Double.self, forKey: .imageAspect) ?? 4.0 / 3.0 lowLight = try container.decodeIfPresent(Bool.self, forKey: .lowLight) ?? false + shoulderSource = try container.decodeIfPresent(PoseShoulderSource.self, forKey: .shoulderSource) + shoulderAge = try container.decodeIfPresent(TimeInterval.self, forKey: .shoulderAge) } public func encode(to encoder: Encoder) throws { @@ -136,5 +158,7 @@ extension PoseSample { try container.encodeIfPresent(headphonePitch, forKey: .headphonePitch) try container.encode(imageAspect, forKey: .imageAspect) try container.encode(lowLight, forKey: .lowLight) + try container.encodeIfPresent(shoulderSource, forKey: .shoulderSource) + try container.encodeIfPresent(shoulderAge, forKey: .shoulderAge) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCalibration.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCalibration.swift index aad5244607..3d62e7b377 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCalibration.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCalibration.swift @@ -175,6 +175,8 @@ public struct PostureCalibrationSession: Sendable { public private(set) var slouch: [PostureMetrics] = [] /// The upright sample with the most joints, for the overlay's target ghost. public private(set) var referencePose: PoseSample? + /// How many upright samples had their shoulders from each request. + public private(set) var shoulderSources: [PoseShoulderSource: Int] = [:] public init() {} @@ -186,6 +188,7 @@ public struct PostureCalibrationSession: Sendable { switch phase { case .upright: upright.append(metrics) + if let source = sample.shoulderSource { shoulderSources[source, default: 0] += 1 } if sample.joints.count >= (referencePose?.joints.count ?? 0) { referencePose = sample } case .slouch: slouch.append(metrics) @@ -194,9 +197,16 @@ public struct PostureCalibrationSession: Sendable { public var canFinish: Bool { upright.count >= PostureBaseline.minimumUprightReadings } - /// The baseline, or nil with too few upright readings. + /// The request most upright samples took their shoulders from; nil when none had shoulders. + public var shoulderSource: PoseShoulderSource? { + shoulderSources.max { lhs, rhs in + lhs.value == rhs.value ? lhs.key.rawValue < rhs.key.rawValue : lhs.value < rhs.value + }?.key + } + + /// The baseline, or nil with too few upright readings. It records which request placed the shoulders. public func baseline() -> PostureBaseline? { - PostureBaseline.calibrate(upright: upright, slouch: slouch) + PostureBaseline.calibrate(upright: upright, slouch: slouch, shoulderSource: shoulderSource) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift index 34096587f0..52f6dfdd28 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift @@ -36,6 +36,7 @@ final class PostureCore: @unchecked Sendable { private var machine: PostureStateMachine private var baseline: PostureBaseline? private var smoother = PostureSmoother() + private var shoulderGate = PostureShoulderGate() private var health: PostureHealth private var minutes = PostureMinuteAccumulator() private var lastScore: PostureScore? @@ -95,6 +96,7 @@ final class PostureCore: @unchecked Sendable { lock.withLock { self.engine = engine smoother.reset() + shoulderGate.reset() } } @@ -103,6 +105,7 @@ final class PostureCore: @unchecked Sendable { let (store, rows) = locked { () -> [PostureEvent] in self.baseline = baseline smoother.reset() + shoulderGate.reset() lastScore = nil return machine.restartPosture(at: time) } @@ -117,6 +120,7 @@ final class PostureCore: @unchecked Sendable { guard hold != scoringHeld else { return [] } scoringHeld = hold smoother.reset() + shoulderGate.reset() lastScore = nil return machine.restartPosture(at: time) } @@ -142,6 +146,7 @@ final class PostureCore: @unchecked Sendable { usesCamera: usesCamera, at: time) if !machine.state.isPosturePhase { smoother.reset() + shoulderGate.reset() lastScore = nil pendingRows += minutes.flush() } @@ -174,7 +179,9 @@ final class PostureCore: @unchecked Sendable { guard machine.usesCamera, !shutDown else { return (nil, false, false) } let watching = machine.state.isWatching && machine.state != .needsCalibration return (watching || collector != nil ? self.engine : nil, - Self.needsFaceOnEveryFrame(calibrating: collector != nil, baseline: baseline), collector != nil) + Self.needsFaceOnEveryFrame(calibrating: collector != nil, baseline: baseline, + liveShoulders: Self.shoulderSource(of: self.engine)), + collector != nil) } guard let engine else { return } let body = engine as? VisionBodyPoseEngine @@ -184,7 +191,7 @@ final class PostureCore: @unchecked Sendable { let sample = engine.analyze(frame) if let body, let saved { (body.faceDetection, body.body3D) = saved } if let slot = shots.take() { shots.write(frame, sample: sample, to: slot) } - if let sample { ingest(sample) } + if let sample, ingest(sample) { body?.readShouldersOnNextFrame() } let (recorder, score, state) = lock.withLock { (debugRecorder, lastScore, machine.state) } if let recorder, !recorder.append(frame: frame, sample: sample, score: sample == nil ? nil : score, state: state) { lock.withLock { if debugRecorder === recorder { debugRecorder = nil } } @@ -211,17 +218,30 @@ final class PostureCore: @unchecked Sendable { recorder?.stop() } - /// Calibration, a face-only baseline, or a body baseline whose Forward head ratio is capped by face - /// growth needs the face on every frame. A body-only baseline still uses fallback face detection. - static func needsFaceOnEveryFrame(calibrating: Bool, baseline: PostureBaseline?) -> Bool { + /// Calibration, a face-only baseline, a body baseline whose Forward head ratio is capped by face + /// growth, or a baseline that cannot judge the live shoulders (`PostureBaseline.shoulderSourceDiffers`, so the + /// face is all that is left) needs the face on every frame. A body-only baseline still uses fallback face detection. + static func needsFaceOnEveryFrame(calibrating: Bool, baseline: PostureBaseline?, + liveShoulders: PoseShoulderSource? = nil) -> Bool { if calibrating { return true } - guard let metrics = baseline?.metrics else { return false } - if metrics[.shoulderWidth] == nil { return true } + guard let baseline else { return false } + let metrics = baseline.metrics + if metrics[.shoulderWidth] == nil || baseline.shoulderSourceDiffers(from: liveShoulders) { return true } let hasForwardRatio = metrics[.eyeSpanRatio] != nil || metrics[.faceWidthRatio] != nil return hasForwardRatio && (metrics[.faceWidth] ?? 0) > 0 } - func ingest(_ sample: PoseSample) { + /// The request a body engine takes the shoulders from, as configured; nil for an engine without shoulders. + static func shoulderSource(of engine: FramePoseEngine) -> PoseShoulderSource? { + guard let body = engine as? VisionBodyPoseEngine else { return nil } + return body.body3D == .never ? .body2D : .body3D + } + + /// Scores one sample and steps the state machine. Returns true when a shoulder fault waits for a fresh 3D + /// reading (`PostureShoulderGate`), so the caller asks the engine for one on the next frame. + @discardableResult + func ingest(_ sample: PoseSample) -> Bool { + var readAgain = false var finished: Collector? var good = false var score: PostureScore? @@ -243,7 +263,11 @@ final class PostureCore: @unchecked Sendable { var scorer = configuration.scorer // The face engine never sees shoulders: the face width is its own Forward head measure. if sample.engine == .visionFace { scorer.forwardHeadWithoutShoulders = nil } - score = scorer.score(smoothed, against: baseline) + let source = sample.shoulderSource + let smoothedScore = scorer.score(smoothed, against: baseline, shoulderSource: source) + let fresh = sample.hasFreshShoulders && source == .body3D + ? scorer.score(metrics, against: baseline, shoulderSource: source) : nil + (score, readAgain) = shoulderGate.apply(smoothedScore, fresh: fresh, source: source, scorer: scorer) } lastScore = score let step = machine.observe(PostureReading(sample: sample, score: score), at: sample.time) @@ -266,6 +290,7 @@ final class PostureCore: @unchecked Sendable { MainActor.assumeIsolated { feed.publish(sample, score: score, good: good) } } } + return readAgain } // MARK: - Calibration diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureDebugRecorder.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureDebugRecorder.swift index 0f21f51ac0..d0e4f6f3e5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureDebugRecorder.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureDebugRecorder.swift @@ -212,12 +212,13 @@ final class PostureDebugRecorder: @unchecked Sendable { [ "isBad": score.isBad, "isClear": score.isClear, "severity": score.severity.rounded(toPlaces: 4), "worst": score.worst?.rawValue ?? NSNull(), "unjudged": score.unjudged.map(\.rawValue).sorted(), + "held": score.held.map(\.rawValue).sorted(), "shoulderSourceMismatch": score.shoulderSourceMismatch, "signals": score.signals.map { signal -> [String: Any] in [ "kind": signal.kind.rawValue, "measure": signal.measure.rawValue, "deviation": signal.deviation.rounded(toPlaces: 4), "enter": signal.enterThreshold.rounded(toPlaces: 4), "exit": signal.exitThreshold.rounded(toPlaces: 4), "severity": signal.severity.rounded(toPlaces: 4), - "bad": signal.isBad, + "bad": signal.isBad, "held": signal.held, ] }, ] diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift index 28a68cc96c..2b5aabe35d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift @@ -87,6 +87,15 @@ public enum PostureMeasure: String, CaseIterable, Codable, Sendable { } } + /// Whether the measure needs the shoulders: it is the shoulder line itself, or it is divided by the shoulder + /// width. These change meaning when the request behind the shoulders changes (`PoseShoulderSource`). + public var usesShoulders: Bool { + switch self { + case .headHeight, .shoulderWidth, .eyeSpanRatio, .faceWidthRatio, .shoulderTilt, .lateralOffset: true + case .faceCenterX, .faceCenterY, .faceWidth, .faceRoll, .facePitch, .headphonePitch: false + } + } + var comparison: Comparison { switch self { case .headHeight: .falls diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift index 0ef7434b5f..675a75017d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift @@ -98,14 +98,32 @@ public struct PostureBaseline: Equatable, Codable, Sendable { public var span: PostureMetrics public var uprightCount: Int public var slouchCount: Int + /// The request that placed the shoulders while calibrating. Nil in a calibration saved before it was recorded + /// (2026-10-20), so a baseline with shoulder measures and no source cannot be trusted against live shoulders. + public var shoulderSource: PoseShoulderSource? public init(metrics: PostureMetrics, tolerance: PostureMetrics = PostureMetrics(), - span: PostureMetrics = PostureMetrics(), uprightCount: Int = 0, slouchCount: Int = 0) { + span: PostureMetrics = PostureMetrics(), uprightCount: Int = 0, slouchCount: Int = 0, + shoulderSource: PoseShoulderSource? = nil) { self.metrics = metrics self.tolerance = tolerance self.span = span self.uprightCount = uprightCount self.slouchCount = slouchCount + self.shoulderSource = shoulderSource + } + + /// Whether the baseline holds any measure that needs the shoulders. + public var hasShoulderMeasures: Bool { + metrics.values.keys.contains(where: \.usesShoulders) + } + + /// Whether live shoulders from `live` cannot be compared with this baseline: it has shoulder measures, and they + /// came from another request or from one it did not record. A 2D baseline against 3D shoulders read a level + /// shoulder line as a tilt of 0.12 and kept Leaning in at −0.4 (debug recording 2026-10-19 22:28:57, Home). + public func shoulderSourceDiffers(from live: PoseShoulderSource?) -> Bool { + guard let live, hasShoulderMeasures else { return false } + return shoulderSource != live } /// Readings an upright calibration needs. Dorso refuses a calibration with fewer than 4. @@ -114,7 +132,8 @@ public struct PostureBaseline: Equatable, Codable, Sendable { /// Builds a baseline from readings taken while the user sat upright (looking around the screen is /// welcome: that spread becomes the tolerance) and, optionally, while they slouched on purpose. /// Returns nil with fewer than `minimumUprightReadings` usable readings. - public static func calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = []) -> PostureBaseline? { + public static func calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = [], + shoulderSource: PoseShoulderSource? = nil) -> PostureBaseline? { let usable = upright.filter { !$0.isEmpty } guard usable.count >= minimumUprightReadings else { return nil } let median = PostureMetrics.median(usable) @@ -136,8 +155,8 @@ public struct PostureBaseline: Equatable, Codable, Sendable { drift > tol * 1.5 else { continue } span[measure] = max(drift - tol, measure.defaultSpan * 0.25) } - return PostureBaseline(metrics: median, tolerance: tolerance, span: span, - uprightCount: usable.count, slouchCount: slouch.count) + return PostureBaseline(metrics: median, tolerance: tolerance, span: span, uprightCount: usable.count, + slouchCount: slouch.count, shoulderSource: shoulderSource) } } @@ -151,19 +170,23 @@ public struct PostureSignal: Equatable, Sendable { public var exitThreshold: Double /// 0 at the enter threshold, 1 at the calibrated slouch. public var severity: Double + /// Past the enter threshold on held 3D shoulders, waiting for a fresh reading to agree + /// (`PostureShoulderGate`). A held signal is neither bad nor clear, so it starts nothing and ends nothing. + public var held: Bool public init(kind: PostureMetricKind, measure: PostureMeasure, deviation: Double, - enterThreshold: Double, exitThreshold: Double, severity: Double) { + enterThreshold: Double, exitThreshold: Double, severity: Double, held: Bool = false) { self.kind = kind self.measure = measure self.deviation = deviation self.enterThreshold = enterThreshold self.exitThreshold = exitThreshold self.severity = severity + self.held = held } - public var isBad: Bool { deviation > enterThreshold } - public var isClear: Bool { deviation < exitThreshold } + public var isBad: Bool { !held && deviation > enterThreshold } + public var isClear: Bool { !held && deviation < exitThreshold } } /// The verdict on one reading. @@ -174,16 +197,25 @@ public struct PostureScore: Equatable, Sendable { /// `severity ^ (1 / sensitivity exponent)`: what a screen effect shows, 0...1. public var strength: Double /// Faults left out on purpose: Forward head when the shoulders are out of view and the scorer's - /// `forwardHeadWithoutShoulders` is `.needsShoulders`. + /// `forwardHeadWithoutShoulders` is `.needsShoulders`, and every shoulder fault while + /// `shoulderSourceMismatch` holds. public var unjudged: Set + /// The baseline's shoulders came from another request than the live ones (`PostureBaseline.shoulderSourceDiffers`), + /// so the shoulder measures were left out. A new calibration fixes it. + public var shoulderSourceMismatch: Bool - public init(signals: [PostureSignal], severity: Double, strength: Double, unjudged: Set = []) { + public init(signals: [PostureSignal], severity: Double, strength: Double, unjudged: Set = [], + shoulderSourceMismatch: Bool = false) { self.signals = signals self.severity = severity self.strength = strength self.unjudged = unjudged + self.shoulderSourceMismatch = shoulderSourceMismatch } + /// Faults past their threshold on held shoulders, waiting for a fresh reading. + public var held: Set { Set(signals.filter(\.held).map(\.kind)) } + /// Nothing could be compared (no shared measure between the reading and the baseline). public var isEmpty: Bool { signals.isEmpty } /// Some fault is past its enter threshold. @@ -192,7 +224,7 @@ public struct PostureScore: Equatable, Sendable { public var isClear: Bool { !signals.isEmpty && signals.allSatisfy(\.isClear) } /// The fault furthest past its threshold, so the UI can say what triggered. public var worst: PostureMetricKind? { - signals.max { lhs, rhs in + signals.filter { !$0.held }.max { lhs, rhs in (lhs.deviation - lhs.enterThreshold) / max(lhs.enterThreshold, 1e-6) < (rhs.deviation - rhs.enterThreshold) / max(rhs.enterThreshold, 1e-6) }.flatMap { $0.deviation > $0.exitThreshold ? $0.kind : nil } @@ -254,10 +286,18 @@ public struct PostureScorer: Equatable, Sendable { } /// Scores the first comparable measure of each enabled fault, using the same drift as calibration. - public func score(_ metrics: PostureMetrics, against baseline: PostureBaseline) -> PostureScore { + /// `shoulderSource` is the request behind the live shoulders. When the baseline's differs, every shoulder + /// measure is left out and the faults that only the shoulders measure are unjudged; nil skips the check. + public func score(_ metrics: PostureMetrics, against baseline: PostureBaseline, + shoulderSource: PoseShoulderSource? = nil) -> PostureScore { let zone = deadZone.fraction var signals: [PostureSignal] = [] var unjudged: Set = [] + let mismatch = baseline.shoulderSourceDiffers(from: shoulderSource) + var metrics = metrics + if mismatch { + for measure in PostureMeasure.allCases where measure.usesShoulders { metrics[measure] = nil } + } for kind in PostureMetricKind.allCases where enabled.contains(kind) { if kind == .headDrop, let lost = headLost(metrics, baseline: baseline, zone: zone) { signals.append(lost) @@ -281,10 +321,34 @@ public struct PostureScorer: Equatable, Sendable { enterThreshold: enter, exitThreshold: exit, severity: severity)) break } + if mismatch, !signals.contains(where: { $0.kind == kind }), + PostureMeasure.preference(for: kind).contains(where: { baseline.metrics[$0] != nil && $0.usesShoulders }) { + unjudged.insert(kind) + } } let severity = signals.map(\.severity).max() ?? 0 - let strength = severity > 0 ? pow(severity, 1 / sensitivity.exponent) : 0 - return PostureScore(signals: signals, severity: severity, strength: strength, unjudged: unjudged) + return PostureScore(signals: signals, severity: severity, strength: strength(severity), unjudged: unjudged, + shoulderSourceMismatch: mismatch) + } + + /// What a screen effect shows for a severity: `severity ^ (1 / sensitivity exponent)`. + func strength(_ severity: Double) -> Double { + severity > 0 ? pow(severity, 1 / sensitivity.exponent) : 0 + } + + /// The score with every bad shoulder signal of `kinds` held, and the severity and strength taken again + /// without them. + func holding(_ kinds: Set, in score: PostureScore) -> PostureScore { + var held = score + for index in held.signals.indices { + let signal = held.signals[index] + guard kinds.contains(signal.kind), signal.measure.usesShoulders, signal.isBad else { continue } + held.signals[index].held = true + held.signals[index].severity = 0 + } + held.severity = held.signals.map(\.severity).max() ?? 0 + held.strength = strength(held.severity) + return held } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift new file mode 100644 index 0000000000..fd5239552c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift @@ -0,0 +1,44 @@ +import Foundation +import GenesisKit + +/// Lets a shoulder fault from the throttled 3D body request turn bad only when two fresh 3D readings in a row are +/// bad, each scored on its own. The engine reuses one 3D reading for several seconds, so without this a single +/// wrong reading, or one taken before the user moved, held a fault for its whole lifetime: a level shoulder line +/// was judged tilted for 6.8 s (debug recording 2026-10-19 22:28:57, Home). +/// +/// While a fault waits, its signal is held (neither bad nor clear) and `apply` asks for a fresh reading on the +/// next frame (`VisionBodyPoseEngine.readShouldersOnNextFrame()`), so the wait is one frame, not one interval. +/// A fault leaves at once when a fresh reading is no longer bad. Readings with 2D shoulders pass unchanged: the +/// 2D request runs on every frame. +struct PostureShoulderGate: Equatable, Sendable { + /// Faults the last two fresh readings both found bad. + private(set) var confirmed: Set = [] + /// Faults only the last fresh reading found bad. + private(set) var pending: Set = [] + + /// Judges one reading. `score` is the smoothed score the monitor acts on; `fresh` is this frame's unsmoothed + /// score, given only when the 3D request ran on this frame. Returns the score to act on and whether the + /// engine should read the shoulders again on the next frame. + mutating func apply(_ score: PostureScore, fresh: PostureScore?, source: PoseShoulderSource?, + scorer: PostureScorer) -> (score: PostureScore, readAgain: Bool) { + guard source == .body3D else { return (score, false) } + if let fresh { + let bad = Self.badShoulderKinds(fresh) + let agreed = bad.intersection(confirmed.union(pending)) + pending = bad.subtracting(agreed) + confirmed = agreed + } + let waiting = Self.badShoulderKinds(score).subtracting(confirmed) + let held = waiting.isEmpty ? score : scorer.holding(waiting, in: score) + return (held, fresh != nil && !pending.isEmpty) + } + + mutating func reset() { + confirmed = [] + pending = [] + } + + static func badShoulderKinds(_ score: PostureScore) -> Set { + Set(score.signals.filter { $0.isBad && $0.measure.usesShoulders }.map(\.kind)) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift new file mode 100644 index 0000000000..7e22bb1556 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift @@ -0,0 +1,237 @@ +import XCTest +@testable import GenesisKitBase +@testable import GenesisKit +@testable import GenesisKitPosture + +/// A body sample whose shoulders came from `source`, read `age` seconds before `time`. +private func shoulderSample(tilt: Double = 0, time: TimeInterval = 0, source: PoseShoulderSource = .body3D, + age: TimeInterval = 0) -> PoseSample { + var sample = postureSample(shoulderTilt: tilt, time: time) + sample.shoulderSource = source + sample.shoulderAge = age + return sample +} + +final class PostureShoulderSourceTests: XCTestCase { + private func baseline(_ source: PoseShoulderSource?) throws -> PostureBaseline { + try XCTUnwrap(PostureBaseline.calibrate(upright: (0..<4).map { _ in PostureMetrics(sample: postureSample()) }, + shoulderSource: source)) + } + + func testTheCalibrationRecordsWhereItsShouldersCameFrom() throws { + var session = PostureCalibrationSession() + for index in 0..<5 { session.add(shoulderSample(time: Double(index)), as: .upright) } + XCTAssertEqual(session.shoulderSource, .body3D) + XCTAssertEqual(try XCTUnwrap(session.baseline()).shoulderSource, .body3D) + + var untold = PostureCalibrationSession() + for index in 0..<5 { untold.add(postureSample(time: Double(index)), as: .upright) } + XCTAssertNil(try XCTUnwrap(untold.baseline()).shoulderSource, "a sample that does not say leaves it unknown") + } + + func testAnOldCalibrationFileLoadsWithNoShoulderSource() throws { + let encoded = try JSONEncoder().encode(try baseline(.body3D)) + XCTAssertEqual(try JSONDecoder().decode(PostureBaseline.self, from: encoded).shoulderSource, .body3D) + + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object["shoulderSource"] = nil + let old = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(PostureBaseline.self, from: old) + XCTAssertNil(decoded.shoulderSource) + XCTAssertTrue(decoded.shoulderSourceDiffers(from: .body3D), "an unknown source cannot be trusted") + XCTAssertTrue(decoded.shoulderSourceDiffers(from: .body2D)) + XCTAssertFalse(decoded.shoulderSourceDiffers(from: nil), "no live source, nothing to compare") + } + + func testASampleKeepsItsShoulderSourceAndAgeThroughJSON() throws { + let sample = shoulderSample(time: 3, age: 1.5) + let decoded = try JSONDecoder().decode(PoseSample.self, from: JSONEncoder().encode(sample)) + XCTAssertEqual(decoded, sample) + XCTAssertFalse(decoded.hasFreshShoulders) + XCTAssertTrue(shoulderSample(age: 0).hasFreshShoulders) + } + + /// Debug recording 2026-10-19 22:28:57: a 2D baseline against 3D shoulders judged a level shoulder line + /// tilted and could never see a lean. Against another source the shoulder faults are left unjudged. + func testShouldersFromAnotherRequestAreNotJudged() throws { + let level = PostureMetrics(sample: shoulderSample()) + let tilted = PostureMetrics(sample: shoulderSample(tilt: 0.1)) + let scorer = PostureScorer() + + let same = scorer.score(tilted, against: try baseline(.body3D), shoulderSource: .body3D) + XCTAssertFalse(same.shoulderSourceMismatch) + XCTAssertEqual(same.worst, .shoulderTilt) + + for stored in [PoseShoulderSource.body2D, nil] { + let other = scorer.score(tilted, against: try baseline(stored), shoulderSource: .body3D) + XCTAssertTrue(other.shoulderSourceMismatch) + XCTAssertFalse(other.isBad) + XCTAssertTrue(other.signals.isEmpty, "every measure of this body sample divides by the shoulders") + XCTAssertEqual(other.unjudged, [.headDrop, .forwardHead, .leanIn, .shoulderTilt, .lateralLean]) + } + + let unchecked = scorer.score(tilted, against: try baseline(nil)) + XCTAssertFalse(unchecked.shoulderSourceMismatch, "without a live source the scorer compares as before") + XCTAssertTrue(scorer.score(level, against: try baseline(nil)).isClear) + } + + func testTheFaceStillJudgesWhenTheShouldersDoNot() throws { + var upright = shoulderSample() + upright.face = PoseFace(box: CGRect(x: 0.4, y: 0.3, width: 0.2, height: 0.25)) + var dropped = upright + dropped.face?.box.origin.y += 0.1 + let base = try XCTUnwrap(PostureBaseline.calibrate(upright: (0..<4).map { _ in PostureMetrics(sample: upright) })) + let score = PostureScorer().score(PostureMetrics(sample: dropped), against: base, shoulderSource: .body3D) + XCTAssertTrue(score.shoulderSourceMismatch) + XCTAssertEqual(score.signals.first { $0.kind == .headDrop }?.measure, .faceCenterY) + XCTAssertTrue(score.isBad, "the face centre still measures a head drop") + XCTAssertTrue(score.unjudged.isSuperset(of: [.leanIn, .shoulderTilt])) + } + + func testAMismatchedBaselineReadsTheFaceOnEveryFrame() throws { + XCTAssertTrue(PostureCore.needsFaceOnEveryFrame(calibrating: false, baseline: try baseline(nil), liveShoulders: .body3D), + "the face is all that is left to judge with") + XCTAssertFalse(PostureCore.needsFaceOnEveryFrame(calibrating: false, baseline: try baseline(.body3D), + liveShoulders: .body3D)) + XCTAssertEqual(PostureCore.shoulderSource(of: VisionBodyPoseEngine()), .body3D) + XCTAssertEqual(PostureCore.shoulderSource(of: VisionBodyPoseEngine(body3D: .never)), .body2D) + XCTAssertNil(PostureCore.shoulderSource(of: VisionFaceEngine())) + } + + func testTheEngineStampsWhereTheShouldersCameFrom() { + let both: [PoseJoint: PoseJointPoint] = [ + .leftShoulder: PoseJointPoint(x: 0.7, y: 0.8, confidence: 1), + .rightShoulder: PoseJointPoint(x: 0.3, y: 0.8, confidence: 1), + ] + XCTAssertNil(VisionBodyPoseEngine.shoulderStamp([:], body3D: .throttled, readAt: 1, time: 2)) + XCTAssertEqual(VisionBodyPoseEngine.shoulderStamp(both, body3D: .never, readAt: nil, time: 2)?.source, .body2D) + XCTAssertEqual(VisionBodyPoseEngine.shoulderStamp(both, body3D: .never, readAt: nil, time: 2)?.age, 0) + let held = VisionBodyPoseEngine.shoulderStamp(both, body3D: .throttled, readAt: 1, time: 3.5) + XCTAssertEqual(held?.source, .body3D) + XCTAssertEqual(held?.age, 2.5) + XCTAssertEqual(VisionBodyPoseEngine.shoulderStamp(both, body3D: .always, readAt: 2, time: 2)?.age, 0) + } + + // MARK: - Held 3D shoulders + + /// One fresh bad 3D reading is held and asks for another; the second agrees and the fault counts, also on + /// the held frames after it; a fresh reading that is no longer bad lets go at once. + func testAShoulderFaultNeedsTwoFresh3DReadingsInARow() throws { + let base = try baseline(.body3D) + let scorer = PostureScorer() + var gate = PostureShoulderGate() + func step(tilt: Double, fresh: Bool) -> (score: PostureScore, readAgain: Bool) { + let score = scorer.score(PostureMetrics(sample: shoulderSample(tilt: tilt)), against: base, + shoulderSource: .body3D) + return gate.apply(score, fresh: fresh ? score : nil, source: .body3D, scorer: scorer) + } + + let first = step(tilt: 0.1, fresh: true) + XCTAssertTrue(first.readAgain) + XCTAssertFalse(first.score.isBad) + XCTAssertFalse(first.score.isClear, "held is neither bad nor clear, so it ends no slouch either") + XCTAssertEqual(first.score.held, [.shoulderTilt]) + XCTAssertEqual(first.score.severity, 0) + XCTAssertNil(first.score.worst) + + let between = step(tilt: 0.1, fresh: false) + XCTAssertFalse(between.readAgain, "a held frame asks for nothing new") + XCTAssertEqual(between.score.held, [.shoulderTilt]) + + let second = step(tilt: 0.1, fresh: true) + XCTAssertFalse(second.readAgain) + XCTAssertTrue(second.score.isBad) + XCTAssertTrue(second.score.held.isEmpty) + XCTAssertGreaterThan(second.score.strength, 0) + + XCTAssertTrue(step(tilt: 0.1, fresh: false).score.isBad, "a confirmed reading stays bad while it is held") + + let level = step(tilt: 0, fresh: true) + XCTAssertTrue(level.score.isClear) + XCTAssertTrue(gate.confirmed.isEmpty) + let again = step(tilt: 0.1, fresh: true) + XCTAssertEqual(again.score.held, [.shoulderTilt], "after a level reading the next tilt waits again") + } + + func testA2DReadingPassesTheGateUnchanged() throws { + let base = try baseline(.body2D) + let scorer = PostureScorer() + var gate = PostureShoulderGate() + let score = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1, source: .body2D)), against: base, + shoulderSource: .body2D) + let result = gate.apply(score, fresh: score, source: .body2D, scorer: scorer) + XCTAssertEqual(result.score, score) + XCTAssertFalse(result.readAgain) + } + + /// The smoother still holds the old shoulders for a moment after a fresh level reading; that reading wins. + func testAFreshLevelReadingHoldsAStillBadSmoothedScore() throws { + let base = try baseline(.body3D) + let scorer = PostureScorer() + var gate = PostureShoulderGate() + let bad = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1)), against: base, shoulderSource: .body3D) + _ = gate.apply(bad, fresh: bad, source: .body3D, scorer: scorer) + XCTAssertTrue(gate.apply(bad, fresh: bad, source: .body3D, scorer: scorer).score.isBad) + let level = scorer.score(PostureMetrics(sample: shoulderSample()), against: base, shoulderSource: .body3D) + let result = gate.apply(bad, fresh: level, source: .body3D, scorer: scorer) + XCTAssertFalse(result.score.isBad) + XCTAssertEqual(result.score.held, [.shoulderTilt]) + } +} + +@MainActor +final class PostureShoulderGateMonitorTests: XCTestCase { + func testAHeldTiltSlouchesOnlyAfterAFreshReadingAgrees() throws { + let clock = ClockBox() + let configuration = PostureMonitor.Configuration( + timing: PostureTiming(confirmBad: 1, confirmGood: 0.5, onsetDelay: 1, resetStreak: 2, resumeGrace: 0)) + let monitor = PostureMonitor(configuration: configuration, camera: nil, cameraControl: RecordingCamera(), + engine: NoEngine(), history: nil, clock: { clock.now }, + wallClock: { Date(timeIntervalSince1970: 1_800_000_000 + clock.now) }) + monitor.setBaseline(try XCTUnwrap(PostureBaseline.calibrate( + upright: (0..<4).map { _ in PostureMetrics(sample: shoulderSample()) }, shoulderSource: .body3D))) + monitor.enable() + // One fresh tilted reading, then 4 s of frames that only hold it: no slouch. + var time = 0.0 + while time <= 4 { + clock.now = time + monitor.ingest(shoulderSample(tilt: 0.1, time: time, age: time)) + time += 0.25 + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + XCTAssertEqual(monitor.state, .monitoring, "one 3D reading held for 4 s is not a slouch") + // A second fresh reading agrees: from here the held frames count. + let confirmedAt = time + while time <= confirmedAt + 3 { + clock.now = time + monitor.ingest(shoulderSample(tilt: 0.1, time: time, age: time - confirmedAt)) + time += 0.25 + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + XCTAssertEqual(monitor.state, .slouching) + } + + func testAnOldCalibrationIsNotJudgedOnLiveShoulders() throws { + let clock = ClockBox() + let configuration = PostureMonitor.Configuration( + timing: PostureTiming(confirmBad: 1, confirmGood: 0.5, onsetDelay: 1, resetStreak: 2, resumeGrace: 0)) + let monitor = PostureMonitor(configuration: configuration, camera: nil, cameraControl: RecordingCamera(), + engine: NoEngine(), history: nil, clock: { clock.now }, + wallClock: { Date(timeIntervalSince1970: 1_800_000_000 + clock.now) }) + monitor.setBaseline(try XCTUnwrap(PostureBaseline.calibrate( + upright: (0..<4).map { _ in PostureMetrics(sample: shoulderSample()) }))) + monitor.enable() + monitor.feed.beginWatching() + defer { monitor.feed.endWatching() } + var time = 0.0 + while time <= 5 { + clock.now = time + monitor.ingest(shoulderSample(tilt: 0.1, time: time)) + time += 0.25 + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + XCTAssertEqual(monitor.state, .monitoring) + XCTAssertEqual(monitor.feed.score?.shoulderSourceMismatch, true) + XCTAssertEqual(monitor.feed.score?.unjudged.contains(.shoulderTilt), true) + } +} From 3ae2f9a202b11e9634b660ef46d63741e2cdeb89 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 23:24:54 +0200 Subject: [PATCH 09/21] fix(macos, widget preview): each build unregisters the retired previews, keeps the newest two and drops stale Launch Services records Every preview build moved the replaced app to ~/.genesis-tools/widget-preview/retired// and left it there, registered: 72 Launch Services records of deleted copies on 2026-10-20, all offered to open -b. The build now unregisters every retired copy, deletes all but the newest RETIRED_PREVIEWS_KEPT (2), and unregisters any record of the preview's bundle id at another path (staleRegistrations, shared with GenesisTools.app; LSREGISTER is exported for it). A failure is logged and the installed preview stays. --- scripts/build-widget-preview.ts | 47 ++++++++++++++++++++++++++++++-- src/macos/lib/permissions/app.ts | 2 +- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/scripts/build-widget-preview.ts b/scripts/build-widget-preview.ts index 6da959356d..f0976c67be 100644 --- a/scripts/build-widget-preview.ts +++ b/scripts/build-widget-preview.ts @@ -1,13 +1,15 @@ import { createHash } from "node:crypto"; -import { cp, mkdir, rename } from "node:fs/promises"; +import { cp, mkdir, readdir, rename, rm } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { buildDiffViewer } from "@app/macos/lib/permissions/app"; +import { buildDiffViewer, LSREGISTER, staleRegistrations } from "@app/macos/lib/permissions/app"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; import { toolDataDir } from "@genesiscz/utils/storage/root"; const bundleId = "com.genesiscz.genesistools.widget-preview"; +/** Replaced previews kept for a rollback; older ones are deleted after each build. */ +const RETIRED_PREVIEWS_KEPT = 2; const appName = "GenesisTools Preview.app"; const repo = resolve(import.meta.dir, ".."); const packagePath = join(repo, "src/macos/GenesisTools"); @@ -232,7 +234,48 @@ async function buildPreview(): Promise { throw error; } + await pruneRetiredPreviews(destination); out.result({ appPath: destination, bundleId, executable: "GenesisWidgetPreview", productionAppTargeted: false }); } +/** + * Every retired preview is unregistered from Launch Services, only the newest {@link RETIRED_PREVIEWS_KEPT} stay on + * disk, and every record of this bundle id at another path than `destination` goes. Before this, each build left its + * retired copy registered and on disk forever: 72 records of deleted copies on 2026-10-20, all offered to `open -b`. + * A failure is logged, never fatal: the new preview is already installed. + */ +async function pruneRetiredPreviews(destination: string): Promise { + const root = toolDataDir("widget-preview", "retired"); + try { + const stamps = (await readdir(root, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isDirectory() && Number.isFinite(Number(entry.name))) + .map((entry) => entry.name) + .sort((a, b) => Number(b) - Number(a)); + for (const stamp of stamps) { + await command([LSREGISTER, "-u", join(root, stamp, appName)]).catch((error) => + logger.warn({ error, stamp }, "retired preview not unregistered") + ); + } + for (const stamp of stamps.slice(RETIRED_PREVIEWS_KEPT)) { + await rm(join(root, stamp), { recursive: true, force: true }); + } + const stale = staleRegistrations(await command([LSREGISTER, "-dump"]), bundleId, destination); + for (const path of stale) { + await command([LSREGISTER, "-u", path]).catch((error) => + logger.warn({ error, path }, "stale preview record not unregistered") + ); + } + logger.info( + { + kept: Math.min(stamps.length, RETIRED_PREVIEWS_KEPT), + deleted: Math.max(0, stamps.length - RETIRED_PREVIEWS_KEPT), + unregistered: stale.length, + }, + "pruned retired widget previews" + ); + } catch (error) { + logger.warn({ error, root }, "pruning retired widget previews failed; they stay"); + } +} + await buildPreview(); diff --git a/src/macos/lib/permissions/app.ts b/src/macos/lib/permissions/app.ts index a31578736d..2d00048f36 100644 --- a/src/macos/lib/permissions/app.ts +++ b/src/macos/lib/permissions/app.ts @@ -89,7 +89,7 @@ const ICON_NAME = "AppIcon"; const BUILD_LOCK_TIMEOUT_MS = 240_000; const PLIST_VERSION_MARKER = "0.0.0"; const PLIST_BUILD_MARKER = "CFBundleVersion\n\t1"; -const LSREGISTER = +export const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; /** Which checkout and commit an install was built from, so "what is running?" is one `bun run app:status`. */ From 6ba2ae9823b13b2155d9ab7550d6efbca9dcf477 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 23:34:06 +0200 Subject: [PATCH 10/21] fix(macos): vault key release compares the caller's exec generation and image UUID instead of its path, since a hard-linked gt-* binary reports whichever name was looked up last --- .../Sources/Host/VaultRecipient.swift | 34 ++++++++- .../GenesisTools/Tests/VaultUnlockTests.swift | 76 +++++++++++++++++-- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift index 1f064f9df1..678b22a578 100644 --- a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift +++ b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift @@ -7,11 +7,26 @@ import Foundation /// execs into such a binary while keeping the read end is not seen. That exception trusts the system /// binary's code not to relay the bytes; it does not prove the read end was closed. enum VaultRecipient { + /// One process image: the pid, its start time, and the kernel's exec generation and image UUID. + /// `==` leaves `executable` out. For a hard-linked binary (every `~/.genesis-tools/bin/gt-` is one + /// inode) proc_pidpath reports whichever name was looked up last, so any other `gt-*` start during the + /// approval changed it and the key was refused (2026-10-20). `execGeneration` is new on every exec of + /// the pid, so it catches every exec a path compare caught, and an exec into the same path too. struct Identity: Equatable { var pid: pid_t var startSeconds: UInt64 var startMicroseconds: UInt64 + /// The kernel's `p_idversion`. + var execGeneration: Int32 + /// LC_UUID of the main executable. + var imageUUID: UUID + /// For display and grant keys only. var executable: String + + static func == (lhs: Identity, rhs: Identity) -> Bool { + lhs.pid == rhs.pid && lhs.startSeconds == rhs.startSeconds && lhs.startMicroseconds == rhs.startMicroseconds + && lhs.execGeneration == rhs.execGeneration && lhs.imageUUID == rhs.imageUUID + } } enum Endpoint: Equatable { @@ -89,10 +104,23 @@ enum VaultRecipient { let size = Int32(MemoryLayout.stride) guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) == size, info.pbi_pid == UInt32(pid), info.pbi_start_tvsec != 0 else { return nil } - var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) - guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + guard let image = image(pid), let executable = executablePath(pid) else { return nil } return Identity(pid: pid, startSeconds: info.pbi_start_tvsec, startMicroseconds: info.pbi_start_tvusec, - executable: String(cString: buffer)) + execGeneration: image.generation, imageUUID: image.uuid, executable: executable) + } + + /// `PROC_PIDUNIQIDENTIFIERINFO` is private: its struct is read by offset from XNU's proc_info_private.h + /// (`p_uuid[16]`, `p_uniqueid`, `p_puniqueid`, `int32_t p_idversion`, then reserved fields; 56 bytes). + /// A short read or a zero generation is no identity, so release is denied. + private static func image(_ pid: pid_t) -> (generation: Int32, uuid: UUID)? { + let flavor: Int32 = 17 + var bytes = [UInt8](repeating: 0, count: 56) + let read = bytes.withUnsafeMutableBytes { proc_pidinfo(pid, flavor, 0, $0.baseAddress, Int32($0.count)) } + guard read == Int32(bytes.count) else { return nil } + let generation = bytes.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: 32, as: Int32.self) } + let uuid = bytes.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: 0, as: uuid_t.self) } + guard generation != 0 else { return nil } + return (generation, UUID(uuid: uuid)) } private struct InspectionFailure: Error { diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index 4f10c29234..bdd73e12ef 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -30,8 +30,68 @@ final class VaultUnlockTests: XCTestCase { cwd: "/Users/alice/project", ancestors: ancestors.map { (pid: $0.0, command: $0.1) }) } + private func fixtureIdentity(generation: Int32 = 9, executable: String = "/bin/client") -> VaultRecipient.Identity { + VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, execGeneration: generation, + imageUUID: UUID(uuidString: "4C4C447C-5555-3144-A138-DBD9C49417A3")!, executable: executable) + } + + func testVaultRecipientIgnoresTheReportedNameButNotAnExec() { + let output = VaultRecipient.Endpoint.pipe(handle: 10, peer: 20) + let holder = [VaultRecipient.ProcessFDs(pid: 42, endpoints: [.pipe(handle: 20, peer: 10)])] + let renamed = fixtureIdentity(executable: "/Users/alice/.genesis-tools/bin/gt-ask") + XCTAssertNil(VaultRecipient.rejection(stdout: output, caller: fixtureIdentity(), current: renamed, processes: holder), + "another name of the same hard-linked image is the same caller") + XCTAssertEqual(VaultRecipient.rejection(stdout: output, caller: fixtureIdentity(), current: fixtureIdentity(generation: 10), + processes: holder), + "the caller's process instance or executable changed before key release") + } + + /// The kernel behaviour the comparison relies on: a process started from one hard link reports the name looked up + /// last, while its exec generation and image UUID stay put; an exec in place changes the generation. bzcat and + /// bzip2 are one inode on the system volume, so the test makes no executable of its own (see tests.md: a new + /// executable started and then deleted crashed syspolicyd). + func testVaultRecipientIdentitySurvivesAHardLinkNameFlip() throws { + let waiting = Process() + waiting.executableURL = URL(fileURLWithPath: "/usr/bin/bzcat") + let stdin = Pipe() + waiting.standardInput = stdin + waiting.standardOutput = FileHandle.nullDevice + waiting.standardError = FileHandle.nullDevice + try waiting.run() + defer { stdin.fileHandleForWriting.closeFile(); waiting.terminate() } + Thread.sleep(forTimeInterval: 0.2) + let before = try XCTUnwrap(VaultRecipient.identity(waiting.processIdentifier)) + + let other = Process() + other.executableURL = URL(fileURLWithPath: "/usr/bin/bzip2") + other.arguments = ["--help"] + other.standardOutput = FileHandle.nullDevice + other.standardError = FileHandle.nullDevice + try other.run() + other.waitUntilExit() + let after = try XCTUnwrap(VaultRecipient.identity(waiting.processIdentifier)) + XCTAssertNotEqual(before.executable, after.executable, "positive control: the reported name flipped") + XCTAssertEqual(before, after) + + let shell = Process() + shell.executableURL = URL(fileURLWithPath: "/bin/sh") + shell.arguments = ["-c", "read line; exec /bin/sleep 5"] + let input = Pipe() + shell.standardInput = input + try shell.run() + defer { shell.terminate() } + Thread.sleep(forTimeInterval: 0.2) + let beforeExec = try XCTUnwrap(VaultRecipient.identity(shell.processIdentifier)) + input.fileHandleForWriting.write(Data("go\n".utf8)) + Thread.sleep(forTimeInterval: 0.3) + let afterExec = try XCTUnwrap(VaultRecipient.identity(shell.processIdentifier)) + XCTAssertEqual(afterExec.executable, "/bin/sleep", "positive control: the exec happened") + XCTAssertNotEqual(beforeExec.execGeneration, afterExec.execGeneration) + XCTAssertNotEqual(beforeExec, afterExec) + } + func testVaultRecipientRequiresTheCallerToBeTheOnlyPeerHolder() { - let identity = VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, executable: "/bin/client") + let identity = fixtureIdentity() let channels: [(VaultRecipient.Endpoint, VaultRecipient.Endpoint)] = [ (.pipe(handle: 10, peer: 20), .pipe(handle: 20, peer: 10)), (.socket(pcb: 10, peer: 20), .socket(pcb: 20, peer: 10)), @@ -77,7 +137,7 @@ final class VaultUnlockTests: XCTestCase { } func testVaultRecipientSkipsOnlyExitedOrPermissionDeniedSystemProcesses() { - let identity = VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, executable: "/bin/client") + let identity = fixtureIdentity() let output = VaultRecipient.Endpoint.pipe(handle: 10, peer: 20) let caller = VaultRecipient.ProcessFDs(pid: 42, endpoints: [.pipe(handle: 20, peer: 10)]) let exited = VaultRecipient.ProcessFDs(pid: 43, endpoints: nil, failure: .exited) @@ -106,25 +166,27 @@ final class VaultUnlockTests: XCTestCase { } func testVaultRecipientRejectsChangedOrUnknownProcessIdentity() { - let identity = VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, executable: "/bin/client") + let identity = fixtureIdentity() let output = VaultRecipient.Endpoint.pipe(handle: 10, peer: 20) let processes = [VaultRecipient.ProcessFDs(pid: 42, endpoints: [.pipe(handle: 20, peer: 10)])] var changedSeconds = identity changedSeconds.startSeconds += 1 var changedMicroseconds = identity changedMicroseconds.startMicroseconds += 1 - var changedPath = identity - changedPath.executable = "/bin/other" + var changedGeneration = identity + changedGeneration.execGeneration += 1 + var changedImage = identity + changedImage.imageUUID = UUID() var changedPid = identity changedPid.pid += 1 - for current in [changedSeconds, changedMicroseconds, changedPath, changedPid, nil] { + for current in [changedSeconds, changedMicroseconds, changedGeneration, changedImage, changedPid, nil] { XCTAssertNotNil(VaultRecipient.rejection(stdout: output, caller: identity, current: current, processes: processes)) } XCTAssertNotNil(VaultRecipient.rejection(stdout: output, caller: nil, current: identity, processes: processes)) } func testVaultRecipientRejectsUnsupportedDisconnectedAndWrongKindEndpoints() { - let identity = VaultRecipient.Identity(pid: 42, startSeconds: 100, startMicroseconds: 7, executable: "/bin/client") + let identity = fixtureIdentity() let processes = [VaultRecipient.ProcessFDs(pid: 42, endpoints: [.pipe(handle: 20, peer: 10)])] for output in [VaultRecipient.Endpoint.other, .pipe(handle: 10, peer: 0), .socket(pcb: 0, peer: 20), .pipe(handle: 10, peer: 10), .socket(pcb: 10, peer: 20)] { From b083641828dd41152d678640fbf073631b8aa30f Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 Oct 2026 22:05:55 +0200 Subject: [PATCH 11/21] fix(macos): a notification reply to a caller that already left no longer aborts the app, and a vault refusal for an unreadable fd table names the process --- src/macos/GenesisTools/Sources/Host/Notify.swift | 12 +++++++++--- .../GenesisTools/Sources/Host/VaultRecipient.swift | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/Notify.swift b/src/macos/GenesisTools/Sources/Host/Notify.swift index 8f8c257d5f..8899afb680 100644 --- a/src/macos/GenesisTools/Sources/Host/Notify.swift +++ b/src/macos/GenesisTools/Sources/Host/Notify.swift @@ -134,12 +134,18 @@ private func emit(_ payload: [String: Any]) { let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let line = String(data: data, encoding: .utf8) else { - let fallback = "{\"ok\":false,\"error\":{\"code\":\"internal\",\"message\":\"could not encode reply\"}}\n" - FileHandle.standardOutput.write(Data(fallback.utf8)) + writeReply("{\"ok\":false,\"error\":{\"code\":\"internal\",\"message\":\"could not encode reply\"}}\n") return } - FileHandle.standardOutput.write(Data("\(line)\n".utf8)) + writeReply("\(line)\n") +} + +/// The caller may be gone by the time a slow notification call answers (its RPC deadline passed, or it was killed). +/// `FileHandle.write(_:)` raises an Objective-C exception on a closed pipe and aborts the app (crash reports +/// 2026-10-21 04:37 and 18:12); the throwing `write(contentsOf:)` lets the reply drop instead. +private func writeReply(_ text: String) { + try? FileHandle.standardOutput.write(contentsOf: Data(text.utf8)) } // MARK: - Content diff --git a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift index 678b22a578..abb1b4e99d 100644 --- a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift +++ b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift @@ -86,8 +86,14 @@ enum VaultRecipient { guard stdout.connected else { return "vault stdout must be a connected pipe or Unix-domain socket" } guard let caller, let current else { return "the caller's process identity could not be read" } guard current == caller else { return "the caller's process instance or executable changed before key release" } - guard processes.allSatisfy({ $0.endpoints != nil || $0.maySkip }) else { - return "the vault recipient could not be verified because a process fd table was unreadable" + if let unreadable = processes.first(where: { $0.endpoints == nil && !$0.maySkip }) { + // Name the process: 37 refusals on 2026-10-20 and 21 said only "a process", so nobody could tell which. + var detail = "pid \(unreadable.pid)" + if case let .unavailable(code, executable) = unreadable.failure { + detail += ", \(String(cString: strerror(code)))" + if let executable { detail += ", \(executable)" } + } + return "the vault recipient could not be verified because a process fd table was unreadable (\(detail))" } let holders = Set(processes.filter { process in process.endpoints?.contains(where: { $0.isPeer(of: stdout) }) == true From 936dc1a0cb9a80325b2627a44a4b6bc436cac977 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 Oct 2026 22:14:52 +0200 Subject: [PATCH 12/21] fix(macos): clones reclaim with --worktrees-of keeps every --dir that is not a worktree and does not hold the repo as its own root, instead of dropping it silently --- src/macos/lib/clones/discover.test.ts | 40 +++++++++++++++++++++++++++ src/macos/lib/clones/discover.ts | 35 +++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/macos/lib/clones/discover.test.ts b/src/macos/lib/clones/discover.test.ts index 7d090cdc37..d9d531a1bb 100644 --- a/src/macos/lib/clones/discover.test.ts +++ b/src/macos/lib/clones/discover.test.ts @@ -137,6 +137,46 @@ describe("discoverRoots", () => { } }); + it("with worktreesOf, a dir that is not a worktree stays its own root, and one inside a worktree adds nothing", async () => { + const outer = fleet(); + try { + mkdirSync(join(outer, "main", "node_modules", "dep"), { recursive: true }); + mkdirSync(join(outer, "main-alive", "node_modules", "dep"), { recursive: true }); + mkdirSync(join(outer, "other", "node_modules", "dep"), { recursive: true }); + mkdirSync(join(outer, "plain", "node_modules", "dep"), { recursive: true }); + + const real = realpathSync(outer); + const res = await discoverRoots({ + dirs: [join(outer, "main"), join(outer, "main-alive", "node_modules"), join(outer, "plain")], + worktreesOf: join(outer, "main"), + targets: ["node_modules"], + }); + expect(res.roots.sort()).toEqual( + [ + join(real, "main", "node_modules"), + join(real, "main-alive", "node_modules"), + join(real, "plain", "node_modules"), + ].sort() + ); + } finally { + rmSync(outer, { recursive: true, force: true }); + } + }); + + it("with worktreesOf, a dir that holds the repo only narrows the search", async () => { + const outer = fleet(); + try { + mkdirSync(join(outer, "main", "node_modules", "dep"), { recursive: true }); + mkdirSync(join(outer, "other", "node_modules", "dep"), { recursive: true }); + + const real = realpathSync(outer); + const res = await discoverRoots({ dirs: [outer], worktreesOf: "main", targets: ["node_modules"] }); + expect(res.roots).toEqual([join(real, "main", "node_modules")]); + } finally { + rmSync(outer, { recursive: true, force: true }); + } + }); + it("without worktreesOf it scans every install tree under the dirs", async () => { const outer = fleet(); try { diff --git a/src/macos/lib/clones/discover.ts b/src/macos/lib/clones/discover.ts index f80aade2e0..e0ddab7a93 100644 --- a/src/macos/lib/clones/discover.ts +++ b/src/macos/lib/clones/discover.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync, realpathSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; +import { isAbsolute, join, resolve, sep } from "node:path"; import { listWorktrees } from "@genesiscz/utils/git/worktree"; import { logger } from "@genesiscz/utils/logger"; import { getFsType } from "@genesiscz/utils/macos/apfs"; @@ -138,6 +138,31 @@ export function partitionApfs( return { apfs, skipped }; } +function isSameOrInside(dir: string, parent: string): boolean { + return dir === parent || dir.startsWith(parent + sep); +} + +/** + * With `--worktrees-of`, the search dirs that still need scanning beside the repo's worktrees. A dir that is an + * ancestor of the repo only narrows the search (`--dir ~/Projects --worktrees-of app`), and a dir that is a worktree + * or sits inside one is already covered. Any other dir is its own root: these used to be dropped without a word + * while the saved selector still listed them (2026-10-21). + */ +export function extraRootDirs({ + searchDirs, + repoRoot, + worktrees, +}: { + searchDirs: string[]; + repoRoot: string; + worktrees: string[]; +}): string[] { + const repo = realpathSync(repoRoot); + return searchDirs.filter( + (dir) => !isSameOrInside(repo, dir) && !worktrees.some((worktree) => isSameOrInside(dir, worktree)) + ); +} + export async function discoverRoots(args: DiscoverArgs): Promise { const end = clonesProfile.start("discover"); const skipped: SkippedRoot[] = []; @@ -155,7 +180,13 @@ export async function discoverRoots(args: DiscoverArgs): Promise let scanDirs = searchDirs; if (args.worktreesOf !== undefined && searchDirs.length > 0) { const repoRoot = resolveRepoRoot(searchDirs, args.worktreesOf); - scanDirs = await worktreeDirs(repoRoot); + const worktrees = await worktreeDirs(repoRoot); + const extra = extraRootDirs({ searchDirs, repoRoot, worktrees }); + if (extra.length > 0) { + log.info({ repoRoot, extra }, "search dirs outside the repo's worktrees are scanned as their own roots"); + } + + scanDirs = [...worktrees, ...extra]; } const expanded = await expandTargets({ dirs: scanDirs, targets: args.targets }); From 42bb333074f2e3dc466032014f294e67adcfef33 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 Oct 2026 22:22:31 +0200 Subject: [PATCH 13/21] feat(macos): tools macos permissions local-network names the program behind each Local Network prompt, and the dialog text points to it --- src/macos/GenesisTools/Info.plist | 2 +- src/macos/README.md | 2 +- src/macos/commands/permissions/index.ts | 63 +++++++++- src/macos/lib/permissions/local-network.ts | 114 +++++++++++++++++- src/macos/lib/permissions/permissions.test.ts | 29 ++++- 5 files changed, 202 insertions(+), 8 deletions(-) diff --git a/src/macos/GenesisTools/Info.plist b/src/macos/GenesisTools/Info.plist index 9fc32bd0de..fbaba9a3b5 100644 --- a/src/macos/GenesisTools/Info.plist +++ b/src/macos/GenesisTools/Info.plist @@ -54,7 +54,7 @@ NSSystemAdministrationUsageDescription Full Disk Access lets tools macos mail, messages and voice-memos read the Mail, Messages and Voice Memos databases. Nothing else in GenesisTools needs it. NSLocalNetworkUsageDescription - A program you started through tools (a dev server, a language server, a Claude session) reaches a device on your network. Every tools command runs inside GenesisTools, so macOS asks in its name. After a rebuild, a program still running the old build asks once more. + A program you started through tools (a dev server, a language server, a Claude session) reaches a device on your network. Every tools command runs inside GenesisTools, so macOS asks in its name. After a rebuild, a program still running the old build asks once more. To see which program asked, run: tools macos permissions local-network CFBundleURLTypes diff --git a/src/macos/README.md b/src/macos/README.md index 9d797f83fb..4897a9f114 100644 --- a/src/macos/README.md +++ b/src/macos/README.md @@ -171,7 +171,7 @@ tools macos calendar list --from 2026-09-01 --to 2026-09-30 # first run prompt Full Disk Access and Accessibility have no system prompt. Only Mail, Messages and Voice Memos need Full Disk Access; when one of those commands hits the missing grant on a TTY, GenesisTools shows its own dialog that says which command needs it, opens the pane and selects the app in Finder (the "+" picker also lists GenesisTools under Applications). `tools macos permissions` only reports; `tools macos permissions open --pane full-disk-access` shows the dialog on demand. Files you name on the Desktop, in Documents or in Downloads get the per-folder system prompt instead, and allowing one folder is enough. The dialog is throttled to once per hour. Calendars, Reminders, Contacts, Automation and folders prompt on first use, with GenesisTools as the name in the dialog. The user TCC database is itself behind Full Disk Access, so `tools macos permissions` treats an unreadable database under GenesisTools.app as "Full Disk Access missing". -**Local Network.** macOS asks "Allow GenesisTools to find devices on local networks?" the first time any process under the launcher reaches a device on the LAN: a dev server, a language server inside a Claude session, a printer. GenesisTools itself does not scan the network; the prompt names it because every `tools` process runs under it, and the Info.plist text says so. macOS keeps the answer in nehelper's rule store (`/Library/Preferences/com.apple.networkextension.plist`), and an executable outside the installed bundle gets an answer of its own, keyed by its path. A rebuild moves the running bundle to `~/.genesis-tools/app/retired.noindex//`, so a process still running that old build asks once more on its first LAN access (2026-10-12 22:00: intelephense in a Claude session whose launcher ran the 17:55 build). `tools macos permissions` shows the installed app's answer and an "Old builds still running" section with each old build, its processes and whether it will ask. `tools macos permissions request local-network` shows the GenesisTools dialog first, then macOS's prompt; `request ` works for every grant the window lists. +**Local Network.** macOS asks "Allow GenesisTools to find devices on local networks?" the first time any process under the launcher reaches a device on the LAN: a dev server, a language server inside a Claude session, a printer. GenesisTools itself does not scan the network; the prompt names it because every `tools` process runs under it, and the Info.plist text says so. macOS keeps the answer in nehelper's rule store (`/Library/Preferences/com.apple.networkextension.plist`), and an executable outside the installed bundle gets an answer of its own, keyed by its path. A rebuild moves the running bundle to `~/.genesis-tools/app/retired.noindex//`, so a process still running that old build asks once more on its first LAN access (2026-10-12 22:00: intelephense in a Claude session whose launcher ran the 17:55 build). `tools macos permissions` shows the installed app's answer and an "Old builds still running" section with each old build, its processes and whether it will ask. `tools macos permissions request local-network` shows the GenesisTools dialog first, then macOS's prompt; `request ` works for every grant the window lists. The dialog cannot name the program that asked (its text is fixed); `tools macos permissions local-network [--since 2h]` reads nehelper's log line for each prompt and prints the responsible launcher pid, whether it runs an old build, and, while it still runs, its program, folder and children. ### Calendar: three grant levels, and the middle one is the trap diff --git a/src/macos/commands/permissions/index.ts b/src/macos/commands/permissions/index.ts index 094aa1a016..c42e6c2946 100644 --- a/src/macos/commands/permissions/index.ts +++ b/src/macos/commands/permissions/index.ts @@ -16,7 +16,13 @@ import { truncateDisplay } from "@genesiscz/utils/table"; import { Command } from "commander"; import { buildApp, describeSource } from "../../lib/permissions/app"; import { isPermissionKind, PERMISSION_KINDS, permissionStatusLabel } from "../../lib/permissions/kinds"; -import type { LocalNetworkAnswer, OldBuild } from "../../lib/permissions/local-network"; +import { + describePromptProcesses, + type LocalNetworkAnswer, + type OldBuild, + readLocalNetworkPrompts, + retiredAtOf, +} from "../../lib/permissions/local-network"; import { ALL_PART_IDS, APP_PARTS, @@ -346,6 +352,61 @@ export function registerPermissionsCommand(program: Command): void { } }); + permissions + .command("local-network") + .description('Which program made macOS ask "Allow GenesisTools to find devices on local networks?"') + .option("--since ", "How far back to look (30m, 2h, 1d)", "2h") + .option("--json", "Print the prompts as JSON") + .action((options: { since: string; json?: boolean }) => { + const prompts = readLocalNetworkPrompts(options.since); + const launcher = genesisAppLauncherPath(); + const live = describePromptProcesses( + launcher, + prompts.map((prompt) => prompt.pid) + ); + const rows = prompts.map((prompt) => ({ + ...prompt, + retiredAt: retiredAtOf(prompt.executable)?.toISOString() ?? null, + process: live.get(prompt.pid) ?? null, + })); + + if (options.json) { + out.result({ prompts: rows }); + return; + } + + if (rows.length === 0) { + ui.info(`No Local Network prompt for GenesisTools in the last ${options.since}.`); + return; + } + + for (const row of rows) { + const retired = retiredAtOf(row.executable); + const build = retired ? `old build, retired ${clock(retired)}` : "installed build"; + ui.raw(`${row.at} pid ${row.pid} ${build}`); + + if (!row.process) { + ui.raw(" the process has exited"); + continue; + } + + ui.raw(` runs ${truncateDisplay(row.process.command, 100)}`); + ui.raw(` started ${clock(row.process.started)}`); + + if (row.process.cwd) { + ui.raw(` in ${row.process.cwd}`); + } + + for (const child of row.process.children.slice(0, 3)) { + ui.raw(` child ${truncateDisplay(child, 100)}`); + } + } + + ui.raw( + " The pid is the launcher that macOS holds responsible: a program it started reached your network. A process keeps the build it started with, so after a rebuild an old build asks once more." + ); + }); + permissions .command("ui") .description("Open the GenesisTools window (permissions with request buttons, launchd services, settings)") diff --git a/src/macos/lib/permissions/local-network.ts b/src/macos/lib/permissions/local-network.ts index b0a5a5681b..0c948b2f49 100644 --- a/src/macos/lib/permissions/local-network.ts +++ b/src/macos/lib/permissions/local-network.ts @@ -3,6 +3,7 @@ import { basename, join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { GENESIS_APP_NAME, genesisAppDir } from "@genesiscz/utils/macos/genesis-app"; +import { readProcessCwd } from "@genesiscz/utils/process/cwd"; import { executableInode } from "@genesiscz/utils/process/process-files"; import { listProcesses } from "@genesiscz/utils/process/processes"; import { retiredRootFor } from "./app"; @@ -173,6 +174,112 @@ function retiredBinaries(): RetiredBinary[] { }); } +/** What a launcher command line runs: the face flags, or the program with its arguments. */ +function launcherProgram(launcherPath: string, command: string): string { + const rest = command.slice(launcherPath.length).trim(); + const [program, ...args] = rest.split(" "); + return !program ? "(window)" : program.startsWith("-") ? rest : [basename(program), ...args].join(" "); +} + +/** One Local Network prompt macOS raised for GenesisTools, from nehelper's log. */ +export interface LocalNetworkPrompt { + /** local time as the log prints it, `2026-10-21 22:16:06` */ + at: string; + /** the responsible process: the launcher whose tree reached the network */ + pid: number; + /** the GenesisTools binary it runs; a path under `retired.noindex/` is an old build */ + executable: string; +} + +const PROMPT_LINE = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.\d+ .*Found new path (.+) for PID (\d+), allowing a new prompt/; + +/** + * The dialog itself cannot name the program: its text is the fixed `NSLocalNetworkUsageDescription`. nehelper logs + * the responsible pid and its executable one line before it prompts (2026-10-21 22:16:06: pid 97417, a Claude session + * launcher on the build a rebuild had retired twelve minutes before). + */ +export function parseLocalNetworkPrompts(logText: string): LocalNetworkPrompt[] { + return logText.split("\n").flatMap((line): LocalNetworkPrompt[] => { + const match = PROMPT_LINE.exec(line); + return match ? [{ at: match[1], pid: Number(match[3]), executable: match[2] }] : []; + }); +} + +/** Prompts in the last `since` (a `log show --last` value: `30m`, `2h`, `1d`); empty when `log show` fails (logged). */ +export function readLocalNetworkPrompts(since: string): LocalNetworkPrompt[] { + const proc = Bun.spawnSync( + [ + "/usr/bin/log", + "show", + "--last", + since, + "--style", + "compact", + "--info", + "--predicate", + 'process == "nehelper" AND eventMessage CONTAINS "allowing a new prompt"', + ], + { stdout: "pipe", stderr: "pipe", timeout: 120_000 } + ); + + if (proc.exitCode !== 0) { + logger.warn( + { since, exitCode: proc.exitCode, stderr: new TextDecoder().decode(proc.stderr).slice(0, 300) }, + "local network: log show failed" + ); + return []; + } + + return parseLocalNetworkPrompts(new TextDecoder().decode(proc.stdout)); +} + +/** What the prompting process was, while it still runs. */ +export interface PromptProcess { + started: Date; + /** the program the launcher runs (`gt-cc …` is a Claude session) */ + command: string; + cwd: string | null; + children: string[]; +} + +/** Every `tools` program carries the same `--preload ` pairs, which hide what it runs. */ +export function withoutPreloads(command: string): string { + return command.replace(/ --preload \S+/g, ""); +} + +/** The live process behind each prompt pid; a pid that has exited is absent. */ +export function describePromptProcesses(launcherPath: string, pids: number[]): Map { + const wanted = new Set(pids); + const entries = listProcesses(); + const described = new Map(); + for (const entry of entries) { + if (!wanted.has(entry.pid) || entry.startMs === null || !entry.command) { + continue; + } + + const command = withoutPreloads( + entry.command.startsWith(launcherPath) ? launcherProgram(launcherPath, entry.command) : entry.command + ); + const children = entries + .filter((child) => child.ppid === entry.pid && child.command) + .map((child) => withoutPreloads(child.command ?? child.name)); + described.set(entry.pid, { + started: new Date(entry.startMs), + command, + cwd: readProcessCwd(entry.pid), + children, + }); + } + + return described; +} + +/** When the build behind `executable` was retired, or null for the installed app. */ +export function retiredAtOf(executable: string): Date | null { + const match = /retired(?:\.noindex)?\/(\d+)\//.exec(executable); + return match ? new Date(Number(match[1])) : null; +} + /** * Old builds a running process still executes: one `ps`, then one `lsof` over the GenesisTools processes only. * Empty when nothing runs an old build, or when either listing fails (logged). @@ -192,10 +299,9 @@ export function oldBuildsInUse(launcherPath: string, rules: LocalNetworkRules | ) { return []; } - const rest = entry.command.slice(launcherPath.length).trim(); - const [program, ...args] = rest.split(" "); - const command = !program ? "(window)" : program.startsWith("-") ? rest : [basename(program), ...args].join(" "); - return [{ pid: entry.pid, started: new Date(entry.startMs), command }]; + return [ + { pid: entry.pid, started: new Date(entry.startMs), command: launcherProgram(launcherPath, entry.command) }, + ]; }); const inodesByPid = new Map>(); for (const entry of processes) { diff --git a/src/macos/lib/permissions/permissions.test.ts b/src/macos/lib/permissions/permissions.test.ts index 493dfd5392..8baf5cf462 100644 --- a/src/macos/lib/permissions/permissions.test.ts +++ b/src/macos/lib/permissions/permissions.test.ts @@ -18,7 +18,15 @@ import { timedSteps, } from "./app"; import { isPermissionKind, PERMISSION_KINDS, permissionStatusLabel } from "./kinds"; -import { type AppProcess, groupOldBuilds, localNetworkAnswer, parseLocalNetworkRules } from "./local-network"; +import { + type AppProcess, + groupOldBuilds, + localNetworkAnswer, + parseLocalNetworkPrompts, + parseLocalNetworkRules, + retiredAtOf, + withoutPreloads, +} from "./local-network"; import { ALL_PART_IDS, bundleStepsFor, @@ -683,6 +691,25 @@ describe("Local Network answers", () => { expect(parseLocalNetworkRules(`{"rules":[]}`)).toBeNull(); expect(localNetworkAnswer(null)).toBe("unknown"); }); + + it("reads the prompting pid and build from nehelper's log", () => { + const log = [ + "Timestamp Ty Process[PID:TID]", + "2026-10-21 22:16:06.926 I nehelper[419:2a66e2b] [com.apple.networkextension:] Found new path /Users/x/.genesis-tools/app/retired.noindex/1792613086315/GenesisTools.app/Contents/MacOS/GenesisTools for PID 97417, allowing a new prompt", + "2026-10-21 22:16:06.930 I nehelper[419:2a66e2b] [com.apple.networkextension:] Local network preference not yet set, prompting for GenesisTools (com.genesiscz.genesistools)", + ].join("\n"); + const [prompt, ...rest] = parseLocalNetworkPrompts(log); + expect(rest).toEqual([]); + expect(prompt).toEqual({ + at: "2026-10-21 22:16:06", + pid: 97417, + executable: + "/Users/x/.genesis-tools/app/retired.noindex/1792613086315/GenesisTools.app/Contents/MacOS/GenesisTools", + }); + expect(retiredAtOf(prompt.executable)?.getTime()).toBe(1792613086315); + expect(retiredAtOf("/Users/x/Applications/GenesisTools.app/Contents/MacOS/GenesisTools")).toBeNull(); + expect(withoutPreloads("gt-cc --preload /a.ts --preload /b.ts --resume abc")).toBe("gt-cc --resume abc"); + }); }); describe("old builds still running", () => { From b2f0dcb0f292c6fbb5aa197e8bc17f7aec557c68 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 Oct 2026 23:55:22 +0200 Subject: [PATCH 14/21] fix(macos): the vault key release inspects again, up to three times, when a process fd table could not be read or the process list did not settle, since those are moments rather than holders --- .../Sources/Host/VaultRecipient.swift | 25 +++++++++++++++++-- .../GenesisTools/Tests/VaultUnlockTests.swift | 20 +++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift index abb1b4e99d..2bfd1acb55 100644 --- a/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift +++ b/src/macos/GenesisTools/Sources/Host/VaultRecipient.swift @@ -93,7 +93,7 @@ enum VaultRecipient { detail += ", \(String(cString: strerror(code)))" if let executable { detail += ", \(executable)" } } - return "the vault recipient could not be verified because a process fd table was unreadable (\(detail))" + return "\(unreadableTable) (\(detail))" } let holders = Set(processes.filter { process in process.endpoints?.contains(where: { $0.isPeer(of: stdout) }) == true @@ -264,13 +264,34 @@ enum VaultRecipient { return nil } + static let unreadableTable = "the vault recipient could not be verified because a process fd table was unreadable" + static let unsettledList = "the user's process list changed during vault recipient inspection" + + /// Runs the whole inspection again, up to `attempts` times, while it fails only because a table could not be read + /// or the list did not settle. Those are moments, not holders: 7 releases in the logs of 2026-10-20 and 21 met one, + /// and each failed a whole usage poll, and a probe of all 1,726 user processes later found only login and ps, + /// which the check skips. Every attempt is the full check, so a release still needs one that passes completely. + static func retryingTransient(attempts: Int, pause: () -> Void, _ inspect: () -> String?) -> String? { + var reason = inspect() + for _ in 1.. String? { + retryingTransient(attempts: 3, pause: { usleep(50_000) }) { inspectRelease(caller: caller) } + } + + private static func inspectRelease(caller: Identity?) -> String? { do { let output = try stdoutEndpoint() guard output.connected else { return "vault stdout must be a connected pipe or Unix-domain socket" } guard let tables = try settledTables(rounds: 16, list: userPids, inspect: { processFDs($0, output: output) }, exited: hasExited) else { - return "the user's process list changed during vault recipient inspection" + return unsettledList } guard try stdoutEndpoint() == output else { return "vault stdout changed during recipient inspection" } return rejection(stdout: output, caller: caller, current: caller.flatMap { identity($0.pid) }, processes: tables) diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index bdd73e12ef..317633f7bd 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -136,6 +136,26 @@ final class VaultUnlockTests: XCTestCase { XCTAssertNil(neverSettles, "a list that keeps growing past the rounds still denies") } + func testVaultRecipientRetriesOnlyTransientRefusals() { + var answers: [String?] = ["\(VaultRecipient.unreadableTable) (pid 7)", VaultRecipient.unsettledList, nil] + var pauses = 0 + XCTAssertNil(VaultRecipient.retryingTransient(attempts: 3, pause: { pauses += 1 }) { answers.removeFirst() }, + "a table that reads on the next try releases") + XCTAssertEqual(pauses, 2) + + var holder: [String?] = ["another process holds the output", nil] + XCTAssertEqual(VaultRecipient.retryingTransient(attempts: 3, pause: {}) { holder.removeFirst() }, + "another process holds the output", "a holder is never retried") + + var stuck = 0 + let reason = VaultRecipient.retryingTransient(attempts: 3, pause: {}) { () -> String? in + stuck += 1 + return VaultRecipient.unsettledList + } + XCTAssertEqual(reason, VaultRecipient.unsettledList) + XCTAssertEqual(stuck, 3, "the attempts are a ceiling") + } + func testVaultRecipientSkipsOnlyExitedOrPermissionDeniedSystemProcesses() { let identity = fixtureIdentity() let output = VaultRecipient.Endpoint.pipe(handle: 10, peer: 20) From 587960479b5195fdced27c65beb11339c324c483 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 16:33:03 +0200 Subject: [PATCH 15/21] fix(macos): the app declares LSUIElement, so a link or a reopen no longer turns a face regular and the Dock no longer keeps stale GenesisTools tiles after the face turns itself back --- src/macos/GenesisTools/Info.plist | 4 ++++ src/macos/GenesisTools/Sources/Host/App/DockTile.swift | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/macos/GenesisTools/Info.plist b/src/macos/GenesisTools/Info.plist index fbaba9a3b5..13b0898ad0 100644 --- a/src/macos/GenesisTools/Info.plist +++ b/src/macos/GenesisTools/Info.plist @@ -20,6 +20,10 @@ 1 LSMinimumSystemVersion 13.0 + + LSUIElement + NSHighResolutionCapable diff --git a/src/macos/GenesisTools/Sources/Host/App/DockTile.swift b/src/macos/GenesisTools/Sources/Host/App/DockTile.swift index 3c35302c4b..6891987f6e 100644 --- a/src/macos/GenesisTools/Sources/Host/App/DockTile.swift +++ b/src/macos/GenesisTools/Sources/Host/App/DockTile.swift @@ -23,6 +23,11 @@ import Foundation /// `applicationShouldHandleReopen` runs (measured with a probe bundle on macOS 26.3, 2026-10-08), and /// the link relay was found `.regular` with no reopen in its journal. So every reopen handler calls /// `enforce()`, and each face checks again on activation changes and on a slow timer. +/// +/// Since 2026-10-22 the bundle declares `LSUIElement`, which stops those promotions (a probe bundle +/// with the key stayed `UIElement` through `open -a` and still got the reopen). The Dock kept a stale +/// tile after some of the flips back: four tiles with one regular process. The checks above stay as +/// a guard. @MainActor package enum DockTile { package enum Role: String { From f5aa238da3f4414c1689a0af0c2102b450230e0e Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 16:40:27 +0200 Subject: [PATCH 16/21] feat(macos): the link relay follows nehelper's prompt line and shows a panel beside the Local Network dialog that names the program, its folder and its build --- src/macos/GenesisTools/Info.plist | 2 +- .../GenesisTools/Sources/Host/HostMain.swift | 4 + .../GenesisTools/Sources/Host/LinkRelay.swift | 2 + .../Sources/Host/LocalNetworkWatch.swift | 406 ++++++++++++++++++ .../Tests/LocalNetworkWatchTests.swift | 17 + src/macos/README.md | 2 +- 6 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift create mode 100644 src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift diff --git a/src/macos/GenesisTools/Info.plist b/src/macos/GenesisTools/Info.plist index 13b0898ad0..ceab8ba559 100644 --- a/src/macos/GenesisTools/Info.plist +++ b/src/macos/GenesisTools/Info.plist @@ -58,7 +58,7 @@ NSSystemAdministrationUsageDescription Full Disk Access lets tools macos mail, messages and voice-memos read the Mail, Messages and Voice Memos databases. Nothing else in GenesisTools needs it. NSLocalNetworkUsageDescription - A program you started through tools (a dev server, a language server, a Claude session) reaches a device on your network. Every tools command runs inside GenesisTools, so macOS asks in its name. After a rebuild, a program still running the old build asks once more. To see which program asked, run: tools macos permissions local-network + A program you started through tools (a dev server, a language server, a Claude session) reaches a device on your network. Every tools command runs inside GenesisTools, so macOS asks in its name. After a rebuild, a program still running the old build asks once more. A GenesisTools panel beside this dialog names the program; tools macos permissions local-network lists earlier ones. CFBundleURLTypes diff --git a/src/macos/GenesisTools/Sources/Host/HostMain.swift b/src/macos/GenesisTools/Sources/Host/HostMain.swift index b27b188660..f7135f058c 100644 --- a/src/macos/GenesisTools/Sources/Host/HostMain.swift +++ b/src/macos/GenesisTools/Sources/Host/HostMain.swift @@ -38,6 +38,10 @@ package func runHost() -> Never { runLocalNetworkRules() } + if firstArgument == "--local-network-panel" { + runLocalNetworkPanel(Array(arguments.dropFirst())) + } + // A face of a part this build does not link says so first, before the staging gate would only say "staging". if AppParts.partOfFace[firstArgument] != nil, !AppParts.has(face: firstArgument) { AppParts.run(face: firstArgument, Array(arguments.dropFirst())) diff --git a/src/macos/GenesisTools/Sources/Host/LinkRelay.swift b/src/macos/GenesisTools/Sources/Host/LinkRelay.swift index e15d4ebff3..faebc96187 100644 --- a/src/macos/GenesisTools/Sources/Host/LinkRelay.swift +++ b/src/macos/GenesisTools/Sources/Host/LinkRelay.swift @@ -156,6 +156,8 @@ enum LinkRelay { HangWatch.start() // The relay was found regular with a Dock tile of its own (2026-10-08): keep it an accessory. DockTile.keep(.windowless) + // One process per machine that always runs: it names the program behind each Local Network dialog. + LocalNetworkWatch.start() } startHeartbeat() note("running as pid \(getpid()), version \(bundleVersion()), parent pid \(getppid())") diff --git a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift new file mode 100644 index 0000000000..05bdaad806 --- /dev/null +++ b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift @@ -0,0 +1,406 @@ +import AppKit +import Foundation +import GenesisKitBase +import SwiftUI + +/// One "Allow GenesisTools to find devices on local networks?" prompt, as `tools macos permissions local-network +/// --json` describes it. The system dialog cannot say which program asked: its text is the fixed +/// `NSLocalNetworkUsageDescription`, and every `tools` program runs inside the same launcher. +struct LocalNetworkPrompt: Decodable, Equatable, Identifiable { + struct Program: Decodable, Equatable { + let started: String + let command: String + let cwd: String? + let children: [String] + } + + let at: String + let pid: Int + let executable: String + let retiredAt: String? + let process: Program? + + var id: String { "\(pid) \(at)" } +} + +struct LocalNetworkPromptList: Decodable { + let prompts: [LocalNetworkPrompt] +} + +/// The link relay follows nehelper's log, which names the responsible pid one line before the dialog opens, and shows +/// a panel beside the dialog that says which program asked. The panel closes when the dialog is gone. +@MainActor +enum LocalNetworkWatch { + private static let predicate = #"process == "nehelper" AND eventMessage CONTAINS "allowing a new prompt""# + private static let restartSeconds = 15.0 + private static var stream: Process? + private static var panel: LocalNetworkPanel? + private nonisolated(unsafe) static var streamPid: pid_t = 0 + private static var reapsOnExit = false + /// The `--local-network-panel` face ends with its panel. + static var onPanelClosed: (() -> Void)? + + /// The pid in nehelper's `Found new path for PID , allowing a new prompt` line. + nonisolated static func promptPid(inLogLine line: String) -> Int? { + guard let marker = line.range(of: ", allowing a new prompt"), + let pidStart = line.range(of: " for PID ", options: .backwards, range: line.startIndex.. 0 { kill(LocalNetworkWatch.streamPid, SIGTERM) } } + } + HubPerf.log("localNetwork: following nehelper prompts (log stream pid \(process.processIdentifier))") + } + + /// Asks `tools` who the pid is; the prompt line comes before the dialog, so the program usually still runs. + private static func announce(pid: Int) async { + HubPerf.log("localNetwork: prompt for pid \(pid)") + var prompt: LocalNetworkPrompt? + do { + let result = try await ToolsBridge(binaryPath: ToolsBridge.defaultBinaryPath()).run( + subcommand: "macos", args: ["permissions", "local-network", "--since", "5m", "--json"], timeoutSeconds: 60) + let list = try JSONDecoder().decode(LocalNetworkPromptList.self, from: Data(result.stdout.utf8)) + prompt = list.prompts.last { $0.pid == pid } + } catch { + HubPerf.log("localNetwork: could not describe pid \(pid): \(error)") + } + show(prompt ?? LocalNetworkPrompt(at: "", pid: pid, executable: "", retiredAt: nil, process: nil)) + } + + static func show(_ prompt: LocalNetworkPrompt) { + if let panel, panel.isVisible { + panel.add(prompt) + return + } + let created = LocalNetworkPanel(prompts: [prompt]) { + panel = nil + onPanelClosed?() + } + panel = created + created.present() + } +} + +/// Splits a byte stream into lines on the reading thread. +private final class LineSplitter: @unchecked Sendable { + private var buffer = Data() + private let lock = NSLock() + private let onLine: (String) -> Void + + init(onLine: @escaping (String) -> Void) { + self.onLine = onLine + } + + func feed(_ data: Data) { + lock.lock() + buffer.append(data) + var lines: [String] = [] + while let newline = buffer.firstIndex(of: 0x0A) { + lines.append(String(decoding: buffer[buffer.startIndex.. Void + private var watch: Timer? + private var dialogSeen = false + private var missingTicks = 0 + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + init(prompts: [LocalNetworkPrompt], closed: @escaping () -> Void) { + model = LocalNetworkPanelModel(prompts: prompts) + self.closed = closed + super.init(contentRect: NSRect(x: 0, y: 0, width: Self.width, height: 200), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + isFloatingPanel = true + level = .statusBar + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + isOpaque = false + backgroundColor = .clear + hasShadow = true + hidesOnDeactivate = false + let model = model + let host = NSHostingView(rootView: LocalNetworkPanelView(model: model) { [weak self] in self?.dismiss() }) + contentView = host + } + + func add(_ prompt: LocalNetworkPrompt) { + guard !model.prompts.contains(prompt) else { return } + model.prompts.append(prompt) + place() + } + + func present() { + place() + orderFrontRegardless() + watch = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.followDialog() } + } + } + + func dismiss() { + watch?.invalidate() + watch = nil + orderOut(nil) + closed() + } + + /// Right of the dialog, or under it when the right side has no room; top centre of the screen without one. + private func place() { + if let fitting = contentView?.fittingSize { + setContentSize(NSSize(width: Self.width, height: fitting.height)) + } + let screen = NSScreen.main ?? NSScreen.screens.first + let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + guard let dialog = Self.dialogFrame() else { + setFrameOrigin(NSPoint(x: visible.midX - frame.width / 2, y: visible.maxY - frame.height - 80)) + return + } + let right = NSPoint(x: dialog.maxX + 14, y: dialog.maxY - frame.height) + if right.x + frame.width <= visible.maxX { + setFrameOrigin(NSPoint(x: right.x, y: max(visible.minY, right.y))) + } else { + setFrameOrigin(NSPoint(x: dialog.midX - frame.width / 2, y: max(visible.minY, dialog.minY - frame.height - 14))) + } + } + + private func followDialog() { + if Self.dialogFrame() != nil { + dialogSeen = true + missingTicks = 0 + place() + return + } + missingTicks += 1 + // Gone after it was seen: answered. Never seen: the dialog closed before the panel came, or it was elsewhere. + if (dialogSeen && missingTicks >= 2) || (!dialogSeen && missingTicks >= 20) { + dismiss() + } + } + + /// The `UserNotificationCenter` window, the process that draws the Local Network dialog (unified log, 2026-10-22 + /// 16:33:02), in Cocoa screen coordinates. + static func dialogFrame() -> NSRect? { + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] + guard let info = list.first(where: { ($0[kCGWindowOwnerName as String] as? String) == "UserNotificationCenter" }), + let bounds = info[kCGWindowBounds as String] as? NSDictionary, + let rect = CGRect(dictionaryRepresentation: bounds), rect.width > 100, + let primary = NSScreen.screens.first + else { return nil } + return NSRect(x: rect.minX, y: primary.frame.maxY - rect.maxY, width: rect.width, height: rect.height) + } +} + +struct LocalNetworkPanelView: View { + @ObservedObject var model: LocalNetworkPanelModel + let close: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "network") + .foregroundStyle(.orange) + Text(model.prompts.count == 1 ? "This program asks for local network access" + : "\(Plural.count(model.prompts.count, "program")) ask for local network access") + .font(.system(size: 13, weight: .semibold)) + Spacer() + Button(action: close) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + // The host links only GenesisKitBase, which has no IconButton or instantTooltip. + .help("Close") + } + ForEach(model.prompts) { prompt in + LocalNetworkPromptRow(prompt: prompt) + } + Text("Each build asks once. Allow lets this build reach devices on your network; Don't Allow blocks only this build.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(width: LocalNetworkPanel.width, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.97)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.12)) + ) + } +} + +struct LocalNetworkPromptRow: View { + let prompt: LocalNetworkPrompt + + private var build: String { + guard let retired = prompt.retiredAt.flatMap(Self.parse) else { return "the installed build" } + return "an old build, retired at \(Self.clock.string(from: retired)) by a rebuild" + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(verbatim: prompt.process.map { Self.short($0.command) } ?? "pid \(prompt.pid) (it has already exited)") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .lineLimit(3) + .textSelection(.enabled) + if let cwd = prompt.process?.cwd { + label("folder", cwd) + } + if let started = prompt.process.flatMap({ Self.parse($0.started) }) { + label("started", Self.clock.string(from: started)) + } + label("runs on", build) + label("pid", "\(prompt.pid)") + Button(action: copy) { + Label("Copy details", systemImage: "doc.on.doc") + .font(.system(size: 11)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.05))) + } + + private func label(_ name: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(name) + .foregroundStyle(.secondary) + .frame(width: 52, alignment: .leading) + Text(verbatim: value) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + .font(.system(size: 11)) + } + + private func copy() { + var lines = ["Local network prompt at \(prompt.at) for pid \(prompt.pid)", "executable: \(prompt.executable)"] + if let process = prompt.process { + lines += ["command: \(process.command)", "folder: \(process.cwd ?? "?")", "started: \(process.started)"] + lines += process.children.map { "child: \($0)" } + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(lines.joined(separator: "\n"), forType: .string) + } + + /// `gt-cc /…/GenesisTools/src/cc/index.ts run foltyn` reads as `cc run foltyn`. + static func short(_ command: String) -> String { + let words = command.split(separator: " ").map(String.init) + guard let first = words.first, first.hasPrefix("gt-") || first.hasSuffix("/bun") else { return command } + if let entry = words.firstIndex(where: { $0.contains("/src/") && ($0.hasSuffix("/index.ts") || $0.hasSuffix("/index.tsx")) }) { + let tool = URL(fileURLWithPath: words[entry]).deletingLastPathComponent().lastPathComponent + return ([tool] + words[(entry + 1)...]).joined(separator: " ") + } + return command + } + + private static let clock: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter + }() + + private static func parse(_ iso: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) + } +} + +/// `GenesisTools --local-network-panel --payload [--snapshot ]`: the panel for a saved +/// `tools macos permissions local-network --json`, on screen or rendered to a PNG without a window. +func runLocalNetworkPanel(_ args: [String]) -> Never { + let value = { (flag: String) in args.firstIndex(of: flag).flatMap { args.indices.contains($0 + 1) ? args[$0 + 1] : nil } } + guard let payload = value("--payload"), + let list = try? JSONDecoder().decode(LocalNetworkPromptList.self, from: Data(payload.utf8)), !list.prompts.isEmpty + else { + FileHandle.standardError.write(Data("--local-network-panel needs --payload with the JSON of tools macos permissions local-network --json\n".utf8)) + exit(64) + } + MainActor.assumeIsolated { + let app = NSApplication.shared + if let snapshot = value("--snapshot") { + app.setActivationPolicy(.prohibited) + let renderer = ImageRenderer(content: LocalNetworkPanelView(model: LocalNetworkPanelModel(prompts: list.prompts)) {} + .environment(\.colorScheme, .dark)) + renderer.scale = 2 + guard let image = renderer.nsImage, let tiff = image.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { exit(1) } + exit((try? png.write(to: URL(fileURLWithPath: snapshot))) == nil ? 1 : 0) + } + app.setActivationPolicy(.accessory) + LocalNetworkWatch.onPanelClosed = { exit(0) } + for prompt in list.prompts { + LocalNetworkWatch.show(prompt) + } + app.run() + } + exit(0) +} diff --git a/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift new file mode 100644 index 0000000000..e23cb8076f --- /dev/null +++ b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift @@ -0,0 +1,17 @@ +@testable import GenesisToolsHost +import XCTest + +final class LocalNetworkWatchTests: XCTestCase { + func testPromptPidReadsNehelpersLine() { + let line = "2026-10-22 16:33:02.480 I nehelper[402:377e9a1] [com.apple.networkextension:] Found new path " + + "/Users/me/.genesis-tools/app/retired.noindex/1792679551787/GenesisTools.app/Contents/MacOS/GenesisTools " + + "for PID 49784, allowing a new prompt" + XCTAssertEqual(LocalNetworkWatch.promptPid(inLogLine: line), 49784) + XCTAssertNil(LocalNetworkWatch.promptPid(inLogLine: "Filtering the log data using \"process == nehelper\"")) + } + + func testShortCommandNamesTheTool() { + XCTAssertEqual(LocalNetworkPromptRow.short("gt-cc /Users/me/GenesisTools/src/cc/index.ts run work --resume x"), "cc run work --resume x") + XCTAssertEqual(LocalNetworkPromptRow.short("/usr/bin/python3 server.py"), "/usr/bin/python3 server.py") + } +} diff --git a/src/macos/README.md b/src/macos/README.md index 4897a9f114..df94edf3c5 100644 --- a/src/macos/README.md +++ b/src/macos/README.md @@ -171,7 +171,7 @@ tools macos calendar list --from 2026-09-01 --to 2026-09-30 # first run prompt Full Disk Access and Accessibility have no system prompt. Only Mail, Messages and Voice Memos need Full Disk Access; when one of those commands hits the missing grant on a TTY, GenesisTools shows its own dialog that says which command needs it, opens the pane and selects the app in Finder (the "+" picker also lists GenesisTools under Applications). `tools macos permissions` only reports; `tools macos permissions open --pane full-disk-access` shows the dialog on demand. Files you name on the Desktop, in Documents or in Downloads get the per-folder system prompt instead, and allowing one folder is enough. The dialog is throttled to once per hour. Calendars, Reminders, Contacts, Automation and folders prompt on first use, with GenesisTools as the name in the dialog. The user TCC database is itself behind Full Disk Access, so `tools macos permissions` treats an unreadable database under GenesisTools.app as "Full Disk Access missing". -**Local Network.** macOS asks "Allow GenesisTools to find devices on local networks?" the first time any process under the launcher reaches a device on the LAN: a dev server, a language server inside a Claude session, a printer. GenesisTools itself does not scan the network; the prompt names it because every `tools` process runs under it, and the Info.plist text says so. macOS keeps the answer in nehelper's rule store (`/Library/Preferences/com.apple.networkextension.plist`), and an executable outside the installed bundle gets an answer of its own, keyed by its path. A rebuild moves the running bundle to `~/.genesis-tools/app/retired.noindex//`, so a process still running that old build asks once more on its first LAN access (2026-10-12 22:00: intelephense in a Claude session whose launcher ran the 17:55 build). `tools macos permissions` shows the installed app's answer and an "Old builds still running" section with each old build, its processes and whether it will ask. `tools macos permissions request local-network` shows the GenesisTools dialog first, then macOS's prompt; `request ` works for every grant the window lists. The dialog cannot name the program that asked (its text is fixed); `tools macos permissions local-network [--since 2h]` reads nehelper's log line for each prompt and prints the responsible launcher pid, whether it runs an old build, and, while it still runs, its program, folder and children. +**Local Network.** macOS asks "Allow GenesisTools to find devices on local networks?" the first time any process under the launcher reaches a device on the LAN: a dev server, a language server inside a Claude session, a printer. GenesisTools itself does not scan the network; the prompt names it because every `tools` process runs under it, and the Info.plist text says so. macOS keeps the answer in nehelper's rule store (`/Library/Preferences/com.apple.networkextension.plist`), and an executable outside the installed bundle gets an answer of its own, keyed by its path. A rebuild moves the running bundle to `~/.genesis-tools/app/retired.noindex//`, so a process still running that old build asks once more on its first LAN access (2026-10-12 22:00: intelephense in a Claude session whose launcher ran the 17:55 build). `tools macos permissions` shows the installed app's answer and an "Old builds still running" section with each old build, its processes and whether it will ask. `tools macos permissions request local-network` shows the GenesisTools dialog first, then macOS's prompt; `request ` works for every grant the window lists. The dialog cannot name the program that asked (its text is fixed); `tools macos permissions local-network [--since 2h]` reads nehelper's log line for each prompt and prints the responsible launcher pid, whether it runs an old build, and, while it still runs, its program, folder and children. The link relay follows that log line live (`log stream`) and shows a panel beside the dialog with the same facts; it closes when the dialog does (`GenesisTools --local-network-panel --payload --snapshot ` renders it). ### Calendar: three grant levels, and the middle one is the trap From c03dcafad367efb2fdc3eb2dce8d7eba69a3655a Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 21:38:17 +0200 Subject: [PATCH 17/21] fix(GenesisKit, GenesisHub): #556 followup - an image overwritten while its diff is open loads again, and the Local Network panel describes only GenesisTools prompts --- .../Sources/GenesisKit/Diff/Model/DiffModel.swift | 14 +++++++++++++- .../Diff/Render/PierreWebDiffRenderer.swift | 9 +++++++-- .../Diff/Source/GitWorkingTreeSource.swift | 4 +++- .../GenesisKitTests/Diff/GitFileDiffTests.swift | 8 ++++++++ .../Sources/Host/LocalNetworkWatch.swift | 8 ++++++-- .../Tests/LocalNetworkWatchTests.swift | 3 +++ src/macos/lib/permissions/local-network.ts | 12 +++++++++++- src/macos/lib/permissions/permissions.test.ts | 1 + 8 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 81e7d75071..7011d98a37 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -56,10 +56,22 @@ public struct DiffImage: Codable, Hashable { public var old: Source? public var new: Source? + /// What the working-tree file was when the diff was read (modification time and size). A source names a place, + /// not its bytes, so an image overwritten while its diff stays open is a new `DiffImage` only through this. + public var revision: String? - public init(old: Source?, new: Source?) { + public init(old: Source?, new: Source?, revision: String? = nil) { self.old = old self.new = new + self.revision = revision + } + + /// `revision` for a file on disk: its modification time and size, nil when it cannot be read. + public static func revision(ofFile path: String) -> String? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modified = attributes[.modificationDate] as? Date, + let size = attributes[.size] as? NSNumber else { return nil } + return "\(modified.timeIntervalSince1970)-\(size)" } /// The binary formats WebKit draws. SVG is text: it has code to show, so it is asked for by `isSVG`. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index f540e3d250..a5806e385c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -372,7 +372,10 @@ private struct BridgeFile: Encodable { hasher.combine(newContents) hasher.combine(file.image) key = String(hasher.finalize(), radix: 36) - image = file.image.map { BridgeImage(old: $0.old.map(DiffImageRegistry.url), new: $0.new.map(DiffImageRegistry.url)) } + image = file.image.map { image in + BridgeImage(old: image.old.map { DiffImageRegistry.url($0, revision: image.revision) }, + new: image.new.map { DiffImageRegistry.url($0, revision: image.revision) }) + } } } @@ -518,9 +521,11 @@ enum DiffImageRegistry { private static let lock = NSLock() private static var sources: [String: DiffImage.Source] = [:] - static func url(_ source: DiffImage.Source) -> String { + /// The page caches an image by its URL, so the URL carries the file's revision: an overwritten image loads again. + static func url(_ source: DiffImage.Source, revision: String? = nil) -> String { var hasher = Hasher() hasher.combine(source) + hasher.combine(revision) let token = String(UInt(bitPattern: hasher.finalize()), radix: 36) lock.withLock { sources[token] = source } return "\(PierreWebDiffRenderer.scheme)://viewer\(prefix)\(token)" diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index 88a8152846..da804efdfa 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -342,10 +342,12 @@ public struct GitWorkingTreeSource { } if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path) { + let working = repo.appendingPathComponent(entry.path).path file.image = DiffImage( old: entry.status == .added ? nil : .blob(repo: repo.path, object: "\(plan.old):\(entry.oldPath ?? entry.path)"), new: entry.status == .deleted ? nil - : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(repo.appendingPathComponent(entry.path).path) + : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(working), + revision: plan.new == nil ? DiffImage.revision(ofFile: working) : nil ) } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 1e8596b43c..78253669b4 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -49,6 +49,14 @@ final class GitFileDiffTests: XCTestCase { XCTAssertTrue(url.path.hasPrefix(DiffImageRegistry.prefix)) XCTAssertEqual(DiffImageRegistry.source(String(url.path.dropFirst(DiffImageRegistry.prefix.count))), changed.old) XCTAssertEqual(DiffImageRegistry.mimeType(for: "a/B.PNG"), "image/png") + + // Overwritten while its diff stays open: the image is a new value with a new URL, so the page loads it again. + try Data([0x89, 0x50, 0x4E, 0x47, 9, 9, 9, 9, 9]).write(to: repo.appendingPathComponent("logo.png")) + let reread = try XCTUnwrap(GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files.first { $0.path == "logo.png" }?.image) + XCTAssertEqual(reread.new, changed.new, "the same place") + XCTAssertNotEqual(reread, changed, "but another revision") + XCTAssertNotEqual(DiffImageRegistry.url(try XCTUnwrap(reread.new), revision: reread.revision), + DiffImageRegistry.url(try XCTUnwrap(changed.new), revision: changed.revision)) } func testUntrackedColorContrastsWithBothPageSchemes() throws { diff --git a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift index 05bdaad806..3124041442 100644 --- a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift +++ b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift @@ -40,10 +40,14 @@ enum LocalNetworkWatch { /// The `--local-network-panel` face ends with its panel. static var onPanelClosed: (() -> Void)? - /// The pid in nehelper's `Found new path for PID , allowing a new prompt` line. + /// The pid in nehelper's `Found new path for PID , allowing a new prompt` line, when the + /// executable is a GenesisTools launcher (installed or a retired build). nehelper logs every app's prompts, and + /// another app's dialog names that app itself. nonisolated static func promptPid(inLogLine line: String) -> Int? { guard let marker = line.range(of: ", allowing a new prompt"), - let pidStart = line.range(of: " for PID ", options: .backwards, range: line.startIndex.. { const match = PROMPT_LINE.exec(line); - return match ? [{ at: match[1], pid: Number(match[3]), executable: match[2] }] : []; + return match && isGenesisToolsBinary(match[2]) + ? [{ at: match[1], pid: Number(match[3]), executable: match[2] }] + : []; }); } +/** + * The launcher of the installed app or of a retired build. nehelper logs every app's prompt, and another app's is + * not ours to describe (its dialog names that app). + */ +export function isGenesisToolsBinary(executable: string): boolean { + return executable.endsWith("/GenesisTools.app/Contents/MacOS/GenesisTools"); +} + /** Prompts in the last `since` (a `log show --last` value: `30m`, `2h`, `1d`); empty when `log show` fails (logged). */ export function readLocalNetworkPrompts(since: string): LocalNetworkPrompt[] { const proc = Bun.spawnSync( diff --git a/src/macos/lib/permissions/permissions.test.ts b/src/macos/lib/permissions/permissions.test.ts index 8baf5cf462..d2a9a51ad8 100644 --- a/src/macos/lib/permissions/permissions.test.ts +++ b/src/macos/lib/permissions/permissions.test.ts @@ -697,6 +697,7 @@ describe("Local Network answers", () => { "Timestamp Ty Process[PID:TID]", "2026-10-21 22:16:06.926 I nehelper[419:2a66e2b] [com.apple.networkextension:] Found new path /Users/x/.genesis-tools/app/retired.noindex/1792613086315/GenesisTools.app/Contents/MacOS/GenesisTools for PID 97417, allowing a new prompt", "2026-10-21 22:16:06.930 I nehelper[419:2a66e2b] [com.apple.networkextension:] Local network preference not yet set, prompting for GenesisTools (com.genesiscz.genesistools)", + "2026-10-21 22:17:01.100 I nehelper[419:2a66e2b] [com.apple.networkextension:] Found new path /Applications/Other.app/Contents/MacOS/Other for PID 51200, allowing a new prompt", ].join("\n"); const [prompt, ...rest] = parseLocalNetworkPrompts(log); expect(rest).toEqual([]); From 11c5f02576e8333eeaf78774ae05cc7d145bbc72 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 21:56:39 +0200 Subject: [PATCH 18/21] fix(GenesisKit, GenesisHub): #556 followup - diff images pin blob ids, stay in budget and refuse symlinks, raise payloads are validated, a failed Local Network lookup is said so, and preview cleanup spares running builds --- scripts/build-widget-preview.ts | 42 ++++++---- .../GenesisKit/Diff/Model/DiffModel.swift | 5 +- .../Diff/Render/PierreWebDiffRenderer.swift | 34 ++++---- .../Diff/Source/GitWorkingTreeSource.swift | 44 +++++++---- .../Sources/GenesisKit/Files/FileTree.swift | 10 ++- .../Posture/PostureCore.swift | 2 +- .../Posture/PostureShoulderGate.swift | 9 ++- .../Diff/GitFileDiffTests.swift | 70 +++++++++++++++- .../Tests/GenesisKitTests/FileTreeTests.swift | 13 +++ .../PostureShoulderSourceTests.swift | 23 ++++++ .../Sources/Host/App/WindowCycle.swift | 38 ++++++--- .../Sources/Host/LocalNetworkWatch.swift | 37 +++++++-- .../Tests/LocalNetworkWatchTests.swift | 17 ++++ .../GenesisTools/Tests/VaultUnlockTests.swift | 14 +++- .../GenesisTools/Tests/WindowCycleTests.swift | 24 ++++++ .../web/diff-viewer/file-diff.test.ts | 17 +++- .../GenesisTools/web/diff-viewer/file-diff.ts | 14 ++++ .../GenesisTools/web/diff-viewer/main.ts | 12 +-- src/macos/commands/permissions/index.ts | 9 +++ src/macos/lib/clones/discover.test.ts | 11 +++ src/macos/lib/clones/discover.ts | 3 +- src/macos/lib/permissions/app.ts | 57 +++++++++++++ src/macos/lib/permissions/local-network.ts | 33 ++++++-- src/macos/lib/permissions/permissions.test.ts | 79 ++++++++++++++++++- 24 files changed, 529 insertions(+), 88 deletions(-) diff --git a/scripts/build-widget-preview.ts b/scripts/build-widget-preview.ts index f0976c67be..b379bd4613 100644 --- a/scripts/build-widget-preview.ts +++ b/scripts/build-widget-preview.ts @@ -1,7 +1,16 @@ import { createHash } from "node:crypto"; -import { cp, mkdir, readdir, rename, rm } from "node:fs/promises"; +import { existsSync, statSync } from "node:fs"; +import { cp, mkdir, rename } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { buildDiffViewer, LSREGISTER, staleRegistrations } from "@app/macos/lib/permissions/app"; +import { + buildDiffViewer, + deleteRetiredStamps, + LSREGISTER, + listRetiredStamps, + readRunningExecutableInodes, + retiredStampsToDelete, + staleRegistrations, +} from "@app/macos/lib/permissions/app"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; @@ -240,25 +249,30 @@ async function buildPreview(): Promise { /** * Every retired preview is unregistered from Launch Services, only the newest {@link RETIRED_PREVIEWS_KEPT} stay on - * disk, and every record of this bundle id at another path than `destination` goes. Before this, each build left its - * retired copy registered and on disk forever: 72 records of deleted copies on 2026-10-20, all offered to `open -b`. - * A failure is logged, never fatal: the new preview is already installed. + * disk (plus any a running Preview still executes), and every record of this bundle id at another path than + * `destination` goes. Before this, each build left its retired copy registered and on disk forever: 72 records of + * deleted copies on 2026-10-20, all offered to `open -b`. A failure is logged, never fatal: the new preview is already + * installed. */ async function pruneRetiredPreviews(destination: string): Promise { const root = toolDataDir("widget-preview", "retired"); try { - const stamps = (await readdir(root, { withFileTypes: true }).catch(() => [])) - .filter((entry) => entry.isDirectory() && Number.isFinite(Number(entry.name))) - .map((entry) => entry.name) - .sort((a, b) => Number(b) - Number(a)); + const stamps = listRetiredStamps(root) ?? []; for (const stamp of stamps) { await command([LSREGISTER, "-u", join(root, stamp, appName)]).catch((error) => logger.warn({ error, stamp }, "retired preview not unregistered") ); } - for (const stamp of stamps.slice(RETIRED_PREVIEWS_KEPT)) { - await rm(join(root, stamp), { recursive: true, force: true }); - } + const binary = (stamp: string) => join(root, stamp, appName, "Contents", "MacOS", "GenesisWidgetPreview"); + const deleted = deleteRetiredStamps( + root, + retiredStampsToDelete({ + stamps, + keep: RETIRED_PREVIEWS_KEPT, + running: readRunningExecutableInodes(), + inodeOf: (stamp) => (existsSync(binary(stamp)) ? statSync(binary(stamp)).ino : null), + }) + ); const stale = staleRegistrations(await command([LSREGISTER, "-dump"]), bundleId, destination); for (const path of stale) { await command([LSREGISTER, "-u", path]).catch((error) => @@ -267,8 +281,8 @@ async function pruneRetiredPreviews(destination: string): Promise { } logger.info( { - kept: Math.min(stamps.length, RETIRED_PREVIEWS_KEPT), - deleted: Math.max(0, stamps.length - RETIRED_PREVIEWS_KEPT), + kept: stamps.length - deleted.length, + deleted: deleted.length, unregistered: stale.length, }, "pruned retired widget previews" diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 7011d98a37..6201d62097 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -56,8 +56,9 @@ public struct DiffImage: Codable, Hashable { public var old: Source? public var new: Source? - /// What the working-tree file was when the diff was read (modification time and size). A source names a place, - /// not its bytes, so an image overwritten while its diff stays open is a new `DiffImage` only through this. + /// What both sides were when the diff was read: a blob side's object id, a working-tree side's modification time + /// and size. A source names a place (`HEAD:logo.png`, a path), not its bytes, so an image overwritten, staged or + /// committed while its diff stays open is a new `DiffImage` only through this. public var revision: String? public init(old: Source?, new: Source?, revision: String? = nil) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index a5806e385c..a23e6f8d1f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -535,23 +535,29 @@ enum DiffImageRegistry { lock.withLock { sources[token] } } - /// The bytes and the path whose extension names their type. Runs git or reads the disk, so off the main thread. - static func load(_ source: DiffImage.Source) -> (data: Data, path: String)? { + /// The largest picture a diff loads. The text diff skips files over `GitWorkingTreeSource.maxBytes`, and those + /// are the images shown here, so the read needs a bound of its own. + static let maxBytes = 20_000_000 + + /// The bytes and the path whose extension names their type; nil for a side over `maxBytes`, and for a symlink + /// (it would read a file outside the repository). Runs git or reads the disk, so off the main thread. + static func load(_ source: DiffImage.Source, maxBytes: Int = maxBytes) -> (data: Data, path: String)? { switch source { case .file(let path): - return (try? Data(contentsOf: URL(fileURLWithPath: path))).map { ($0, path) } + let url = URL(fileURLWithPath: path) + guard let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey, .fileSizeKey]), + values.isSymbolicLink != true, values.isRegularFile == true, + let size = values.fileSize, size <= maxBytes else { return nil } + return (try? Data(contentsOf: url)).map { ($0, path) } case .blob(let repo, let object): - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["-C", repo, "cat-file", "blob", object] - let out = Pipe() - process.standardOutput = out - process.standardError = FileHandle.nullDevice - guard (try? process.run()) != nil else { return nil } - let data = out.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - return (data, object) + let folder = URL(fileURLWithPath: repo) + let git = { (args: [String]) in + try? GenesisKitGT.provider.git(args, in: folder, input: nil, gitDirectory: nil, environment: nil) + } + guard let sized = git(["cat-file", "-s", object]), sized.status == 0, + let size = Int(String(decoding: sized.stdout, as: UTF8.self).trimmed), size <= maxBytes, + let blob = git(["cat-file", "blob", object]), blob.status == 0 else { return nil } + return (blob.stdout, object) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index da804efdfa..0a2582e0d4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -307,9 +307,9 @@ public struct GitWorkingTreeSource { let counts = parseNumstat((try? gitData(["diff", "--numstat", "-z", "--find-renames"] + plan.diffArgs)) ?? Data()) let olds = catFile(entries.filter { $0.status != .added }.map { (plan.old, $0.oldPath ?? $0.path) }) - let news: [String: Content] = plan.new.map { rev in + let news: Blobs = plan.new.map { rev in catFile(entries.filter { $0.status != .deleted }.map { (rev, $0.path) }) - } ?? [:] + } ?? Blobs() let files: [DiffFile] = entries.map { entry in var file = DiffFile( @@ -341,13 +341,19 @@ public struct GitWorkingTreeSource { } } - if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path) { - let working = repo.appendingPathComponent(entry.path).path + let working = repo.appendingPathComponent(entry.path) + // A working-tree symlink shows as its target path, like any symlink: its picture would be a file outside. + let workingIsLink = plan.new == nil && entry.status != .deleted + && (try? working.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path), !workingIsLink { + let oldName = "\(plan.old):\(entry.oldPath ?? entry.path)" + // `HEAD:` and the index move: the object ids pin what each side was when the diff was read. + let newRevision = plan.new.map { news.oids["\($0):\(entry.path)"] } ?? DiffImage.revision(ofFile: working.path) file.image = DiffImage( - old: entry.status == .added ? nil : .blob(repo: repo.path, object: "\(plan.old):\(entry.oldPath ?? entry.path)"), + old: entry.status == .added ? nil : .blob(repo: repo.path, object: oldName), new: entry.status == .deleted ? nil - : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(working), - revision: plan.new == nil ? DiffImage.revision(ofFile: working) : nil + : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(working.path), + revision: "\(olds.oids[oldName] ?? "-") \(newRevision ?? "-")" ) } @@ -652,24 +658,34 @@ public struct GitWorkingTreeSource { case skipped(String) } + /// What `catFileBatch` read, by object name: each blob's content, and the object id the name resolved to (a large + /// file gets an id but no content). + private struct Blobs { + var contents: [String: Content] = [:] + var oids: [String: String] = [:] + + subscript(name: String) -> Content? { contents[name] } + } + /// One `git cat-file --batch` for every `rev:path` (rev "" = the index): ` blob \n\n` /// or ` missing\n`. Keys of the result are `rev:path`. - private func catFile(_ specs: [(rev: String, path: String)]) -> [String: Content] { + private func catFile(_ specs: [(rev: String, path: String)]) -> Blobs { catFileBatch(specs.map { "\($0.rev):\($0.path)" }) } /// One `git cat-file --batch` for these object names (`rev:path` or a bare oid), keyed by name. - private func catFileBatch(_ names: [String], gitDir: String? = nil) -> [String: Content] { - guard !names.isEmpty else { return [:] } + private func catFileBatch(_ names: [String], gitDir: String? = nil) -> Blobs { + guard !names.isEmpty else { return Blobs() } let request = names.map { "\($0)\n" }.joined() - guard let metadata = try? gitData(["cat-file", "--batch-check"], input: Data(request.utf8), gitDir: gitDir) else { return [:] } - var result: [String: Content] = [:] + guard let metadata = try? gitData(["cat-file", "--batch-check"], input: Data(request.utf8), gitDir: gitDir) else { return Blobs() } + var result = Blobs() var eligible: [(name: String, oid: String)] = [] for (name, line) in zip(names, String(decoding: metadata, as: UTF8.self).split(separator: "\n")) { let fields = line.split(separator: " ") guard fields.count == 3, fields[1] == "blob", let size = Int(fields[2]), size >= 0 else { continue } + result.oids[name] = String(fields[0]) if size > Self.maxBytes { - result[name] = .skipped("large file, \(size / 1024) KB") + result.contents[name] = .skipped("large file, \(size / 1024) KB") } else { eligible.append((name, String(fields[0]))) } @@ -687,7 +703,7 @@ public struct GitWorkingTreeSource { guard fields.count == 3, fields[1] == "blob", let size = Int(fields[2]), size >= 0, size < data.distance(from: cursor, to: data.endIndex) else { break } let end = data.index(cursor, offsetBy: size) - result[name] = decode(data[cursor.. Int { + guard let event, [.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp] + .contains(event.type) else { return 1 } + return event.clickCount + } } /// One row. Equal while its row value, selection, icon, visual style, pulse and actions' revision are. @@ -661,7 +669,7 @@ struct FileTreeRowView: View, Equatable { let _ = RenderProbe.hit("tree.row") let metrics = style.metrics Button { - switch FileTreeClick.forRow(isFolder: row.isFolder, clickCount: NSApp.currentEvent?.clickCount ?? 1) { + switch FileTreeClick.forRow(isFolder: row.isFolder, clickCount: FileTreeClick.clickCount(of: NSApp.currentEvent)) { case .toggleFolder: model.reduceMotion = reduceMotion GenMotion.perform(expanding: !row.expanded, reduceMotion: reduceMotion) { model.toggle(row.id) } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift index 52f6dfdd28..3d922c0dff 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift @@ -146,7 +146,7 @@ final class PostureCore: @unchecked Sendable { usesCamera: usesCamera, at: time) if !machine.state.isPosturePhase { smoother.reset() - shoulderGate.reset() + shoulderGate.reset() lastScore = nil pendingRows += minutes.flush() } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift index fd5239552c..ba9e461f85 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift @@ -15,13 +15,17 @@ struct PostureShoulderGate: Equatable, Sendable { private(set) var confirmed: Set = [] /// Faults only the last fresh reading found bad. private(set) var pending: Set = [] + /// Where the last frame with shoulders took them from. + private(set) var lastSource: PoseShoulderSource? /// Judges one reading. `score` is the smoothed score the monitor acts on; `fresh` is this frame's unsmoothed /// score, given only when the 3D request ran on this frame. Returns the score to act on and whether the - /// engine should read the shoulders again on the next frame. + /// engine should read the shoulders again on the next frame. A frame without shoulders (`source` nil) still + /// carries the smoother's shoulder measures from the 3D frames before it, so it is judged as those were. mutating func apply(_ score: PostureScore, fresh: PostureScore?, source: PoseShoulderSource?, scorer: PostureScorer) -> (score: PostureScore, readAgain: Bool) { - guard source == .body3D else { return (score, false) } + if let source { lastSource = source } + guard (source ?? lastSource) == .body3D else { return (score, false) } if let fresh { let bad = Self.badShoulderKinds(fresh) let agreed = bad.intersection(confirmed.union(pending)) @@ -36,6 +40,7 @@ struct PostureShoulderGate: Equatable, Sendable { mutating func reset() { confirmed = [] pending = [] + lastSource = nil } static func badShoulderKinds(_ score: PostureScore) -> Set { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 78253669b4..7271147901 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -40,7 +40,7 @@ final class GitFileDiffTests: XCTestCase { XCTAssertNotNil(svg.newContents, "an SVG keeps its code") XCTAssertEqual(svg.image?.new, .file(repo.appendingPathComponent("icon.svg").path), "and names its picture too") - let load = { (source: DiffImage.Source?) in try XCTUnwrap(source.flatMap(DiffImageRegistry.load)).data } + let load = { (source: DiffImage.Source?) in try XCTUnwrap(source.flatMap { DiffImageRegistry.load($0) }).data } XCTAssertEqual(try load(changed.old), before, "the before side comes from HEAD") XCTAssertEqual(try load(changed.new), after, "the after side is the working-tree file") XCTAssertEqual(try load(added.new), after) @@ -57,6 +57,74 @@ final class GitFileDiffTests: XCTestCase { XCTAssertNotEqual(reread, changed, "but another revision") XCTAssertNotEqual(DiffImageRegistry.url(try XCTUnwrap(reread.new), revision: reread.revision), DiffImageRegistry.url(try XCTUnwrap(changed.new), revision: changed.revision)) + + // HEAD moves while the working file stays: the before side `HEAD:logo.png` names other bytes now. + let other = repo.deletingLastPathComponent().appendingPathComponent("gk-diff-other-\(UUID().uuidString).png") + // A zero byte keeps it binary, as the text diff decides. + try Data([0x89, 0x50, 0x4E, 0x47, 0, 7, 7]).write(to: other) + defer { try? FileManager.default.removeItem(at: other) } + let oid = try git(["hash-object", "-w", other.path], in: repo) + _ = try git(["update-index", "--cacheinfo", "100644,\(oid),logo.png"], in: repo) + _ = try git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "logo 2"], in: repo) + let moved = try XCTUnwrap(GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files.first { $0.path == "logo.png" }?.image) + XCTAssertEqual(moved.old, reread.old, "the same name") + XCTAssertNotEqual(moved.revision, reread.revision, "but another object") + XCTAssertEqual(try load(moved.old), Data([0x89, 0x50, 0x4E, 0x47, 0, 7, 7])) + } + + /// A picture over the budget is not read, from disk or from git; under it, the same call reads it through the + /// provider (the host's, when it set one). + func testAnImageOverTheBudgetIsNotLoaded() throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-budget-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repo) } + let bytes = Data([0x89, 0x50, 0x4E, 0x47, 1, 2, 3, 4, 5]) + _ = try git(["init", "-q"], in: repo) + try bytes.write(to: repo.appendingPathComponent("big.png")) + _ = try git(["add", "big.png"], in: repo) + let file = DiffImage.Source.file(repo.appendingPathComponent("big.png").path) + let blob = DiffImage.Source.blob(repo: repo.path, object: ":big.png") + XCTAssertNil(DiffImageRegistry.load(file, maxBytes: 4)) + XCTAssertNil(DiffImageRegistry.load(blob, maxBytes: 4)) + XCTAssertEqual(DiffImageRegistry.load(file, maxBytes: 9)?.data, bytes) + + final class RecordingGitProvider: GenesisKitGTCLIProvider { + var calls: [[String]] = [] + override func git(_ arguments: [String], in repo: URL, input: Data? = nil, gitDirectory: String? = nil, + environment: [String: String]? = nil) throws -> GitRunResult { + calls.append(arguments) + return try super.git(arguments, in: repo, input: input, gitDirectory: gitDirectory, environment: environment) + } + } + let recording = RecordingGitProvider() + let previous = GenesisKitGT.provider + GenesisKitGT.provider = recording + defer { GenesisKitGT.provider = previous } + XCTAssertEqual(DiffImageRegistry.load(blob, maxBytes: 9)?.data, bytes) + XCTAssertEqual(recording.calls, [["cat-file", "-s", ":big.png"], ["cat-file", "blob", ":big.png"]]) + } + + /// An SVG symlinked out of the repository shows its target path as code and has no picture: the picture would + /// be the file outside. + func testASymlinkedSVGHasNoPicture() throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-link-\(UUID().uuidString)") + let outside = repo.deletingLastPathComponent().appendingPathComponent("gk-diff-outside-\(UUID().uuidString).svg") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: repo) + try? FileManager.default.removeItem(at: outside) + } + try "outside\n" + .write(to: outside, atomically: true, encoding: .utf8) + _ = try git(["init", "-q"], in: repo) + _ = try git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "start"], + in: repo) + try FileManager.default.createSymbolicLink(at: repo.appendingPathComponent("icon.svg"), withDestinationURL: outside) + + let svg = try XCTUnwrap(GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files.first { $0.path == "icon.svg" }) + XCTAssertEqual(svg.newContents, outside.path, "the code diff is the link's target") + XCTAssertNil(svg.image) + XCTAssertNil(DiffImageRegistry.load(.file(repo.appendingPathComponent("icon.svg").path)), "served bytes refuse it too") } func testUntrackedColorContrastsWithBothPageSchemes() throws { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift index 719d628474..12c80060e9 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import XCTest @testable import GenesisKitBase @@ -63,6 +64,18 @@ final class FileTreeTests: XCTestCase { XCTAssertEqual(FileTreeClick.forRow(isFolder: false, clickCount: 2), .keepFile) } + /// A row activated from the keyboard reads as one click; reading `clickCount` of a key event raises. + func testOnlyAMouseEventHasAClickCount() throws { + let key = try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, characters: " ", + charactersIgnoringModifiers: " ", isARepeat: false, keyCode: 49)) + XCTAssertEqual(FileTreeClick.clickCount(of: key), 1) + XCTAssertEqual(FileTreeClick.clickCount(of: nil), 1) + let double = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseUp, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, eventNumber: 0, clickCount: 2, pressure: 0)) + XCTAssertEqual(FileTreeClick.clickCount(of: double), 2) + } + func testListingPutsFoldersFirstInFinderOrderAndSkipsHiddenFiles() { let names = FileTreeListing.entries(of: root.path).map(\.name) XCTAssertEqual(names, ["Alpha", "beta", "note 2.md", "note 10.md"]) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift index 7e22bb1556..935d6725ec 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift @@ -164,6 +164,29 @@ final class PostureShoulderSourceTests: XCTestCase { XCTAssertFalse(result.readAgain) } + /// The confirming frame came back without shoulders: the smoothed score still carries the first reading's tilt, + /// and it stays held, not bad. A 2D engine's frame without shoulders passes as before. + func testAFrameWithoutShouldersKeepsAnUnconfirmedFaultHeld() throws { + let base = try baseline(.body3D) + let scorer = PostureScorer() + var gate = PostureShoulderGate() + let bad = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1)), against: base, shoulderSource: .body3D) + XCTAssertTrue(gate.apply(bad, fresh: bad, source: .body3D, scorer: scorer).readAgain) + let smoothed = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1)), against: base, shoulderSource: nil) + XCTAssertTrue(smoothed.isBad, "positive control: the smoothed shoulders alone are bad") + let faceOnly = gate.apply(smoothed, fresh: nil, source: nil, scorer: scorer) + XCTAssertFalse(faceOnly.score.isBad) + XCTAssertEqual(faceOnly.score.held, [.shoulderTilt]) + + var flat = PostureShoulderGate() + let base2D = try baseline(.body2D) + let bad2D = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1, source: .body2D)), against: base2D, + shoulderSource: .body2D) + _ = flat.apply(bad2D, fresh: nil, source: .body2D, scorer: scorer) + let missed = scorer.score(PostureMetrics(sample: shoulderSample(tilt: 0.1)), against: base2D, shoulderSource: nil) + XCTAssertEqual(flat.apply(missed, fresh: nil, source: nil, scorer: scorer).score, missed) + } + /// The smoother still holds the old shoulders for a moment after a fresh level reading; that reading wins. func testAFreshLevelReadingHoldsAStillBadSmoothedScore() throws { let base = try baseline(.body3D) diff --git a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift index 841b678f19..74254af4ad 100644 --- a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift +++ b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift @@ -101,10 +101,9 @@ package enum WindowCycle { MainActor.assumeIsolated { raiseRequested(info) } }) tokens.append(distributed.addObserver(forName: focusName, object: nil, queue: .main) { note in - guard let pid = note.userInfo?["pid"] as? Int, pid != Int(getpid()), - let number = note.userInfo?["window"] as? Int else { return } + guard let window = focusedWindow(note.userInfo, own: getpid()) else { return } MainActor.assumeIsolated { - recent = promote(Candidate(pid: pid_t(pid), number: number), in: recent) + recent = promote(window, in: recent) // A window of another face took focus: GenesisTools is in use, so a Cmd+Tab back is not pending. leftFrom = nil } @@ -249,24 +248,45 @@ package enum WindowCycle { guard let pid = info?["pid"] as? Int, pid == Int(getpid()), let number = info?["window"] as? Int else { return } lastCycleRaise = Date() leftFrom = nil - guard let flat = info?["order"] as? [Int], let index = info?["index"] as? Int, let raw = info?["modifiers"] as? Int, - flat.count.isMultiple(of: 2), index < flat.count / 2 else { + guard let requested = requestedSession(info, pid: getpid(), window: number) else { raise(number, reason: "asked by another face", cycling: false) return } - let order = stride(from: 0, to: flat.count, by: 2).map { Candidate(pid: pid_t(flat[$0]), number: flat[$0 + 1]) } - let modifiers = NSEvent.ModifierFlags(rawValue: UInt(raw)) // The modifier went up while the activation was on its way: this face never sees that release. let held = NSEvent.ModifierFlags(rawValue: UInt(CGEventSource.flagsState(.combinedSessionState).rawValue)) - guard held.contains(modifiers) else { + guard held.contains(requested.modifiers) else { session = nil raise(number, reason: "asked by another face, run ended", cycling: false) return } - session = Session(order: order, index: index, modifiers: modifiers) + session = requested raise(number, reason: "asked by another face", cycling: true) } + /// The run a raise request carries: pairs of pid and window number, the index of the landed window, which must + /// be `window` of `pid`, and the modifiers whose release ends the run. Any process may post the notification, so + /// a payload that does not fit (a negative index, a pid outside `pid_t`, no modifier) is nil, a plain raise. + package static func requestedSession(_ info: [AnyHashable: Any]?, pid: pid_t, window: Int) -> Session? { + guard let flat = info?["order"] as? [Int], !flat.isEmpty, flat.count.isMultiple(of: 2), + let index = info?["index"] as? Int, (0..<(flat.count / 2)).contains(index), + let raw = info?["modifiers"] as? Int, let bits = UInt(exactly: raw) else { return nil } + var order: [Candidate] = [] + for at in stride(from: 0, to: flat.count, by: 2) { + guard let owner = pid_t(exactly: flat[at]) else { return nil } + order.append(Candidate(pid: owner, number: flat[at + 1])) + } + let modifiers = NSEvent.ModifierFlags(rawValue: bits).intersection(relevant) + guard !modifiers.isEmpty, order[index] == Candidate(pid: pid, number: window) else { return nil } + return Session(order: order, index: index, modifiers: modifiers) + } + + /// The window a focus broadcast names, when it is another face's and its pid fits `pid_t`. + nonisolated package static func focusedWindow(_ info: [AnyHashable: Any]?, own: pid_t) -> Candidate? { + guard let raw = info?["pid"] as? Int, let pid = pid_t(exactly: raw), pid != own, + let number = info?["window"] as? Int else { return nil } + return Candidate(pid: pid, number: number) + } + /// The modifier of a run went up: the landed window becomes the most recent one everywhere. private static func released(_ modifiers: NSEvent.ModifierFlags) { guard let current = session, !modifiers.intersection(relevant).contains(current.modifiers) else { return } diff --git a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift index 3124041442..48519a3ec3 100644 --- a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift +++ b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift @@ -19,8 +19,16 @@ struct LocalNetworkPrompt: Decodable, Equatable, Identifiable { let executable: String let retiredAt: String? let process: Program? + /// Set by the relay when it could not look the pid up; `tools` never sends it. + var note: String? = nil var id: String { "\(pid) \(at)" } + + /// The row's first line: the program, or why there is none. + var headline: String { + if let process { return LocalNetworkPromptRow.short(process.command) } + return "pid \(pid) (\(note ?? "it has already exited"))" + } } struct LocalNetworkPromptList: Decodable { @@ -35,7 +43,8 @@ enum LocalNetworkWatch { private static let restartSeconds = 15.0 private static var stream: Process? private static var panel: LocalNetworkPanel? - private nonisolated(unsafe) static var streamPid: pid_t = 0 + /// The running `log stream`, 0 when none runs; the exit hook signals it. + nonisolated(unsafe) static var streamPid: pid_t = 0 private static var reapsOnExit = false /// The `--local-network-panel` face ends with its panel. static var onPanelClosed: (() -> Void)? @@ -74,6 +83,7 @@ enum LocalNetworkWatch { } process.terminationHandler = { ended in let status = ended.terminationStatus + streamEnded() Task { @MainActor in stream = nil HubPerf.log("localNetwork: log stream ended (status \(status)); again in \(Int(restartSeconds)) s") @@ -96,19 +106,32 @@ enum LocalNetworkWatch { HubPerf.log("localNetwork: following nehelper prompts (log stream pid \(process.processIdentifier))") } + /// The `log stream` child ended: its pid may belong to another process soon, so the exit hook must not signal it. + nonisolated static func streamEnded() { + streamPid = 0 + } + /// Asks `tools` who the pid is; the prompt line comes before the dialog, so the program usually still runs. private static func announce(pid: Int) async { HubPerf.log("localNetwork: prompt for pid \(pid)") - var prompt: LocalNetworkPrompt? + var list: LocalNetworkPromptList? do { + // Longer than the 120 s `log show` limit on the tools side, so its own failure is the one reported. let result = try await ToolsBridge(binaryPath: ToolsBridge.defaultBinaryPath()).run( - subcommand: "macos", args: ["permissions", "local-network", "--since", "5m", "--json"], timeoutSeconds: 60) - let list = try JSONDecoder().decode(LocalNetworkPromptList.self, from: Data(result.stdout.utf8)) - prompt = list.prompts.last { $0.pid == pid } + subcommand: "macos", args: ["permissions", "local-network", "--since", "5m", "--json"], timeoutSeconds: 130) + list = try JSONDecoder().decode(LocalNetworkPromptList.self, from: Data(result.stdout.utf8)) } catch { HubPerf.log("localNetwork: could not describe pid \(pid): \(error)") } - show(prompt ?? LocalNetworkPrompt(at: "", pid: pid, executable: "", retiredAt: nil, process: nil)) + show(prompt(for: pid, in: list)) + } + + /// The prompt `list` has for `pid`. Without one (the lookup failed, or the log did not have the line yet) the + /// panel says so, rather than that the process exited: that is known only from a prompt without a process. + nonisolated static func prompt(for pid: Int, in list: LocalNetworkPromptList?) -> LocalNetworkPrompt { + list?.prompts.last { $0.pid == pid } + ?? LocalNetworkPrompt(at: "", pid: pid, executable: "", retiredAt: nil, process: nil, + note: "could not look up this process") } static func show(_ prompt: LocalNetworkPrompt) { @@ -306,7 +329,7 @@ struct LocalNetworkPromptRow: View { var body: some View { VStack(alignment: .leading, spacing: 5) { - Text(verbatim: prompt.process.map { Self.short($0.command) } ?? "pid \(prompt.pid) (it has already exited)") + Text(verbatim: prompt.headline) .font(.system(size: 12, weight: .medium, design: .monospaced)) .lineLimit(3) .textSelection(.enabled) diff --git a/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift index 1690890d39..1af677f4c0 100644 --- a/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift +++ b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift @@ -13,6 +13,23 @@ final class LocalNetworkWatchTests: XCTestCase { XCTAssertNil(LocalNetworkWatch.promptPid(inLogLine: other), "another app's prompt is not ours to describe") } + /// Only a prompt that `tools` returned without a process says the process exited; a lookup that failed or did not + /// find the pid says that instead. + func testAFailedLookupIsNotShownAsAnExitedProcess() { + let exited = LocalNetworkPrompt(at: "2026-10-22 16:33:02", pid: 49784, executable: "/x", retiredAt: nil, process: nil) + let list = LocalNetworkPromptList(prompts: [exited]) + XCTAssertEqual(LocalNetworkWatch.prompt(for: 49784, in: list).headline, "pid 49784 (it has already exited)") + XCTAssertEqual(LocalNetworkWatch.prompt(for: 51200, in: list).headline, "pid 51200 (could not look up this process)") + XCTAssertEqual(LocalNetworkWatch.prompt(for: 49784, in: nil).headline, "pid 49784 (could not look up this process)") + } + + /// The `log stream` child ended: the exit hook must not signal its pid, which another process may hold by then. + func testAnEndedLogStreamLeavesNoPidForTheExitHook() { + LocalNetworkWatch.streamPid = 12345 + LocalNetworkWatch.streamEnded() + XCTAssertEqual(LocalNetworkWatch.streamPid, 0) + } + func testShortCommandNamesTheTool() { XCTAssertEqual(LocalNetworkPromptRow.short("gt-cc /Users/me/GenesisTools/src/cc/index.ts run work --resume x"), "cc run work --resume x") XCTAssertEqual(LocalNetworkPromptRow.short("/usr/bin/python3 server.py"), "/usr/bin/python3 server.py") diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index 317633f7bd..90034ef535 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -80,11 +80,17 @@ final class VaultUnlockTests: XCTestCase { shell.standardInput = input try shell.run() defer { shell.terminate() } - Thread.sleep(forTimeInterval: 0.2) + // run() returns after posix_spawn, so /bin/sh is already the running image. let beforeExec = try XCTUnwrap(VaultRecipient.identity(shell.processIdentifier)) - input.fileHandleForWriting.write(Data("go\n".utf8)) - Thread.sleep(forTimeInterval: 0.3) - let afterExec = try XCTUnwrap(VaultRecipient.identity(shell.processIdentifier)) + try input.fileHandleForWriting.write(contentsOf: Data("go\n".utf8)) + // A loaded machine can take a while to run the exec: wait for it, up to 5 s. + let deadline = Date().addingTimeInterval(5) + var current = VaultRecipient.identity(shell.processIdentifier) + while current?.executable != "/bin/sleep", Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + current = VaultRecipient.identity(shell.processIdentifier) + } + let afterExec = try XCTUnwrap(current) XCTAssertEqual(afterExec.executable, "/bin/sleep", "positive control: the exec happened") XCTAssertNotEqual(beforeExec.execGeneration, afterExec.execGeneration) XCTAssertNotEqual(beforeExec, afterExec) diff --git a/src/macos/GenesisTools/Tests/WindowCycleTests.swift b/src/macos/GenesisTools/Tests/WindowCycleTests.swift index fd9dbbf5a4..826516e7fc 100644 --- a/src/macos/GenesisTools/Tests/WindowCycleTests.swift +++ b/src/macos/GenesisTools/Tests/WindowCycleTests.swift @@ -80,4 +80,28 @@ final class WindowCycleTests: XCTestCase { XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 2, sinceMouse: 4, sinceKey: 0.1), "a typed key (a launcher)") XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 0.3, sinceMouse: 0.02, sinceKey: 5), "a click (Dock, link, notification)") } + + /// Any process can post the raise and focus notifications: a payload that does not fit is a plain raise or is + /// ignored, and never traps the face. + func testAMalformedRaiseOrFocusPayloadIsRejected() { + let option = Int(NSEvent.ModifierFlags.option.rawValue) + func request(_ order: [Int], index: Int, modifiers: Int = option) -> [AnyHashable: Any] { + ["order": order, "index": index, "modifiers": modifiers] + } + XCTAssertEqual(WindowCycle.requestedSession(request([1, 10, 2, 20], index: 1), pid: 2, window: 20), + WindowCycle.Session(order: [.init(pid: 1, number: 10), .init(pid: 2, number: 20)], index: 1, modifiers: .option)) + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, 2, 20], index: -1), pid: 2, window: 20), "a negative index") + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, 2, 20], index: 2), pid: 2, window: 20), "past the end") + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, 2, 20], index: 1, modifiers: -1), pid: 2, window: 20)) + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, 2, 20], index: 1, modifiers: 0), pid: 2, window: 20), + "no modifier: no release would end the run") + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, Int(Int32.max) + 1, 20], index: 1), pid: 2, window: 20)) + XCTAssertNil(WindowCycle.requestedSession(request([], index: 0), pid: 2, window: 20)) + XCTAssertNil(WindowCycle.requestedSession(request([1, 10, 2, 20], index: 0), pid: 2, window: 20), + "the landed window is not the one to raise") + + XCTAssertEqual(WindowCycle.focusedWindow(["pid": 2, "window": 20], own: 1), .init(pid: 2, number: 20)) + XCTAssertNil(WindowCycle.focusedWindow(["pid": 1, "window": 20], own: 1), "this face's own broadcast") + XCTAssertNil(WindowCycle.focusedWindow(["pid": Int(Int32.max) + 1, "window": 20], own: 1)) + } } diff --git a/src/macos/GenesisTools/web/diff-viewer/file-diff.test.ts b/src/macos/GenesisTools/web/diff-viewer/file-diff.test.ts index adb286a297..27f2d7230f 100644 --- a/src/macos/GenesisTools/web/diff-viewer/file-diff.test.ts +++ b/src/macos/GenesisTools/web/diff-viewer/file-diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getFiletypeFromFileName } from "@pierre/diffs"; -import { parseFileDiff } from "./file-diff"; +import { keepOnlyFiles, parseFileDiff } from "./file-diff"; function sides(path: string, oldPath: string | null = null) { return { path, oldPath, oldContents: "{\n}\n", newContents: '{\n "a": 1\n}\n', key: path }; @@ -43,3 +43,18 @@ describe("parseFileDiff", () => { expect(fileDiff.type).toBe("new"); }); }); + +describe("keepOnlyFiles", () => { + test("drops the files that left the diff from every cache", () => { + const parsed = new Map([ + ["a.ts", 1], + ["gone.svg", 2], + ]); + const images = new Map([["gone.svg", { svg: "" }]]); + const asImage = new Set(["gone.svg", "a.svg"]); + keepOnlyFiles(new Set(["a.ts", "a.svg"]), parsed, images, asImage); + expect([...parsed.keys()]).toEqual(["a.ts"]); + expect(images.size).toBe(0); + expect([...asImage]).toEqual(["a.svg"]); + }); +}); diff --git a/src/macos/GenesisTools/web/diff-viewer/file-diff.ts b/src/macos/GenesisTools/web/diff-viewer/file-diff.ts index 9ac81d489a..dd2d79c9fd 100644 --- a/src/macos/GenesisTools/web/diff-viewer/file-diff.ts +++ b/src/macos/GenesisTools/web/diff-viewer/file-diff.ts @@ -34,3 +34,17 @@ export function parseFileDiff(file: FileSides): FileDiffMetadata { return fileDiff; } + +/** + * Drops every entry whose file id is not in `ids` (the file set the page shows now). The page's per-file caches keep + * whole file texts, so a long-lived review would hold every file it ever showed without this. + */ +export function keepOnlyFiles(ids: Set, ...caches: Array | Set>): void { + for (const cache of caches) { + for (const id of [...cache.keys()]) { + if (!ids.has(id)) { + cache.delete(id); + } + } + } +} diff --git a/src/macos/GenesisTools/web/diff-viewer/main.ts b/src/macos/GenesisTools/web/diff-viewer/main.ts index 96758c9752..110e5cab5e 100644 --- a/src/macos/GenesisTools/web/diff-viewer/main.ts +++ b/src/macos/GenesisTools/web/diff-viewer/main.ts @@ -15,7 +15,7 @@ import { } from "@pierre/diffs"; import { WorkerPoolManager } from "@pierre/diffs/worker"; import { fenceCloses, fencedParts, fenceLanguage, fenceMarker } from "./code-lang"; -import { parseFileDiff } from "./file-diff"; +import { keepOnlyFiles, parseFileDiff } from "./file-diff"; import { createGlide } from "./glide"; import { installReviewState } from "./review-state"; @@ -3393,14 +3393,8 @@ function addFiles(batch: FilesBatch): void { applyPendingReveal(); if (batch.last) { - const ids = new Set(files.map((file) => file.id)); - - for (const id of parsed.keys()) { - if (!ids.has(id)) { - parsed.delete(id); - } - } - + // The image map keeps an SVG's whole file, so it goes with the files that left, as the parsed diffs do. + keepOnlyFiles(new Set(files.map((file) => file.id)), parsed, imagesByFile, svgAsImage); post({ type: "log", message: `diff.files ${batch.total} files in ${loadBatches} batches, ${loadProgressive ? "shown as they came" : "swapped in at the end"}: parse ${Math.round(loadParseMs)}ms, all on the page ${Math.round(performance.now() - loadStarted)}ms`, diff --git a/src/macos/commands/permissions/index.ts b/src/macos/commands/permissions/index.ts index c42e6c2946..c7da2e31ef 100644 --- a/src/macos/commands/permissions/index.ts +++ b/src/macos/commands/permissions/index.ts @@ -359,6 +359,15 @@ export function registerPermissionsCommand(program: Command): void { .option("--json", "Print the prompts as JSON") .action((options: { since: string; json?: boolean }) => { const prompts = readLocalNetworkPrompts(options.since); + + if (prompts === null) { + ui.err( + `Could not read the Local Network prompts: log show --last ${options.since} failed (see the log).` + ); + process.exitCode = 1; + return; + } + const launcher = genesisAppLauncherPath(); const live = describePromptProcesses( launcher, diff --git a/src/macos/lib/clones/discover.test.ts b/src/macos/lib/clones/discover.test.ts index d9d531a1bb..345021239d 100644 --- a/src/macos/lib/clones/discover.test.ts +++ b/src/macos/lib/clones/discover.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { discoverRoots, + extraRootDirs, partitionApfs, RepoNotFoundError, resolveRepoRoot, @@ -177,6 +178,16 @@ describe("discoverRoots", () => { } }); + it("with worktreesOf, the filesystem root holds the repo and only narrows the search", () => { + const repo = mkdtempSync(join(tmpdir(), "gt-clones-root-")); + try { + expect(extraRootDirs({ searchDirs: ["/"], repoRoot: repo, worktrees: [] })).toEqual([]); + expect(extraRootDirs({ searchDirs: ["/"], repoRoot: repo, worktrees: ["/"] })).toEqual([]); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + it("without worktreesOf it scans every install tree under the dirs", async () => { const outer = fleet(); try { diff --git a/src/macos/lib/clones/discover.ts b/src/macos/lib/clones/discover.ts index e0ddab7a93..8f7d314fb4 100644 --- a/src/macos/lib/clones/discover.ts +++ b/src/macos/lib/clones/discover.ts @@ -138,8 +138,9 @@ export function partitionApfs( return { apfs, skipped }; } +/** A filesystem root (`/`) already ends with the separator, so it gets none appended. */ function isSameOrInside(dir: string, parent: string): boolean { - return dir === parent || dir.startsWith(parent + sep); + return dir === parent || dir.startsWith(parent.endsWith(sep) ? parent : parent + sep); } /** diff --git a/src/macos/lib/permissions/app.ts b/src/macos/lib/permissions/app.ts index 2d00048f36..b516f08ce8 100644 --- a/src/macos/lib/permissions/app.ts +++ b/src/macos/lib/permissions/app.ts @@ -1364,6 +1364,63 @@ export function readRunningExecutableInodes(): Set | null { ); } +/** + * The retired copies under `root` (folders named by their retirement time in ms), newest first. Empty when `root` does + * not exist yet; null when it could not be read (logged with `root`), so a caller deletes nothing on a guess. + */ +export function listRetiredStamps(root: string): string[] | null { + try { + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && Number.isFinite(Number(entry.name))) + .map((entry) => entry.name) + .sort((a, b) => Number(b) - Number(a)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return []; + } + + logger.warn({ error, root }, "retired copies not listed; none deleted"); + return null; + } +} + +/** + * The retired copies to delete, from `stamps` newest first: those past the newest `keep`, except a pinned one + * ({@link pinnedRetiredBundles}: a running process still executes its binary, or the binary cannot be stat'ed). + * Running processes that could not be listed (`running` null) delete nothing. + */ +export function retiredStampsToDelete({ + stamps, + keep, + running, + inodeOf, +}: { + stamps: string[]; + keep: number; + running: Set | null; + inodeOf: (stamp: string) => number | null; +}): string[] { + if (running === null) { + return []; + } + + const pinned = pinnedRetiredBundles({ entries: stamps, inodeOf, running }); + return stamps.slice(keep).filter((stamp) => !pinned.has(stamp)); +} + +/** Deletes each stamp folder under `root`; a failure is logged and the rest still go. Returns the deleted stamps. */ +export function deleteRetiredStamps(root: string, stamps: string[]): string[] { + return stamps.filter((stamp) => { + try { + rmSync(join(root, stamp), { recursive: true, force: true }); + return true; + } catch (error) { + logger.warn({ error, root, stamp }, "retired copy not deleted"); + return false; + } + }); +} + /** * Keeps the newest {@link RETIRED_KEEP_FOLDERS} retired bundles and every pinned one as folders, packs the * other idle ones into `.zst` (tar inside seekable zstd, checked file by file before the folder goes; diff --git a/src/macos/lib/permissions/local-network.ts b/src/macos/lib/permissions/local-network.ts index 287b76d6fc..ff46b36c29 100644 --- a/src/macos/lib/permissions/local-network.ts +++ b/src/macos/lib/permissions/local-network.ts @@ -215,8 +215,8 @@ export function isGenesisToolsBinary(executable: string): boolean { return executable.endsWith("/GenesisTools.app/Contents/MacOS/GenesisTools"); } -/** Prompts in the last `since` (a `log show --last` value: `30m`, `2h`, `1d`); empty when `log show` fails (logged). */ -export function readLocalNetworkPrompts(since: string): LocalNetworkPrompt[] { +/** Prompts in the last `since` (a `log show --last` value: `30m`, `2h`, `1d`); null when `log show` fails (logged). */ +export function readLocalNetworkPrompts(since: string): LocalNetworkPrompt[] | null { const proc = Bun.spawnSync( [ "/usr/bin/log", @@ -231,16 +231,33 @@ export function readLocalNetworkPrompts(since: string): LocalNetworkPrompt[] { ], { stdout: "pipe", stderr: "pipe", timeout: 120_000 } ); + return promptsFromLogShow({ since, exitCode: proc.exitCode, stdout: proc.stdout, stderr: proc.stderr }); +} - if (proc.exitCode !== 0) { +/** + * The prompts in a `log show` run's output. A failed run (a bad `--since`, the timeout) is null, never an empty list: + * "no prompt" is an answer the caller prints, and a failure is not one. + */ +export function promptsFromLogShow({ + since, + exitCode, + stdout, + stderr, +}: { + since: string; + exitCode: number | null; + stdout: Uint8Array; + stderr: Uint8Array; +}): LocalNetworkPrompt[] | null { + if (exitCode !== 0) { logger.warn( - { since, exitCode: proc.exitCode, stderr: new TextDecoder().decode(proc.stderr).slice(0, 300) }, + { since, exitCode, stderr: new TextDecoder().decode(stderr).slice(0, 300) }, "local network: log show failed" ); - return []; + return null; } - return parseLocalNetworkPrompts(new TextDecoder().decode(proc.stdout)); + return parseLocalNetworkPrompts(new TextDecoder().decode(stdout)); } /** What the prompting process was, while it still runs. */ @@ -259,6 +276,10 @@ export function withoutPreloads(command: string): string { /** The live process behind each prompt pid; a pid that has exited is absent. */ export function describePromptProcesses(launcherPath: string, pids: number[]): Map { + if (pids.length === 0) { + return new Map(); + } + const wanted = new Set(pids); const entries = listProcesses(); const described = new Map(); diff --git a/src/macos/lib/permissions/permissions.test.ts b/src/macos/lib/permissions/permissions.test.ts index d2a9a51ad8..c3d95d89af 100644 --- a/src/macos/lib/permissions/permissions.test.ts +++ b/src/macos/lib/permissions/permissions.test.ts @@ -1,15 +1,19 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, spyOn } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; +import * as processes from "@genesiscz/utils/process/processes"; import { skip } from "@genesiscz/utils/test/skip"; import { + deleteRetiredStamps, describeSource, + listRetiredStamps, parseCodesignInfo, pickCodesignIdentity, readSourceInfo, + retiredStampsToDelete, signByHashes, signStagedBundle, staleAppFacePids, @@ -20,10 +24,12 @@ import { import { isPermissionKind, PERMISSION_KINDS, permissionStatusLabel } from "./kinds"; import { type AppProcess, + describePromptProcesses, groupOldBuilds, localNetworkAnswer, parseLocalNetworkPrompts, parseLocalNetworkRules, + promptsFromLogShow, retiredAtOf, withoutPreloads, } from "./local-network"; @@ -646,6 +652,53 @@ describe("staleRegistrations", () => { }); }); +describe("retired copies", () => { + it("lists a missing root as empty and an unreadable one as unknown", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-retired-")); + try { + mkdirSync(join(dir, "root", "100"), { recursive: true }); + mkdirSync(join(dir, "root", "300")); + mkdirSync(join(dir, "root", "notes")); + writeFileSync(join(dir, "file"), ""); + expect(listRetiredStamps(join(dir, "root"))).toEqual(["300", "100"]); + expect(listRetiredStamps(join(dir, "missing"))).toEqual([]); + expect(listRetiredStamps(join(dir, "file"))).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("never deletes a copy a running process still executes, nor any when the processes are unknown", () => { + const inodes: Record = { "4": 40, "3": 30, "2": 20, "1": 10, "0": null }; + const plan = (running: Set | null) => + retiredStampsToDelete({ stamps: ["4", "3", "2", "1", "0"], keep: 2, running, inodeOf: (s) => inodes[s] }); + expect(plan(new Set([10]))).toEqual(["2"]); + expect(plan(new Set())).toEqual(["2", "1"]); + expect(plan(null)).toEqual([]); + }); + + it("keeps deleting after one copy fails", () => { + if (process.getuid?.() === 0) { + return; + } + + const root = mkdtempSync(join(tmpdir(), "gt-retired-rm-")); + const locked = join(root, "2", "inner"); + try { + mkdirSync(locked, { recursive: true }); + writeFileSync(join(locked, "file"), ""); + mkdirSync(join(root, "1")); + chmodSync(locked, 0o500); + expect(deleteRetiredStamps(root, ["2", "1"])).toEqual(["1"]); + expect(existsSync(join(root, "1"))).toBe(false); + expect(existsSync(join(root, "2"))).toBe(true); + } finally { + chmodSync(locked, 0o700); + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("timedSteps", () => { it("times each step until the next one and lists them slowest first", () => { let clock = 0; @@ -711,6 +764,28 @@ describe("Local Network answers", () => { expect(retiredAtOf("/Users/x/Applications/GenesisTools.app/Contents/MacOS/GenesisTools")).toBeNull(); expect(withoutPreloads("gt-cc --preload /a.ts --preload /b.ts --resume abc")).toBe("gt-cc --resume abc"); }); + + it("tells a failed log show from a quiet one", () => { + const bytes = (text: string) => new TextEncoder().encode(text); + const failed = { since: "2x", exitCode: 64, stdout: bytes(""), stderr: bytes("log: invalid duration") }; + expect(promptsFromLogShow(failed)).toBeNull(); + expect(promptsFromLogShow({ ...failed, exitCode: null })).toBeNull(); + expect( + promptsFromLogShow({ since: "5m", exitCode: 0, stdout: bytes("Timestamp\n"), stderr: bytes("") }) + ).toEqual([]); + }); + + it("reads no process table when no prompt names a pid", () => { + const list = spyOn(processes, "listProcesses").mockImplementation(() => { + throw new Error("the process table was read"); + }); + try { + expect(describePromptProcesses("/x/GenesisTools", []).size).toBe(0); + expect(() => describePromptProcesses("/x/GenesisTools", [1])).toThrow("the process table was read"); + } finally { + list.mockRestore(); + } + }); }); describe("old builds still running", () => { From 3697b20eace3f8bcb999d9da89286ca82c228374 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 21:58:59 +0200 Subject: [PATCH 19/21] refactor(tools): #556 followup - the GenesisTools binary helpers live in @genesiscz/utils/genesis-tools/app-binary --- .../Sources/Host/LocalNetworkWatch.swift | 1 + src/macos/commands/permissions/index.ts | 2 +- src/macos/lib/permissions/local-network.ts | 15 +-------------- src/macos/lib/permissions/permissions.test.ts | 9 ++++++++- src/utils/genesis-tools/app-binary.ts | 19 +++++++++++++++++++ 5 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 src/utils/genesis-tools/app-binary.ts diff --git a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift index 48519a3ec3..863d5c1141 100644 --- a/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift +++ b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift @@ -56,6 +56,7 @@ enum LocalNetworkWatch { guard let marker = line.range(of: ", allowing a new prompt"), let pidStart = line.range(of: " for PID ", options: .backwards, range: line.startIndex.. { }); expect(retiredAtOf(prompt.executable)?.getTime()).toBe(1792613086315); expect(retiredAtOf("/Users/x/Applications/GenesisTools.app/Contents/MacOS/GenesisTools")).toBeNull(); + expect(isGenesisToolsBinary(`/Users/x/Applications${GENESIS_TOOLS_LAUNCHER_SUFFIX}`)).toBe(true); + expect(isGenesisToolsBinary("/Applications/Other.app/Contents/MacOS/Other")).toBe(false); + expect(isGenesisToolsBinary(`/x${GENESIS_TOOLS_LAUNCHER_SUFFIX}-helper`)).toBe(false); expect(withoutPreloads("gt-cc --preload /a.ts --preload /b.ts --resume abc")).toBe("gt-cc --resume abc"); }); diff --git a/src/utils/genesis-tools/app-binary.ts b/src/utils/genesis-tools/app-binary.ts new file mode 100644 index 0000000000..1ab8052b6e --- /dev/null +++ b/src/utils/genesis-tools/app-binary.ts @@ -0,0 +1,19 @@ +/** + * The GenesisTools.app launcher binary, as other processes and logs name it. The Swift link relay checks the same + * suffix on its own (`LocalNetworkWatch.promptPid`); change both together. + */ +export const GENESIS_TOOLS_LAUNCHER_SUFFIX = "/GenesisTools.app/Contents/MacOS/GenesisTools"; + +/** + * The launcher of the installed app or of a retired build. nehelper logs every app's Local Network prompt, and another + * app's is not ours to describe (its dialog names that app). + */ +export function isGenesisToolsBinary(executable: string): boolean { + return executable.endsWith(GENESIS_TOOLS_LAUNCHER_SUFFIX); +} + +/** When the build behind `executable` was retired, or null for the installed app. */ +export function retiredAtOf(executable: string): Date | null { + const match = /retired(?:\.noindex)?\/(\d+)\//.exec(executable); + return match ? new Date(Number(match[1])) : null; +} From c1ae88e458ee34da059a9e32b3d6da2bac652f99 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:52:39 +0200 Subject: [PATCH 20/21] fix(GenesisKit, macos): #556 followup - an image side loads the blob id its diff read, shoulders a baseline cannot judge stay out of the smoother, and a reused pid is not blamed for an earlier Local Network prompt --- .../GenesisKit/Diff/Model/DiffModel.swift | 9 +++--- .../Diff/Render/PierreWebDiffRenderer.swift | 4 +-- .../Diff/Source/GitWorkingTreeSource.swift | 13 +++++--- .../Posture/PostureCore.swift | 7 ++-- .../Posture/PostureMetrics.swift | 5 +++ .../Posture/PostureScorer.swift | 4 +-- .../Diff/GitFileDiffTests.swift | 8 +++-- .../PostureShoulderSourceTests.swift | 32 +++++++++++++++++++ src/macos/commands/permissions/index.ts | 3 +- src/macos/lib/permissions/local-network.ts | 19 +++++++++++ src/macos/lib/permissions/permissions.test.ts | 12 +++++++ 11 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 6201d62097..e292784ce9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -50,15 +50,16 @@ public struct DiffImage: Codable, Hashable { public enum Source: Codable, Hashable { /// A file on disk (the working tree). case file(String) - /// `git -C cat-file blob `, the object as `:` (an empty rev is the index). - case blob(repo: String, object: String) + /// `git -C cat-file blob `: the object id the diff read, or `:` (an empty rev is the + /// index) when it had none. `path` names the file, whose extension is the picture's type. + case blob(repo: String, object: String, path: String) } public var old: Source? public var new: Source? /// What both sides were when the diff was read: a blob side's object id, a working-tree side's modification time - /// and size. A source names a place (`HEAD:logo.png`, a path), not its bytes, so an image overwritten, staged or - /// committed while its diff stays open is a new `DiffImage` only through this. + /// and size. A file source names a place, not its bytes, so an image overwritten while its diff stays open is a new + /// `DiffImage` only through this. public var revision: String? public init(old: Source?, new: Source?, revision: String? = nil) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index a23e6f8d1f..aa089e74ca 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -549,7 +549,7 @@ enum DiffImageRegistry { values.isSymbolicLink != true, values.isRegularFile == true, let size = values.fileSize, size <= maxBytes else { return nil } return (try? Data(contentsOf: url)).map { ($0, path) } - case .blob(let repo, let object): + case .blob(let repo, let object, let path): let folder = URL(fileURLWithPath: repo) let git = { (args: [String]) in try? GenesisKitGT.provider.git(args, in: folder, input: nil, gitDirectory: nil, environment: nil) @@ -557,7 +557,7 @@ enum DiffImageRegistry { guard let sized = git(["cat-file", "-s", object]), sized.status == 0, let size = Int(String(decoding: sized.stdout, as: UTF8.self).trimmed), size <= maxBytes, let blob = git(["cat-file", "blob", object]), blob.status == 0 else { return nil } - return (blob.stdout, object) + return (blob.stdout, path) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index 0a2582e0d4..8cd99a637f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -346,13 +346,18 @@ public struct GitWorkingTreeSource { let workingIsLink = plan.new == nil && entry.status != .deleted && (try? working.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path), !workingIsLink { - let oldName = "\(plan.old):\(entry.oldPath ?? entry.path)" - // `HEAD:` and the index move: the object ids pin what each side was when the diff was read. + let oldPath = entry.oldPath ?? entry.path + let oldName = "\(plan.old):\(oldPath)" + // `HEAD:` and the index move: each side loads the object id it had when the diff was read. let newRevision = plan.new.map { news.oids["\($0):\(entry.path)"] } ?? DiffImage.revision(ofFile: working.path) file.image = DiffImage( - old: entry.status == .added ? nil : .blob(repo: repo.path, object: oldName), + old: entry.status == .added ? nil + : .blob(repo: repo.path, object: olds.oids[oldName] ?? oldName, path: oldPath), new: entry.status == .deleted ? nil - : plan.new.map { .blob(repo: repo.path, object: "\($0):\(entry.path)") } ?? .file(working.path), + : plan.new.map { rev in + let name = "\(rev):\(entry.path)" + return .blob(repo: repo.path, object: news.oids[name] ?? name, path: entry.path) + } ?? .file(working.path), revision: "\(olds.oids[oldName] ?? "-") \(newRevision ?? "-")" ) } diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift index 3d922c0dff..ff5ddd2415 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureCore.swift @@ -257,13 +257,16 @@ final class PostureCore: @unchecked Sendable { return [] } if scoringHeld { return [] } - let metrics = PostureMetrics(sample: sample) + var metrics = PostureMetrics(sample: sample) if let baseline, !metrics.isEmpty, !sample.lowLight { + let source = sample.shoulderSource + // Shoulders the baseline cannot judge stay out of the smoother: a later frame without shoulders has + // no source, so it would score them as comparable. + if baseline.shoulderSourceDiffers(from: source) { metrics.removeShoulderMeasures() } let smoothed = smoother.add(metrics, at: sample.time) var scorer = configuration.scorer // The face engine never sees shoulders: the face width is its own Forward head measure. if sample.engine == .visionFace { scorer.forwardHeadWithoutShoulders = nil } - let source = sample.shoulderSource let smoothedScore = scorer.score(smoothed, against: baseline, shoulderSource: source) let fresh = sample.hasFreshShoulders && source == .body3D ? scorer.score(metrics, against: baseline, shoulderSource: source) : nil diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift index 2b5aabe35d..751d6f50ac 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureMetrics.swift @@ -168,6 +168,11 @@ public struct PostureMetrics: Equatable, Codable, Sendable { public var isEmpty: Bool { values.isEmpty } + /// Leaves out every measure that divides by or reads the shoulders. + public mutating func removeShoulderMeasures() { + values = values.filter { !$0.key.usesShoulders } + } + /// Reads every measure the sample supports. Horizontal distances are scaled by the image aspect /// first, so every length is in image heights and angles are true angles (a 4:3 frame no longer /// shrinks widths by a quarter). Shoulders narrower than 2% of the image height are treated as diff --git a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift index 675a75017d..546605d540 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureScorer.swift @@ -295,9 +295,7 @@ public struct PostureScorer: Equatable, Sendable { var unjudged: Set = [] let mismatch = baseline.shoulderSourceDiffers(from: shoulderSource) var metrics = metrics - if mismatch { - for measure in PostureMeasure.allCases where measure.usesShoulders { metrics[measure] = nil } - } + if mismatch { metrics.removeShoulderMeasures() } for kind in PostureMetricKind.allCases where enabled.contains(kind) { if kind == .headDrop, let lost = headLost(metrics, baseline: baseline, zone: zone) { signals.append(lost) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 7271147901..91a42bbc40 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -67,9 +67,11 @@ final class GitFileDiffTests: XCTestCase { _ = try git(["update-index", "--cacheinfo", "100644,\(oid),logo.png"], in: repo) _ = try git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "logo 2"], in: repo) let moved = try XCTUnwrap(GitWorkingTreeSource(repo: repo).load(scope: .uncommitted).files.first { $0.path == "logo.png" }?.image) - XCTAssertEqual(moved.old, reread.old, "the same name") - XCTAssertNotEqual(moved.revision, reread.revision, "but another object") + XCTAssertNotEqual(moved.old, reread.old, "another object") + XCTAssertNotEqual(moved.revision, reread.revision) XCTAssertEqual(try load(moved.old), Data([0x89, 0x50, 0x4E, 0x47, 0, 7, 7])) + XCTAssertEqual(try load(reread.old), before, "a side read before HEAD moved still loads the bytes it showed") + XCTAssertEqual(DiffImageRegistry.load(try XCTUnwrap(reread.old))?.path, "logo.png", "named by its file, for its type") } /// A picture over the budget is not read, from disk or from git; under it, the same call reads it through the @@ -83,7 +85,7 @@ final class GitFileDiffTests: XCTestCase { try bytes.write(to: repo.appendingPathComponent("big.png")) _ = try git(["add", "big.png"], in: repo) let file = DiffImage.Source.file(repo.appendingPathComponent("big.png").path) - let blob = DiffImage.Source.blob(repo: repo.path, object: ":big.png") + let blob = DiffImage.Source.blob(repo: repo.path, object: ":big.png", path: "big.png") XCTAssertNil(DiffImageRegistry.load(file, maxBytes: 4)) XCTAssertNil(DiffImageRegistry.load(blob, maxBytes: 4)) XCTAssertEqual(DiffImageRegistry.load(file, maxBytes: 9)?.data, bytes) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift index 935d6725ec..6b9ca83362 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift @@ -257,4 +257,36 @@ final class PostureShoulderGateMonitorTests: XCTestCase { XCTAssertEqual(monitor.feed.score?.shoulderSourceMismatch, true) XCTAssertEqual(monitor.feed.score?.unjudged.contains(.shoulderTilt), true) } + + /// 2D shoulders against a 3D baseline are not judged, and the face-only frame after them must not judge them + /// either: that frame names no source, and the smoother still holds the frames before it. + func testAFaceOnlyFrameDoesNotJudgeShouldersFromAnotherRequest() throws { + let clock = ClockBox() + let monitor = PostureMonitor(configuration: PostureMonitor.Configuration(), camera: nil, + cameraControl: RecordingCamera(), engine: NoEngine(), history: nil, + clock: { clock.now }, wallClock: { Date(timeIntervalSince1970: 1_800_000_000 + clock.now) }) + let face = PoseFace(box: CGRect(x: 0.4, y: 0.3, width: 0.2, height: 0.25)) + func withFace(_ sample: PoseSample) -> PoseSample { + var sample = sample + sample.face = face + return sample + } + monitor.setBaseline(try XCTUnwrap(PostureBaseline.calibrate( + upright: (0..<4).map { _ in PostureMetrics(sample: withFace(shoulderSample())) }, shoulderSource: .body3D))) + monitor.enable() + monitor.feed.beginWatching() + defer { monitor.feed.endWatching() } + for time in [0, 0.25] { + clock.now = time + monitor.ingest(withFace(shoulderSample(tilt: 0.1, time: time, source: .body2D))) + } + clock.now = 0.5 + var faceOnly = PoseSample(time: 0.5, engine: .visionBody, joints: [:], imageAspect: 1) + faceOnly.face = face + monitor.ingest(faceOnly) + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + let score = try XCTUnwrap(monitor.feed.score) + XCTAssertFalse(score.signals.contains { $0.measure.usesShoulders }, "\(score.signals)") + XCTAssertFalse(score.isBad) + } } diff --git a/src/macos/commands/permissions/index.ts b/src/macos/commands/permissions/index.ts index 8afed9ecc1..572e47406c 100644 --- a/src/macos/commands/permissions/index.ts +++ b/src/macos/commands/permissions/index.ts @@ -21,6 +21,7 @@ import { describePromptProcesses, type LocalNetworkAnswer, type OldBuild, + promptProcess, readLocalNetworkPrompts, } from "../../lib/permissions/local-network"; import { @@ -376,7 +377,7 @@ export function registerPermissionsCommand(program: Command): void { const rows = prompts.map((prompt) => ({ ...prompt, retiredAt: retiredAtOf(prompt.executable)?.toISOString() ?? null, - process: live.get(prompt.pid) ?? null, + process: promptProcess(prompt, live), })); if (options.json) { diff --git a/src/macos/lib/permissions/local-network.ts b/src/macos/lib/permissions/local-network.ts index 965a25379d..4579d7f062 100644 --- a/src/macos/lib/permissions/local-network.ts +++ b/src/macos/lib/permissions/local-network.ts @@ -298,6 +298,25 @@ export function describePromptProcesses(launcherPath: string, pids: number[]): M return described; } +/** + * The live process behind `prompt`, or null when its pid now belongs to a process that started after the prompt (the + * prompting one exited and the pid was reused). The log time drops its fraction, so the prompt fell inside + * `[at, at + 1 s)`, and a process that asked started before `at + 1 s`. + */ +export function promptProcess( + prompt: Pick, + live: Map +): PromptProcess | null { + const process = live.get(prompt.pid); + const promptMs = new Date(prompt.at.replace(" ", "T")).getTime(); + + if (!process || (Number.isFinite(promptMs) && process.started.getTime() >= promptMs + 1000)) { + return null; + } + + return process; +} + /** * Old builds a running process still executes: one `ps`, then one `lsof` over the GenesisTools processes only. * Empty when nothing runs an old build, or when either listing fails (logged). diff --git a/src/macos/lib/permissions/permissions.test.ts b/src/macos/lib/permissions/permissions.test.ts index 0cafa3962c..07b4576adc 100644 --- a/src/macos/lib/permissions/permissions.test.ts +++ b/src/macos/lib/permissions/permissions.test.ts @@ -34,6 +34,7 @@ import { localNetworkAnswer, parseLocalNetworkPrompts, parseLocalNetworkRules, + promptProcess, promptsFromLogShow, withoutPreloads, } from "./local-network"; @@ -793,6 +794,17 @@ describe("Local Network answers", () => { list.mockRestore(); } }); + + it("does not blame a process that took the pid after the prompt", () => { + const at = "2026-10-21 22:16:06"; + const running = (started: string) => + new Map([[97417, { started: new Date(started), command: "gt-cc", cwd: null, children: [] }]]); + expect(promptProcess({ at, pid: 97417 }, running("2026-10-21T21:00:00"))?.command).toBe("gt-cc"); + // the log drops the fraction: a process started within the prompt's own second still asked + expect(promptProcess({ at, pid: 97417 }, running("2026-10-21T22:16:06.900"))?.command).toBe("gt-cc"); + expect(promptProcess({ at, pid: 97417 }, running("2026-10-21T22:16:07"))).toBeNull(); + expect(promptProcess({ at, pid: 97417 }, new Map())).toBeNull(); + }); }); describe("old builds still running", () => { From 45a37d956ce16010b428817418ed9c47e53e1b81 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 00:14:32 +0200 Subject: [PATCH 21/21] fix(GenesisKit): #556 followup - a last-turn review shows changed images, the before side read from the change log's object store --- .../GenesisKit/Diff/Model/DiffModel.swift | 5 ++- .../Diff/Render/PierreWebDiffRenderer.swift | 4 +- .../Diff/Source/GitWorkingTreeSource.swift | 27 ++++++++++-- .../Diff/GitFileDiffTests.swift | 42 +++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index e292784ce9..219aa00277 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -51,8 +51,9 @@ public struct DiffImage: Codable, Hashable { /// A file on disk (the working tree). case file(String) /// `git -C cat-file blob `: the object id the diff read, or `:` (an empty rev is the - /// index) when it had none. `path` names the file, whose extension is the picture's type. - case blob(repo: String, object: String, path: String) + /// index) when it had none. `path` names the file, whose extension is the picture's type. `gitDirectory` reads + /// the object from another store (the agents change log's) instead of the repository's. + case blob(repo: String, object: String, path: String, gitDirectory: String? = nil) } public var old: Source? diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index aa089e74ca..7733ad4c5c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -549,10 +549,10 @@ enum DiffImageRegistry { values.isSymbolicLink != true, values.isRegularFile == true, let size = values.fileSize, size <= maxBytes else { return nil } return (try? Data(contentsOf: url)).map { ($0, path) } - case .blob(let repo, let object, let path): + case .blob(let repo, let object, let path, let gitDirectory): let folder = URL(fileURLWithPath: repo) let git = { (args: [String]) in - try? GenesisKitGT.provider.git(args, in: folder, input: nil, gitDirectory: nil, environment: nil) + try? GenesisKitGT.provider.git(args, in: folder, input: nil, gitDirectory: gitDirectory, environment: nil) } guard let sized = git(["cat-file", "-s", object]), sized.status == 0, let size = Int(String(decoding: sized.stdout, as: UTF8.self).trimmed), size <= maxBytes, diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index 8cd99a637f..4c1cd03129 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -342,10 +342,7 @@ public struct GitWorkingTreeSource { } let working = repo.appendingPathComponent(entry.path) - // A working-tree symlink shows as its target path, like any symlink: its picture would be a file outside. - let workingIsLink = plan.new == nil && entry.status != .deleted - && (try? working.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true - if file.skipped != nil && DiffImage.isImage(entry.path) || DiffImage.isSVG(entry.path), !workingIsLink { + if showsPicture(file, newOnDisk: plan.new == nil) { let oldPath = entry.oldPath ?? entry.path let oldName = "\(plan.old):\(oldPath)" // `HEAD:` and the index move: each side loads the object id it had when the diff was read. @@ -442,6 +439,18 @@ public struct GitWorkingTreeSource { if file.skipped == nil, file.oldContents == file.newContents { return nil } + if showsPicture(file, newOnDisk: true) { + // The before side is the change log's blob, read from its store; a before-state the store does not + // hold (or nobody recorded) has no picture. + let before = entry.beforeOid.flatMap { olds.oids[$0] } + let working = repo.appendingPathComponent(path) + let workingRevision = exists ? DiffImage.revision(ofFile: working.path) : nil + file.image = DiffImage( + old: before.map { .blob(repo: repo.path, object: $0, path: path, gitDirectory: log.objects) }, + new: exists ? .file(working.path) : nil, + revision: "\(before ?? "-") \(workingRevision ?? "-")" + ) + } let counts = Self.lineCounts(old: file.oldContents ?? "", new: file.newContents ?? "") file.additions = counts.additions file.deletions = counts.deletions @@ -452,6 +461,16 @@ public struct GitWorkingTreeSource { return Snapshot(branch: branch, base: nil, files: files.sorted { $0.path < $1.path }) } + /// Whether `file` gets a picture: a binary image (its text diff skipped) or an SVG. With its new side on disk, a + /// working-tree symlink gets none: it shows as its target path, like any symlink, and its picture would be a file + /// outside. + private func showsPicture(_ file: DiffFile, newOnDisk: Bool) -> Bool { + guard file.skipped != nil && DiffImage.isImage(file.path) || DiffImage.isSVG(file.path) else { return false } + guard newOnDisk, file.status != .deleted else { return true } + let working = repo.appendingPathComponent(file.path) + return (try? working.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) != true + } + /// `path` with every symlink resolved (`/var` is `/private/var`). A part that no longer exists (a /// deleted file, its deleted folder) is kept as written under its resolved parent. public static func realPath(_ path: String) -> String { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 91a42bbc40..740477f2d6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -74,6 +74,48 @@ final class GitFileDiffTests: XCTestCase { XCTAssertEqual(DiffImageRegistry.load(try XCTUnwrap(reread.old))?.path, "logo.png", "named by its file, for its type") } + /// A last-turn review shows a changed image too: its before side is the change log's blob, read from the log's + /// own object store, and its after side is the working-tree file. + func testALastTurnImageShowsBothSides() throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-turns-\(UUID().uuidString)") + let store = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-turns-store-\(UUID().uuidString)") + let loose = FileManager.default.temporaryDirectory.appendingPathComponent("gk-diff-turns-\(UUID().uuidString).png") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + defer { + for url in [repo, store, loose] { try? FileManager.default.removeItem(at: url) } + } + let before = Data([0x89, 0x50, 0x4E, 0x47, 0, 1, 2]) + let after = Data([0x89, 0x50, 0x4E, 0x47, 0, 3, 4, 5]) + _ = try git(["init", "-q"], in: repo) + _ = try git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "start"], + in: repo) + _ = try git(["init", "-q", "--bare", store.path], in: repo) + try before.write(to: loose) + let oid = try git(["--git-dir", store.path, "hash-object", "-w", loose.path], in: repo) + try after.write(to: repo.appendingPathComponent("logo.png")) + + final class TurnsProvider: GenesisKitGTCLIProvider { + var changes = GitWorkingTreeSource.TurnChanges(files: [], objects: "") + override func agentTurnChanges(session: String, lastTurns: Int) throws -> GitWorkingTreeSource.TurnChanges { + changes + } + } + let provider = TurnsProvider() + provider.changes = GitWorkingTreeSource.TurnChanges( + files: [.init(path: repo.appendingPathComponent("logo.png").path, beforeOid: oid, skipped: nil)], + objects: store.path) + let previous = GenesisKitGT.provider + GenesisKitGT.provider = provider + defer { GenesisKitGT.provider = previous } + + let files = try GitWorkingTreeSource(repo: repo).load(scope: .lastTurns(1), session: "s1").files + let image = try XCTUnwrap(files.first { $0.path == "logo.png" }?.image, "a last-turn image has a picture") + let load = { (source: DiffImage.Source?) in try XCTUnwrap(source.flatMap { DiffImageRegistry.load($0) }) } + XCTAssertEqual(try load(image.old).data, before, "the before side comes from the change log's store") + XCTAssertEqual(try load(image.old).path, "logo.png") + XCTAssertEqual(try load(image.new).data, after) + } + /// A picture over the budget is not read, from disk or from git; under it, the same call reads it through the /// provider (the host's, when it set one). func testAnImageOverTheBudgetIsNotLoaded() throws {