diff --git a/scripts/build-widget-preview.ts b/scripts/build-widget-preview.ts index 6da959356d..b379bd4613 100644 --- a/scripts/build-widget-preview.ts +++ b/scripts/build-widget-preview.ts @@ -1,13 +1,24 @@ import { createHash } from "node:crypto"; +import { existsSync, statSync } from "node:fs"; import { cp, mkdir, rename } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { buildDiffViewer } 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"; 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 +243,53 @@ 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 (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 = 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") + ); + } + 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) => + logger.warn({ error, path }, "stale preview record not unregistered") + ); + } + logger.info( + { + kept: stamps.length - deleted.length, + deleted: deleted.length, + 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/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 () => { 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/GenesisKit/Diff/Model/DiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift index 3e3eaff760..219aa00277 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/DiffModel.swift @@ -18,6 +18,9 @@ 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, 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 { (path as NSString).lastPathComponent @@ -28,7 +31,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 +41,51 @@ 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 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. `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? + 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 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) { + 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`. + 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" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index 64b3246990..7733ad4c5c 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,12 @@ 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 { image in + BridgeImage(old: image.old.map { DiffImageRegistry.url($0, revision: image.revision) }, + new: image.new.map { DiffImageRegistry.url($0, revision: image.revision) }) + } } } @@ -502,11 +514,83 @@ 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] = [:] + + /// 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)" + } + + static func source(_ token: String) -> DiffImage.Source? { + lock.withLock { sources[token] } + } + + /// 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): + 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 path, let gitDirectory): + let folder = URL(fileURLWithPath: repo) + let git = { (args: [String]) in + 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, + let blob = git(["cat-file", "blob", object]), blob.status == 0 else { return nil } + return (blob.stdout, path) + } + } + + 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" + // Shown through , where an SVG's scripts never run. + case "svg": return "image/svg+xml" + 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 +614,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..4c1cd03129 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,6 +341,24 @@ public struct GitWorkingTreeSource { } } + let working = repo.appendingPathComponent(entry.path) + 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. + 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: olds.oids[oldName] ?? oldName, path: oldPath), + new: entry.status == .deleted ? nil + : 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 ?? "-")" + ) + } + if let count = counts[entry.path] { file.additions = count.additions file.deletions = count.deletions @@ -421,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 @@ -431,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 { @@ -642,24 +682,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]))) } @@ -677,7 +727,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..= 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 + } + + /// The click count of the event that activated a row: 1 unless it is a mouse event. A row activated from the + /// keyboard has a key event as the current one, and `clickCount` raises on any event that is not a mouse event. + static func clickCount(of event: NSEvent?) -> 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. struct FileTreeRowView: View, Equatable { let row: FileTreeRow @@ -644,11 +669,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: FileTreeClick.clickCount(of: NSApp.currentEvent)) { + 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 +724,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/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..ff5ddd2415 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? @@ -237,13 +257,20 @@ 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 } - score = scorer.score(smoothed, against: baseline) + 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 +293,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..751d6f50ac 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 @@ -159,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 0ef7434b5f..546605d540 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,16 @@ 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 { 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) @@ -281,10 +319,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..ba9e461f85 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitPosture/Posture/PostureShoulderGate.swift @@ -0,0 +1,49 @@ +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 = [] + /// 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. 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) { + 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)) + 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 = [] + lastSource = nil + } + + 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/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 f51906462d..740477f2d6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -15,6 +15,162 @@ 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")) + 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($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) + + 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") + + // 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)) + + // 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) + 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 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 { + 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", path: "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 { for (name, background) in [(NSAppearance.Name.aqua, 1.0), (.darkAqua, 0.0)] { let appearance = try XCTUnwrap(NSAppearance(named: name)) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/FileTreeTests.swift index 7c51925d77..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 @@ -55,6 +56,26 @@ 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) + } + + /// 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/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", "-", diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift new file mode 100644 index 0000000000..6b9ca83362 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PostureShoulderSourceTests.swift @@ -0,0 +1,292 @@ +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 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) + 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) + } + + /// 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/GenesisTools/Info.plist b/src/macos/GenesisTools/Info.plist index 9fc32bd0de..ceab8ba559 100644 --- a/src/macos/GenesisTools/Info.plist +++ b/src/macos/GenesisTools/Info.plist @@ -20,6 +20,10 @@ 1 LSMinimumSystemVersion 13.0 + + LSUIElement + NSHighResolutionCapable @@ -54,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. + 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/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 { 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..74254af4ad --- /dev/null +++ b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift @@ -0,0 +1,344 @@ +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 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, no key 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 + } + + /// 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 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 !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 + } + 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 window = focusedWindow(note.userInfo, own: getpid()) else { return } + MainActor.assumeIsolated { + 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 + } + }) + 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. + 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, 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)), + (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) + } + } + + /// `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 { + // 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 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. + guard let first = Session.start(frozen, backwards: backwards, modifiers: shortcut.modifiers) else { return false } + next = first + } + // 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 + } + 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 \(window.pid)") + } + } + + 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 requested = requestedSession(info, pid: getpid(), window: number) else { + raise(number, reason: "asked by another face", cycling: false) + return + } + // 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(requested.modifiers) else { + session = nil + raise(number, reason: "asked by another face, run ended", cycling: false) + return + } + 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 } + 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) + } + shown.makeKeyAndOrderFront(nil) + NSApp.activate() + } +} 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..863d5c1141 --- /dev/null +++ b/src/macos/GenesisTools/Sources/Host/LocalNetworkWatch.swift @@ -0,0 +1,434 @@ +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? + /// 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 { + 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? + /// 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)? + + /// 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.. 0 { kill(LocalNetworkWatch.streamPid, SIGTERM) } } + } + 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 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: 130) + list = try JSONDecoder().decode(LocalNetworkPromptList.self, from: Data(result.stdout.utf8)) + } catch { + HubPerf.log("localNetwork: could not describe pid \(pid): \(error)") + } + 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) { + 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.headline) + .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/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 133aa6ec6c..2bfd1acb55 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 { @@ -71,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 "\(unreadableTable) (\(detail))" } let holders = Set(processes.filter { process in process.endpoints?.contains(where: { $0.isPeer(of: stdout) }) == true @@ -89,10 +110,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 { @@ -211,16 +245,53 @@ 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.. 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" } - 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 { - return "the user's process list changed during vault recipient inspection" + guard let tables = try settledTables(rounds: 16, list: userPids, + inspect: { processFDs($0, output: output) }, exited: hasExited) else { + 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/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/LocalNetworkWatchTests.swift b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift new file mode 100644 index 0000000000..1af677f4c0 --- /dev/null +++ b/src/macos/GenesisTools/Tests/LocalNetworkWatchTests.swift @@ -0,0 +1,37 @@ +@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\"")) + let other = "2026-10-22 16:34:10.002 I nehelper[402:377e9a1] [com.apple.networkextension:] Found new path " + + "/Applications/Other.app/Contents/MacOS/Other for PID 51200, allowing a new prompt" + 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 9b12bfbb6f..90034ef535 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -30,8 +30,74 @@ 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() } + // run() returns after posix_spawn, so /bin/sh is already the running image. + let beforeExec = 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) + } + 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)), @@ -53,8 +119,51 @@ 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 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 = 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) @@ -83,25 +192,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)] { diff --git a/src/macos/GenesisTools/Tests/WindowCycleTests.swift b/src/macos/GenesisTools/Tests/WindowCycleTests.swift new file mode 100644 index 0000000000..826516e7fc --- /dev/null +++ b/src/macos/GenesisTools/Tests/WindowCycleTests.swift @@ -0,0 +1,107 @@ +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)]) + } + + 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)") + } + + /// 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 1cf181561d..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"; @@ -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 { @@ -344,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 @@ -467,6 +517,9 @@ function viewOptions(): CodeViewOptions { renderHeaderPrefix(fileDiff) { return foldChevron(fileDiff.name); }, + renderHeaderFilenameSuffix(fileDiff) { + return svgModeButton(fileDiff.name); + }, }; } @@ -637,6 +690,23 @@ 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. 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(); @@ -660,6 +730,11 @@ function annotationsFor(fileId: string): DiffLineAnnotation[] { lineNumber: group[0].endLine, metadata: { kind: "comments", comments: group }, })); + const entry = imagesByFile.get(fileId); + + if (entry && (!entry.svg || svgShownAsImage(fileId))) { + annotations.unshift({ side: "additions", lineNumber: 1, metadata: { kind: "image", image: entry.image } }); + } if (composer && composer.fileId === fileId) { annotations.push({ @@ -676,19 +751,30 @@ function annotationsFor(fileId: string): DiffLineAnnotation[] { const parsed = new Map(); function toItem(file: BridgeFile): CodeViewItem { + if (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 { @@ -2327,6 +2413,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 +2465,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) { @@ -3260,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/README.md b/src/macos/README.md index 9d797f83fb..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. +**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 diff --git a/src/macos/commands/permissions/index.ts b/src/macos/commands/permissions/index.ts index 094aa1a016..572e47406c 100644 --- a/src/macos/commands/permissions/index.ts +++ b/src/macos/commands/permissions/index.ts @@ -3,6 +3,7 @@ import { dirname } from "node:path"; import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { ui } from "@genesiscz/utils/cli/ui"; +import { retiredAtOf } from "@genesiscz/utils/genesis-tools/app-binary"; import { out } from "@genesiscz/utils/logger"; import { requestFullDiskAccess } from "@genesiscz/utils/macos/full-disk-access"; import { @@ -16,7 +17,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, + promptProcess, + readLocalNetworkPrompts, +} from "../../lib/permissions/local-network"; import { ALL_PART_IDS, APP_PARTS, @@ -346,6 +353,70 @@ 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); + + 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, + prompts.map((prompt) => prompt.pid) + ); + const rows = prompts.map((prompt) => ({ + ...prompt, + retiredAt: retiredAtOf(prompt.executable)?.toISOString() ?? null, + process: promptProcess(prompt, live), + })); + + 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/clones/discover.test.ts b/src/macos/lib/clones/discover.test.ts index 7d090cdc37..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, @@ -137,6 +138,56 @@ 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("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 f80aade2e0..8f7d314fb4 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,32 @@ 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.endsWith(sep) ? parent : 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 +181,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 }); diff --git a/src/macos/lib/permissions/app.ts b/src/macos/lib/permissions/app.ts index a31578736d..b516f08ce8 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`. */ @@ -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 b0a5a5681b..4579d7f062 100644 --- a/src/macos/lib/permissions/local-network.ts +++ b/src/macos/lib/permissions/local-network.ts @@ -1,8 +1,10 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; +import { isGenesisToolsBinary } from "@genesiscz/utils/genesis-tools/app-binary"; 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 +175,148 @@ 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 && isGenesisToolsBinary(match[2]) + ? [{ at: match[1], pid: Number(match[3]), executable: match[2] }] + : []; + }); +} + +/** 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", + "show", + "--last", + since, + "--style", + "compact", + "--info", + "--predicate", + 'process == "nehelper" AND eventMessage CONTAINS "allowing a new prompt"', + ], + { stdout: "pipe", stderr: "pipe", timeout: 120_000 } + ); + return promptsFromLogShow({ since, exitCode: proc.exitCode, stdout: proc.stdout, stderr: proc.stderr }); +} + +/** + * 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, stderr: new TextDecoder().decode(stderr).slice(0, 300) }, + "local network: log show failed" + ); + return null; + } + + return parseLocalNetworkPrompts(new TextDecoder().decode(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 { + if (pids.length === 0) { + return new 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; +} + +/** + * 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). @@ -192,10 +336,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..07b4576adc 100644 --- a/src/macos/lib/permissions/permissions.test.ts +++ b/src/macos/lib/permissions/permissions.test.ts @@ -1,15 +1,24 @@ -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 { + GENESIS_TOOLS_LAUNCHER_SUFFIX, + isGenesisToolsBinary, + retiredAtOf, +} from "@genesiscz/utils/genesis-tools/app-binary"; 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, @@ -18,7 +27,17 @@ import { timedSteps, } from "./app"; import { isPermissionKind, PERMISSION_KINDS, permissionStatusLabel } from "./kinds"; -import { type AppProcess, groupOldBuilds, localNetworkAnswer, parseLocalNetworkRules } from "./local-network"; +import { + type AppProcess, + describePromptProcesses, + groupOldBuilds, + localNetworkAnswer, + parseLocalNetworkPrompts, + parseLocalNetworkRules, + promptProcess, + promptsFromLogShow, + withoutPreloads, +} from "./local-network"; import { ALL_PART_IDS, bundleStepsFor, @@ -638,6 +657,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; @@ -683,6 +749,62 @@ 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)", + "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([]); + 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(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"); + }); + + 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(); + } + }); + + 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", () => { 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; +}