Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/PullMark/App/AppLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ enum AppLinkRouter {
SettingsOpener.open(tab: target.tab, anchor: target.anchor)
} else if let compare = AppLinks.compareTarget(url) {
AppState.deliverCompareOpen(file: compare.file, request: compare.request)
} else if CaptureChrome.isActive, url.host == "capture" {
// Screenshot-generator drive channel — only routable in
// capture runs, never in a normally launched app.
CaptureChrome.handleCaptureURL(url)
} else {
presentUnsupported(url)
}
Expand Down
105 changes: 105 additions & 0 deletions Sources/PullMark/App/CaptureChrome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import AppKit
import ObjectiveC
import WebKit

/// Screenshot-generator hook (`-pm.captureChrome 1`, argument domain
/// only): windows draw ACTIVE chrome — colored traffic lights, accent
/// selection — while the app stays backgrounded. The generator captures
/// many instances in parallel without ever stealing focus, and the
/// pixels come out identical to a frontmost window's.
///
/// AppKit and SwiftUI consult isKeyWindow / isMainWindow /
/// NSApp.isActive when drawing window chrome and resolving
/// controlActiveState; there is no supported per-window "appear
/// active" switch on macOS 13, so the getters are swizzled to return
/// true. Capture instances are driven purely through AX and
/// pid-targeted events and never see real user input, so the lie has
/// no one to confuse. Never set this flag on a normal run.
enum CaptureChrome {
static var isActive: Bool {
UserDefaults.standard.bool(forKey: "pm.captureChrome")
}

static func installIfRequested() {
guard isActive else { return }
forceTrue(NSWindow.self, #selector(getter: NSWindow.isKeyWindow))
forceTrue(NSWindow.self, #selector(getter: NSWindow.isMainWindow))
forceTrue(NSApplication.self, #selector(getter: NSApplication.isActive))
// Selection color: row views draw the accent only when
// "emphasized" (their table in the key responder chain) —
// force it so sidebar selection captures blue, not gray.
forceTrue(NSTableRowView.self, #selector(getter: NSTableRowView.isEmphasized))
// A never-activated app has no key window, so AppKit DROPS posted
// keyboard events (and menu key equivalents). Nominate one: the
// most recently created visible window that can take key status —
// panels (Open Quickly, Open) appear after the main window and
// win while they're up, which is exactly the routing a user's
// focus would produce.
let keyWindowGetter = #selector(getter: NSApplication.keyWindow)
if let method = class_getInstanceMethod(NSApplication.self, keyWindowGetter) {
let nominate: @convention(block) (AnyObject) -> NSWindow? = { _ in
// orderedWindows is front-to-back z-order: an open panel
// (Open Quickly, Open) floats above the main window and
// wins, exactly as real focus would.
NSApp.orderedWindows.first { $0.isVisible && $0.canBecomeKey }
?? NSApp.windows.last { $0.isVisible && $0.canBecomeKey }
}
method_setImplementation(method, imp_implementationWithBlock(nominate))
}
// Make new windows GENUINELY key and main. A background app's
// window can hold real key status without the app activating —
// proof: the Open Quickly panel scene always photographed with
// colored lights, because closing the panel handed real key
// status back to the main window. Faked become-key
// notifications were tried twice (once-per-window and repeated)
// and both raced AppKit into gray traffic lights the moment
// anything re-laid-out the titlebar. Real state is
// resize-proof, and it also gives posted keyboard events a real
// key window to land in.
let blessed = NSHashTable<NSWindow>.weakObjects()
let blessAll = {
for window in NSApp.windows where window.isVisible && !blessed.contains(window) {
blessed.add(window)
if window.canBecomeMain { window.makeMain() }
if window.canBecomeKey { window.makeKey() }
if window.firstResponder === window,
let responder = window.initialFirstResponder ?? window.contentView {
window.makeFirstResponder(responder)
}
}
}
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in blessAll() }
}

private static func forceTrue(_ cls: AnyClass, _ selector: Selector) {
guard let method = class_getInstanceMethod(cls, selector) else { return }
let alwaysTrue: @convention(block) (AnyObject) -> Bool = { _ in true }
method_setImplementation(method, imp_implementationWithBlock(alwaysTrue))
}

/// pullmark://capture/… — the generator's drive channel for the few
/// page interactions no accessibility or keyboard path can reach.
/// Routed only when the capture flag is set (see AppLinkRouter).
@MainActor
static func handleCaptureURL(_ url: URL) {
guard url.path == "/reveal",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let lineText = components.queryItems?.first(where: { $0.name == "line" })?.value,
let line = Int(lineText)
else { return }
let webView = NSApp.orderedWindows.lazy
.compactMap { $0.contentView.flatMap(findWebView) }
.first
webView?.evaluateJavaScript(
"window.__pmRevealBlock && __pmRevealBlock(\(line));",
completionHandler: nil)
}

private static func findWebView(in view: NSView) -> WKWebView? {
if let webView = view as? WKWebView { return webView }
for subview in view.subviews {
if let found = findWebView(in: subview) { return found }
}
return nil
}
}
22 changes: 21 additions & 1 deletion Sources/PullMark/App/DemoMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ enum DemoMode {

/// The isolated defaults domain demo launches read and write — never
/// the app's real `app.pullmark.PullMark` domain.
static let defaultsSuiteName = "app.pullmark.PullMark.demo"
/// Per-process: the screenshot generator runs MANY demo instances in
/// parallel, each wiping its suite at startup — with a shared name,
/// every launch nuked every other instance's live state (@AppStorage
/// observed the wipe and reset blame mid-scene). Demo state is
/// throwaway by definition, so nothing is lost by never sharing it.
static let defaultsSuiteName = "app.pullmark.PullMark.demo.\(ProcessInfo.processInfo.processIdentifier)"
}

extension UserDefaults {
Expand All @@ -37,6 +42,21 @@ extension UserDefaults {
/// on every demo launch, so nothing persists between demo runs either.
static let pullmark: UserDefaults = {
guard DemoMode.active else { return .standard }
// Janitor: per-pid suites (see defaultsSuiteName) would leave a
// plist per past demo instance — clear the ones whose owner is
// gone. kill(pid, 0) probes liveness without signaling, so
// parallel LIVE instances are never touched.
let preferences = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Preferences")
let prefix = "app.pullmark.PullMark.demo."
if let entries = try? FileManager.default.contentsOfDirectory(atPath: preferences.path) {
for entry in entries where entry.hasPrefix(prefix) && entry.hasSuffix(".plist") {
let stem = String(entry.dropFirst(prefix.count).dropLast(".plist".count))
if let pid = Int32(stem), kill(pid, 0) != 0 {
UserDefaults().removePersistentDomain(forName: String(entry.dropLast(".plist".count)))
}
}
}
let name = DemoMode.defaultsSuiteName
guard let suite = UserDefaults(suiteName: name) else { return .standard }
suite.removePersistentDomain(forName: name)
Expand Down
9 changes: 7 additions & 2 deletions Sources/PullMark/App/DemoSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,11 @@ enum DemoSession {
]
state.checks = [
CheckItem(name: "build", group: "CI", state: .passed,
detailsUrl: nil, isRequired: true, durationLabel: "1m 32s"),
detailsUrl: nil, isRequired: true,
durationLabel: CheckItem.durationLabel(seconds: 92)),
CheckItem(name: "docs-links", group: "CI", state: .passed,
detailsUrl: nil, isRequired: false, durationLabel: "48s"),
detailsUrl: nil, isRequired: false,
durationLabel: CheckItem.durationLabel(seconds: 48)),
CheckItem(name: "spellcheck", group: "CI", state: .skipped,
detailsUrl: nil, isRequired: false, durationLabel: nil),
CheckItem(name: "license/cla", group: nil, state: .passed,
Expand Down Expand Up @@ -718,6 +720,9 @@ enum DemoSession {
.appendingPathComponent("PullMark Demo", isDirectory: true)
try? FileManager.default.removeItem(at: root)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
// Titlebars, tooltips, and Open Quickly rows show the plausible
// "~/Documents/PullMark Demo" instead of the raw temp path.
PathAbbreviator.demoRoot = root.path
var docs: [LocalFile] = []
var texts = [(path: String, text: String)]()
for path in [gettingStartedPath, calibrationPath, exportFormatsPath] {
Expand Down
19 changes: 12 additions & 7 deletions Sources/PullMark/App/PullMarkApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct PullMarkApp: App {
.keyboardShortcut(shortcuts.keyboardShortcut(for: .prResult))
.disabled(!prFileSelected)
Button(diffLayoutRaw == PRFileView.DiffLayout.inline.rawValue
? "Side-by-Side Diffs" : "Inline Diffs") {
? String(localized: "Side-by-Side Diffs") : String(localized: "Inline Diffs")) {
state?.send(.flipDiffLayout)
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .prFlipLayout))
Expand Down Expand Up @@ -370,16 +370,15 @@ struct PullMarkApp: App {
.disabled(!marginNotesEnabled || activeLocalFileURL == nil
|| state?.sourceViewVisible == true)
.help(marginNotesEnabled
? "Leave a note on the block you're reading — it's saved into the "
+ "file as a <!-- note --> comment"
: "Turn on margin notes in Settings → Experimental")
? String(localized: "Leave a note on the block you're reading — it's saved into the file as a <!-- note --> comment")
: String(localized: "Turn on margin notes in Settings → Experimental"))
Button("File Margin Note…") { state?.send(.addFileMarginNote) }
.keyboardShortcut(shortcuts.keyboardShortcut(for: .addFileMarginNote))
.disabled(!marginNotesEnabled || activeLocalFileURL == nil
|| state?.sourceViewVisible == true)
.help(marginNotesEnabled
? "Leave a note about the whole document, at the top"
: "Turn on margin notes in Settings → Experimental")
? String(localized: "Leave a note about the whole document, at the top")
: String(localized: "Turn on margin notes in Settings → Experimental"))
}
CommandGroup(replacing: .help) {
Button("Release Notes") {
Expand Down Expand Up @@ -580,7 +579,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Needed when launched via `swift run` (no bundle): become a regular,
// focusable app with a menu bar.
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// Capture instances draw active chrome without focus and must
// NOT activate — the whole point is not stealing the user's
// input while the generator drives many instances at once.
CaptureChrome.installIfRequested()
if !CaptureChrome.isActive {
NSApp.activate(ignoringOtherApps: true)
}
Appearance.applyCurrent()
// Pin the launch language before any UI (or the user) can
// change the stored value — the Settings row compares against it.
Expand Down
6 changes: 3 additions & 3 deletions Sources/PullMark/Core/ContentWidth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ enum ContentWidth: String, CaseIterable, Identifiable {

var descriptor: String {
switch self {
case .standard: return "A classic reading measure"
case .wide: return "Longer lines, more on screen"
case .full: return "Text uses the whole window"
case .standard: return String(localized: "A classic reading measure")
case .wide: return String(localized: "Longer lines, more on screen")
case .full: return String(localized: "Text uses the whole window")
}
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/PullMark/Core/PathAbbreviator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ import Foundation
/// UI (titlebar subtitles, tooltips, search-result subtitles). Pure string
/// logic so it stays unit-testable; pass `home` explicitly in tests.
enum PathAbbreviator {
/// Demo mode only: the demo session's real files live in a temp dir,
/// and `/var/folders/…/T/PullMark Demo` in the titlebar ruins every
/// published screenshot. Set by DemoSession when it writes the docs;
/// display shows the path a real user would plausibly have. Never
/// set outside demo mode — real paths are never masked.
static var demoRoot: String?
static let demoDisplayRoot = "~/Documents/PullMark Demo"

static func abbreviate(_ path: String, home: String = NSHomeDirectory()) -> String {
if let demoRoot, path.hasPrefix(demoRoot) {
return demoDisplayRoot + path.dropFirst(demoRoot.count)
}
guard !home.isEmpty, home != "/" else { return path }
let home = home.hasSuffix("/") ? String(home.dropLast()) : home
if path == home { return "~" }
Expand Down
12 changes: 6 additions & 6 deletions Sources/PullMark/Core/ReviewControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ enum ReviewVerdict: String, CaseIterable, Identifiable {

var label: String {
switch self {
case .comment: return "Comment"
case .approve: return "Approve"
case .requestChanges: return "Request changes"
case .comment: return String(localized: "Comment")
case .approve: return String(localized: "Approve")
case .requestChanges: return String(localized: "Request changes")
}
}

var help: String {
switch self {
case .comment: return "Submit general feedback without explicit approval"
case .approve: return "Approve merging these changes"
case .requestChanges: return "Ask for changes before this can merge"
case .comment: return String(localized: "Submit general feedback without explicit approval")
case .approve: return String(localized: "Approve merging these changes")
case .requestChanges: return String(localized: "Ask for changes before this can merge")
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/PullMark/Core/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ enum Theme: String, CaseIterable, Identifiable {
/// One-line descriptor shown under the theme's preview card.
var descriptor: String {
switch self {
case .github: return "The classic look, exactly as on github.com"
case .editorial: return "Bookish serif headers on warm paper"
case .terminal: return "Monospace with a phosphor-green accent"
case .github: return String(localized: "The classic look, exactly as on github.com")
case .editorial: return String(localized: "Bookish serif headers on warm paper")
case .terminal: return String(localized: "Monospace with a phosphor-green accent")
}
}

Expand Down
19 changes: 14 additions & 5 deletions Sources/PullMark/GitHub/PRCockpit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,26 @@ struct CheckItem: Equatable, Identifiable {
}
}

/// "58s" / "3m 12s" / "1h 4m" between two ISO-8601 stamps.
/// "58s" / "3m 12s" / "1h 4m" between two ISO-8601 stamps —
/// abbreviated in the launch language's own units (ja: 3分12秒).
static func durationLabel(startedAt: String?, completedAt: String?) -> String? {
guard let startedAt, let completedAt,
let start = GitHubDate.parse(startedAt),
let end = GitHubDate.parse(completedAt) else { return nil }
let seconds = Int(end.timeIntervalSince(start).rounded())
return durationLabel(seconds: seconds)
}

/// Seconds-based core (the demo session feeds it directly so demo
/// checks render native units too, not baked English strings).
static func durationLabel(seconds: Int) -> String? {
guard seconds >= 0 else { return nil }
if seconds < 60 { return "\(seconds)s" }
let minutes = seconds / 60
if minutes < 60 { return "\(minutes)m \(seconds % 60)s" }
return "\(minutes / 60)h \(minutes % 60)m"
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = seconds < 60 ? [.second]
: seconds < 3600 ? [.minute, .second]
: [.hour, .minute]
return formatter.string(from: TimeInterval(seconds))
}
}

Expand Down
9 changes: 9 additions & 0 deletions Sources/PullMark/Rendering/MarkdownWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ struct MarkdownWebView: NSViewRepresentable {
forURLScheme: RemoteResourceSchemeHandler.scheme)
configuration.setURLSchemeHandler(context.coordinator.attachmentHandler,
forURLScheme: AttachmentSchemeHandler.scheme)
// Capture runs only: parallel instances overlap on cascaded
// frames, and WebKit suspends a fully occluded window's
// WebContent process — sometimes before first paint, which
// captured as a permanently blank pane. Keep rendering alive
// regardless of visibility while the generator drives us.
if CaptureChrome.isActive {
configuration.preferences.setValue(
false, forKey: "pageVisibilityBasedProcessSuppressionEnabled")
}
let webView = interactive
? ZoomableWebView(frame: .zero, configuration: configuration)
: PassthroughWebView(frame: .zero, configuration: configuration)
Expand Down
22 changes: 22 additions & 0 deletions Sources/PullMark/Resources/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5113,6 +5113,28 @@
var parts = host.getAttribute("data-pm-lines").split("-");
reveal(host, parseInt(parts[0], 10), parseInt(parts[1], 10));
});

// Screenshot-generator hook (pullmark://capture/reveal, gated
// Swift-side to capture runs): reveal the block containing a
// source line. Blocks aren't individually reachable through the
// accessibility tree — the click listener above is delegated —
// so scene scripts can't activate one without a real cursor.
window.__pmRevealBlock = function (line) {
// Mirror the click path: close any open editor first (edit-mode
// entry auto-reveals the focused block) or two editors stack.
if (revealState) { commitReveal(); }
var host = null;
content.querySelectorAll(".pm-editable[data-pm-lines]").forEach(function (el) {
if (host || el.style.display === "none") { return; }
var parts = el.getAttribute("data-pm-lines").split("-");
if (parseInt(parts[0], 10) <= line && line <= parseInt(parts[1], 10)) {
host = el;
}
});
if (!host) { return; }
var parts = host.getAttribute("data-pm-lines").split("-");
reveal(host, parseInt(parts[0], 10), parseInt(parts[1], 10));
};
}

var blameAnnotated = payload.blame && payload.blame.length && linesAnnotated;
Expand Down
Loading
Loading