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
135 changes: 135 additions & 0 deletions Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,139 @@ final class TmuxFocusTests: XCTestCase {
XCTAssertNil(actual?.socket) // empty socket → default socket
XCTAssertNil(actual?.hostBundleID) // no lcTerminal → no host raise
}

// MARK: - iTerm2 session matching

private func row(_ guid: String, tty: String = "", pane: String = "", role: String = "",
paneTitle: String = "", autoName: String = "", name: String = ""
) -> AppActivator.ITermSessionRow {
AppActivator.ITermSessionRow(guid: guid, tty: tty, tmuxPane: pane, tmuxRole: role,
tmuxPaneTitle: paneTitle, autoName: autoName, name: name)
}

private let controlClient = AppActivator.TmuxClient(controlMode: true, activity: 5, pid: 29208,
tty: "/dev/ttys035")

private func match(_ pane: String, _ title: String?,
clients: [AppActivator.TmuxClient]?,
_ rows: [AppActivator.ITermSessionRow]) -> String? {
AppActivator.matchITermSession(pane: pane, title: title, clients: clients, in: rows)
}

func test_parseITermSessions_splitsOnUnitSeparatorAndBlanksMissingValue() {
let raw = ["G1", "missing value", "6", "client", "✳ a|b", "tmux", "✳ a|b (claude)"]
.joined(separator: "\u{1F}") + "\nshort\u{1F}row\n"

let actual = AppActivator.parseITermSessions(raw)

XCTAssertEqual(actual, [row("G1", pane: "6", role: "client", paneTitle: "✳ a|b",
autoName: "tmux", name: "✳ a|b (claude)")])
}

func test_parseTmuxClients_mostRecentlyActiveFirst() {
let raw = "1 100 29208 /dev/ttys035\n0 300 4411 /dev/ttys004\nbad line\n"

let actual = AppActivator.parseTmuxClients(raw)

XCTAssertEqual(actual.map(\.pid), [4411, 29208])
XCTAssertEqual(actual.map(\.controlMode), [false, true])
XCTAssertEqual(actual.first?.tty, "/dev/ttys004")
}

func test_applyClientEnvironment_readsHostAndITermSession() {
// The client's env names the terminal drawing it now, even when the
// agent's env (LC_TERMINAL from when the pane started) says otherwise.
let ps = """
29208 tmux -CC __CFBundleIdentifier=com.googlecode.iterm2 ITERM_SESSION_ID=w0t0p0:GW TERM_PROGRAM=iTerm.app
4411 tmux attach TERM_PROGRAM=Apple_Terminal
"""
let clients = [controlClient,
AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004")]

let actual = AppActivator.applyClientEnvironment(ps, to: clients)

XCTAssertEqual(actual.map(\.hostBundleID), ["com.googlecode.iterm2", "com.apple.Terminal"])
XCTAssertEqual(actual.map(\.iTermSessionGUID), ["GW", nil])
}

func test_match_ccPaneNumberWinsOverASharedTitle() {
// "Name (Job)" titles and a profile-named autoName: only the pane number
// tells these two apart.
let rows = [
row("A", pane: "21", paneTitle: "✳ same", autoName: "tmux", name: "✳ same (claude)"),
row("B", pane: "43", paneTitle: "✳ same", autoName: "tmux", name: "✳ same (claude)"),
]
XCTAssertEqual(match("%43", "✳ same", clients: [controlClient], rows), "B")
}

func test_match_piPaneFromSidecarMatchesByNumber() {
let rows = [row("P", pane: "38", paneTitle: "π - stackone", name: "π - stackone (pi)")]
XCTAssertEqual(match("%38", "π - stackone", clients: [controlClient], rows), "P")
}

func test_match_paneNumbersIgnoredWithoutAControlModeClientOnThisServer() {
// %6 on a server iTerm2 isn't attached to via -CC is not the %6 tab
// iTerm2 shows for some other server.
let rows = [row("OTHER-SERVER", pane: "6", paneTitle: "✳ task", name: "✳ task")]
XCTAssertNil(match("%6", "✳ task", clients: [], rows))
}

func test_match_paneNumberTieAcrossServersBreaksOnTitle() {
let rows = [
row("S1", pane: "6", paneTitle: "✳ one", name: "✳ one (claude)"),
row("S2", pane: "6", paneTitle: "✳ two", name: "✳ two (claude)"),
]
XCTAssertEqual(match("%6", "✳ two", clients: [controlClient], rows), "S2")
}

func test_match_tmuxDrawnInAnOrdinaryTabUsesTheClientsITermSession() {
// Not -CC: iTerm2 sees one plain session running the tmux client, named
// whatever tmux set, so the client's own ITERM_SESSION_ID is the key.
var client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004")
client.iTermSessionGUID = "HOST"
let rows = [row("OTHER", tty: "/dev/ttys001", name: "zsh"),
row("HOST", tty: "/dev/ttys004", name: "tmux (tmux)")]
XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [client], rows), "HOST")
}

