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
5 changes: 5 additions & 0 deletions .changeset/3b0b7691.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: minor
---

Replace per-window `SCStream` with polled `CGSHWCaptureWindowList` (private SkyLight) for live tile previews. Window enumeration also moves off ScreenCaptureKit to `CGWindowListCopyWindowInfo`, eliminating the SCK setup race fixed in #18 and dropping the CPU/GPU cost of N concurrent streams. Screen Recording permission is still required — current macOS gates `CGSHWCaptureWindowList` on it and still attributes capture to the app via the menu-bar indicator. See issue #21.
8 changes: 4 additions & 4 deletions Sources/cmdcmd/LabelAssigner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ final class LabelAssigner {
/// in the assignment map keep their existing prefix; new tiles claim the
/// next non-colliding prefix.
func assign(_ tiles: [Tile]) -> [CGWindowID: String] {
let presentIDs = Set(tiles.map { CGWindowID($0.scWindow.windowID) })
let presentIDs = Set(tiles.map { CGWindowID($0.window.windowID) })
entries = entries.filter { presentIDs.contains($0.key) }

var used: [String: String] = [:] // prefix -> appKey
for (_, e) in entries { used[e.prefix] = e.appKey }

for tile in tiles {
let id = CGWindowID(tile.scWindow.windowID)
let id = tile.window.windowID
if entries[id] != nil { continue }
let appName = tile.scWindow.owningApplication?.applicationName ?? "?"
let appKey = tile.scWindow.owningApplication?.bundleIdentifier ?? appName
let appName = tile.window.applicationName.isEmpty ? "?" : tile.window.applicationName
let appKey = tile.window.bundleIdentifier ?? appName
let natural = Self.naturalPrefix(appName: appName)

guard !natural.isEmpty else { continue }
Expand Down
125 changes: 88 additions & 37 deletions Sources/cmdcmd/Onboarding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ enum Permission {
}
}

var pendingGuidance: String {
switch self {
case .screenRecording:
"Find ⌘ ⌘ in the list and turn it on. We'll detect it and relaunch automatically."
case .accessibility:
"Find ⌘ ⌘ in the list and turn it on. We'll detect it and relaunch automatically."
}
}

var settingsURL: URL {
switch self {
case .screenRecording:
Expand Down Expand Up @@ -54,7 +63,7 @@ enum Permission {
final class Onboarding {
private var window: NSWindow?
private var rows: [Permission: PermissionRow] = [:]
private var continueButton: NSButton!
private var pollTimer: Timer?
private let onComplete: () -> Void
private let permissions: [Permission] = [.screenRecording, .accessibility]

Expand All @@ -70,19 +79,18 @@ final class Onboarding {

private func present() {
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 400),
contentRect: NSRect(x: 0, y: 0, width: 480, height: 100),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
w.title = "Welcome to ⌘ ⌘"
w.center()
w.isReleasedWhenClosed = false

let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 16
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false

let heading = NSTextField(labelWithString: "Two permissions to get started")
Expand All @@ -94,81 +102,103 @@ final class Onboarding {
)
blurb.font = NSFont.systemFont(ofSize: 13)
blurb.textColor = .secondaryLabelColor
blurb.preferredMaxLayoutWidth = 472
blurb.preferredMaxLayoutWidth = 432
stack.addArrangedSubview(blurb)

for p in permissions {
let row = PermissionRow(permission: p) { [weak self] in
p.request()
NSWorkspace.shared.open(p.settingsURL)
self?.refresh()
self?.grantTapped(p)
}
rows[p] = row
stack.addArrangedSubview(row.view)
}

continueButton = NSButton(title: "Continue", target: self, action: #selector(didTapContinue))
continueButton.bezelStyle = .rounded
continueButton.keyEquivalent = "\r"
continueButton.translatesAutoresizingMaskIntoConstraints = false

let bottomRow = NSView()
bottomRow.translatesAutoresizingMaskIntoConstraints = false
bottomRow.addSubview(continueButton)
NSLayoutConstraint.activate([
continueButton.trailingAnchor.constraint(equalTo: bottomRow.trailingAnchor),
continueButton.topAnchor.constraint(equalTo: bottomRow.topAnchor),
continueButton.bottomAnchor.constraint(equalTo: bottomRow.bottomAnchor),
bottomRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 28),
])
stack.addArrangedSubview(bottomRow)

let content = NSView()
content.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
bottomRow.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
bottomRow.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24),
])
for r in rows.values {
r.view.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true
r.view.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true
}
w.contentView = content
content.layoutSubtreeIfNeeded()
w.setContentSize(content.fittingSize)
w.center()
window = w

NSApp.activate(ignoringOtherApps: true)
w.makeKeyAndOrderFront(nil)
refresh()
startPolling()
}

private func grantTapped(_ p: Permission) {
p.request()
NSWorkspace.shared.open(p.settingsURL)
rows[p]?.setPending(true)
}

private func startPolling() {
pollTimer?.invalidate()
pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refresh()
}
}

@discardableResult
private func refresh() -> Bool {
private func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}

private func refresh() {
var allGranted = true
for p in permissions {
let granted = p.granted()
rows[p]?.setGranted(granted)
if !granted { allGranted = false }
}
return allGranted
if allGranted {
stopPolling()
relaunchOrComplete()
}
}

@objc private func didTapContinue() {
guard refresh() else { return }
/// Accessibility (and Screen Recording, when it was here) often need a
/// fresh process before global event taps / capture sessions register
/// against the new TCC state. Relaunching is the only reliable way to get
/// a clean install. If the relaunch fails for any reason, fall back to
/// inline `onComplete` so the user isn't stuck.
private func relaunchOrComplete() {
let bundleURL = Bundle.main.bundleURL
let config = NSWorkspace.OpenConfiguration()
config.createsNewApplicationInstance = true
window?.orderOut(nil)
window = nil
onComplete()
NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { [weak self] app, error in
DispatchQueue.main.async {
if app != nil, error == nil {
NSApp.terminate(nil)
} else {
Log.write("onboarding relaunch failed: \(error?.localizedDescription ?? "nil"); continuing in-process")
self?.window = nil
self?.onComplete()
}
}
}
}
}

private final class PermissionRow {
let view: NSView
private let dot: NSView
private let button: NSButton
private let rationale: NSTextField
private let permission: Permission
private var pending = false

init(permission: Permission, onGrant: @escaping () -> Void) {
self.permission = permission
Expand All @@ -191,7 +221,8 @@ private final class PermissionRow {
let rationale = NSTextField(wrappingLabelWithString: permission.rationale)
rationale.font = NSFont.systemFont(ofSize: 12)
rationale.textColor = .secondaryLabelColor
rationale.preferredMaxLayoutWidth = 340
rationale.preferredMaxLayoutWidth = 300
self.rationale = rationale

let textStack = NSStackView(views: [title, rationale])
textStack.orientation = .vertical
Expand All @@ -201,6 +232,7 @@ private final class PermissionRow {

let btn = ButtonWrapper.make(title: "Grant", action: onGrant)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setContentHuggingPriority(.required, for: .horizontal)
self.button = btn

container.addSubview(dot)
Expand All @@ -227,8 +259,27 @@ private final class PermissionRow {

func setGranted(_ granted: Bool) {
dot.layer?.backgroundColor = (granted ? NSColor.systemGreen : NSColor.systemOrange).cgColor
button.title = granted ? "Granted" : "Grant"
button.isEnabled = !granted
if granted {
pending = false
button.title = "Granted"
button.isEnabled = false
rationale.stringValue = permission.rationale
} else if pending {
button.title = "Waiting…"
button.isEnabled = false
} else {
button.title = "Grant"
button.isEnabled = true
}
}

func setPending(_ value: Bool) {
pending = value
if value {
button.title = "Waiting…"
button.isEnabled = false
rationale.stringValue = permission.pendingGuidance
}
}
}

Expand Down
Loading
Loading