func test_match_ordinaryTabFallsBackToTheClientTTY() {
let client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004")
let rows = [row("OTHER", tty: "/dev/ttys001"), row("HOST", tty: "/dev/ttys004")]
XCTAssertEqual(match("%6", nil, clients: [client], rows), "HOST")
}

func test_match_titleIsNeverTrustedForTmuxDrawnInAnOrdinaryTab() {
// Without -CC, iTerm2 titles don't mirror pane titles; a same-named
// unrelated tab must not be focused.
let client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004")
let rows = [row("UNRELATED", tty: "/dev/ttys009", name: "✳ stack-nudge")]
XCTAssertNil(match("%6", "✳ stack-nudge", clients: [client], rows))
}

func test_match_neverSelectsTheGatewaySession() {
let rows = [row("GATEWAY", tty: "/dev/ttys035", role: "gateway", name: "tmux -CC")]
XCTAssertNil(match("%6", "tmux -CC", clients: [controlClient], rows))
}

func test_match_preVariableITermFallsBackToName() {
// Before 3.3 there are no tmux variables; name is the only title,
// with or without the job appended.
XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [controlClient],
[row("OLD", name: "✳ stack-nudge")]), "OLD")
XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [controlClient],
[row("OLD", name: "✳ stack-nudge (claude)")]), "OLD")
// tmux unreachable: the -CC fallbacks stay available.
XCTAssertEqual(match("%6", "✳ stack-nudge", clients: nil,
[row("OLD", name: "✳ stack-nudge (claude)")]), "OLD")
}

func test_match_ambiguousTitleWithoutPaneNumbersIsNil() {
let rows = [row("A", name: "✳ same (claude)"), row("B", name: "✳ same (claude)")]
XCTAssertNil(match("%6", "✳ same", clients: [controlClient], rows))
}

func test_match_titlePrefixAloneIsNotAJobSuffix() {
XCTAssertNil(match("%6", "✳ stack-nudge", clients: [controlClient],
[row("A", name: "✳ stack-nudge-extras")]))
}
}
7 changes: 5 additions & 2 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1584,20 +1584,23 @@ final class PanelNav: ObservableObject {

// MARK: - Permissions

// Probe the three runtime grants and record which aren't set yet.
// Probe the runtime grants and record which aren't set yet.
// Notifications is async, so the ordered list is assembled in its callback
// (same order the Permissions window renders them). denied and
// not-yet-determined both count as missing — the panel can't fully
// function without the grant either way.
// function without the grant either way. iTerm2 is the exception: only a
// denial counts, because an undecided grant is asked for on first focus.
func refreshPermissions() {
let accessibility = Permissions.accessibility()
let automation = Permissions.automation()
let iTerm = Permissions.iTermAutomation()
Permissions.notifications { [weak self] notifications in
guard let self else { return }
var missing: [SettingsPane] = []
if notifications != .granted { missing.append(.notifications) }
if accessibility != .granted { missing.append(.accessibility) }
if automation != .granted { missing.append(.automation) }
if iTerm == .denied { missing.append(.automationITerm2) }
if missing != self.missingPermissions { self.missingPermissions = missing }
}
}
Expand Down
50 changes: 44 additions & 6 deletions panel/Permissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ enum Permissions {
AXIsProcessTrusted() ? .granted : .denied
}

static func automation() -> PermissionStatus {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents")
static func automation(target bundleID: String = "com.apple.systemevents") -> PermissionStatus {
let target = NSAppleEventDescriptor(bundleIdentifier: bundleID)
let status = AEDeterminePermissionToAutomateTarget(
target.aeDesc, typeWildCard, typeWildCard, false)
switch status {
Expand All @@ -29,6 +29,16 @@ enum Permissions {
}
}

// tmux focus selects the iTerm2 tab over AppleScript, a grant separate from
// System Events. nil while iTerm2 isn't running: the grant can only be
// probed against a live target.
static func iTermAutomation() -> PermissionStatus? {
guard !NSRunningApplication
.runningApplications(withBundleIdentifier: AppActivator.iTermBundleID).isEmpty
else { return nil }
return automation(target: AppActivator.iTermBundleID)
}

static func openSettings(_ target: SettingsPane) {
NSWorkspace.shared.open(target.url)
}
Expand Down Expand Up @@ -68,6 +78,8 @@ enum Permissions {
_ = AXIsProcessTrustedWithOptions(options)
case .automation:
triggerAutomationPrompt()
case .automationITerm2:
triggerITermAutomationPrompt()
case .notifications:
promptNotifications()
}
Expand Down Expand Up @@ -102,6 +114,10 @@ enum Permissions {
_ = AXIsProcessTrustedWithOptions(options)
case .automation:
triggerAutomationPrompt()
case .automationITerm2:
// The reset clears every AppleEvents target, System Events included.
triggerAutomationPrompt()
triggerITermAutomationPrompt()
case .notifications:
promptNotifications()
}
Expand All @@ -121,18 +137,30 @@ enum Permissions {
_ = script.executeAndReturnError(&error)
}
}

// Only while iTerm2 runs, so asking never launches it.
private static func triggerITermAutomationPrompt() {
guard iTermAutomation() != nil,
let script = NSAppleScript(source: "tell application id \"\(AppActivator.iTermBundleID)\" to get name")
else { return }
DispatchQueue.global(qos: .userInitiated).async {
var error: NSDictionary?
_ = script.executeAndReturnError(&error)
}
}
}

enum SettingsPane {
case accessibility
case automation
case automationITerm2
case notifications

var url: URL {
switch self {
case .accessibility:
return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
case .automation:
case .automation, .automationITerm2:
return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")!
case .notifications:
return URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!
Expand All @@ -142,7 +170,7 @@ enum SettingsPane {
var tccService: String? {
switch self {
case .accessibility: return "Accessibility"
case .automation: return "AppleEvents"
case .automation, .automationITerm2: return "AppleEvents"
case .notifications: return nil // notifications aren't a TCC service
}
}
Expand All @@ -152,6 +180,7 @@ enum SettingsPane {
switch self {
case .accessibility: return "Accessibility"
case .automation: return "Automation"
case .automationITerm2: return "Automation (iTerm2)"
case .notifications: return "Notifications"
}
}
Expand All @@ -162,6 +191,7 @@ struct PermissionsView: View {
@State private var accessibility: PermissionStatus = .unknown
@State private var automation: PermissionStatus = .unknown
@State private var notifications: PermissionStatus = .unknown
@State private var iTerm: PermissionStatus?

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Expand Down Expand Up @@ -189,6 +219,13 @@ struct PermissionsView: View {
status: automation,
pane: .automation)

if let iTerm {
row(title: "Automation → iTerm2",
description: "Required to bring the exact iTerm2 tab forward for a tmux-hosted agent.",
status: iTerm,
pane: .automationITerm2)
}

Spacer(minLength: 0)

HStack {
Expand All @@ -198,7 +235,7 @@ struct PermissionsView: View {
}
}
.padding(20)
.frame(width: 480, height: 440)
.frame(width: 480, height: 540)
.onAppear { refresh() }
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
refresh()
Expand Down Expand Up @@ -247,6 +284,7 @@ struct PermissionsView: View {
private func refresh() {
accessibility = Permissions.accessibility()
automation = Permissions.automation()
iTerm = Permissions.iTermAutomation()
Permissions.notifications { notifications = $0 }
}

Expand Down Expand Up @@ -282,7 +320,7 @@ final class PermissionsWindowController: NSWindowController {

convenience init() {
let window = EscClosesWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 440),
contentRect: NSRect(x: 0, y: 0, width: 480, height: 540),
styleMask: [.titled, .closable],
backing: .buffered, defer: false)
window.title = "StackNudge — Permissions"
Expand Down
Loading