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
Original file line number Diff line number Diff line change
@@ -1,25 +1,89 @@
import CoreGraphics
import Foundation

private let leftOptionKey = CGKeyCode(0x3A)
private let rightOptionKey = CGKeyCode(0x3D)
private let leftOptionDeviceFlag = CGEventFlags(rawValue: 0x20)
private let rightOptionDeviceFlag = CGEventFlags(rawValue: 0x40)
/// Which pair of physical modifier keys must be held together to trigger AppSnap.
/// Must stay in sync with `DesktopAppSnapChord` in packages/contracts/src/ipc.ts.
enum ModifierChord: String {
case option
case shift
case control
case command

private func optionChordEventCallback(
static let `default`: ModifierChord = .option

var label: String {
switch self {
case .option: return "Option"
case .shift: return "Shift"
case .control: return "Control"
case .command: return "Command"
}
}

var leftKeyCode: CGKeyCode {
switch self {
case .option: return CGKeyCode(0x3A)
case .shift: return CGKeyCode(0x38)
case .control: return CGKeyCode(0x3B)
case .command: return CGKeyCode(0x37)
}
}

var rightKeyCode: CGKeyCode {
switch self {
case .option: return CGKeyCode(0x3D)
case .shift: return CGKeyCode(0x3C)
case .control: return CGKeyCode(0x3E)
case .command: return CGKeyCode(0x36)
}
}

// Device-dependent modifier bits (IOLLEvent.h NX_DEVICE*KEYMASK). Unlike the higher-level
// `CGEventFlags.mask*` constants below, these distinguish which physical side (left/right)
// of the same logical modifier is down, which is what the chord detection needs.
var leftDeviceFlag: CGEventFlags {
switch self {
case .option: return CGEventFlags(rawValue: 0x20)
case .shift: return CGEventFlags(rawValue: 0x02)
case .control: return CGEventFlags(rawValue: 0x01)
case .command: return CGEventFlags(rawValue: 0x08)
}
}

var rightDeviceFlag: CGEventFlags {
switch self {
case .option: return CGEventFlags(rawValue: 0x40)
case .shift: return CGEventFlags(rawValue: 0x04)
case .control: return CGEventFlags(rawValue: 0x2000)
case .command: return CGEventFlags(rawValue: 0x10)
}
}

var overallMask: CGEventFlags {
switch self {
case .option: return .maskAlternate
case .shift: return .maskShift
case .control: return .maskControl
case .command: return .maskCommand
}
}
}

private func modifierChordEventCallback(
proxy: CGEventTapProxy,
type: CGEventType,
event: CGEvent,
userInfo: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
if let userInfo {
let monitor = Unmanaged<OptionChordMonitor>.fromOpaque(userInfo).takeUnretainedValue()
let monitor = Unmanaged<ModifierChordMonitor>.fromOpaque(userInfo).takeUnretainedValue()
monitor.receive(type: type, event: event)
}
return Unmanaged.passUnretained(event)
}

final class OptionChordMonitor {
final class ModifierChordMonitor {
private let chord: ModifierChord
private let emitter: NDJSONEmitter
private let onChord: () -> Void
private var tap: CFMachPort?
Expand All @@ -30,7 +94,8 @@ final class OptionChordMonitor {
private var latched = false
private var lastErrorCode: String?

init(emitter: NDJSONEmitter, onChord: @escaping () -> Void) {
init(chord: ModifierChord, emitter: NDJSONEmitter, onChord: @escaping () -> Void) {
self.chord = chord
self.emitter = emitter
self.onChord = onChord
}
Expand All @@ -50,19 +115,19 @@ final class OptionChordMonitor {
emitter.emitError(
AppSnapFailure(
code: "event_tap_disabled",
message: "macOS disabled the passive Option-key listener; TeaCode re-enabled it."
message: "macOS disabled the passive \(chord.label)-key listener; TeaCode re-enabled it."
),
capturedAt: appSnapTimestamp()
)
return
}
guard type == .flagsChanged else { return }
let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
guard keyCode == leftOptionKey || keyCode == rightOptionKey else { return }
guard keyCode == chord.leftKeyCode || keyCode == chord.rightKeyCode else { return }

leftDown = event.flags.contains(leftOptionDeviceFlag)
rightDown = event.flags.contains(rightOptionDeviceFlag)
if !event.flags.contains(.maskAlternate) {
leftDown = event.flags.contains(chord.leftDeviceFlag)
rightDown = event.flags.contains(chord.rightDeviceFlag)
if !event.flags.contains(chord.overallMask) {
reset()
return
}
Expand Down Expand Up @@ -91,7 +156,7 @@ final class OptionChordMonitor {
guard CGPreflightListenEventAccess() else {
reportOnce(AppSnapFailure(
code: "input-monitoring-required",
message: "Input Monitoring permission is required for the two-Option-key shortcut."
message: "Input Monitoring permission is required for the two-\(chord.label)-key shortcut."
))
return false
}
Expand All @@ -101,13 +166,13 @@ final class OptionChordMonitor {
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: flagsChangedMask,
callback: optionChordEventCallback,
callback: modifierChordEventCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
), let newSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newTap, 0)
else {
reportOnce(AppSnapFailure(
code: "event_tap_unavailable",
message: "macOS could not create the passive Option-key listener."
message: "macOS could not create the passive \(chord.label)-key listener."
))
return false
}
Expand Down
32 changes: 18 additions & 14 deletions apps/desktop/native/appsnap/WindowCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,6 @@ private func number(_ dictionary: [String: Any], _ key: CFString) -> NSNumber? {
}

func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result<SelectedWindow, AppSnapFailure> {
guard let app = NSWorkspace.shared.frontmostApplication, !app.isTerminated else {
return .failure(AppSnapFailure(
code: "no_frontmost_application",
message: "There is no frontmost application to capture."
))
}
guard app.bundleIdentifier != bundleIdentifier else {
return .failure(AppSnapFailure(
code: "excluded_frontmost_application",
message: "TeaCode does not capture its own window."
))
}
guard let windows = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements],
kCGNullWindowID
Expand All @@ -64,8 +52,9 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result<Selecte

var untitledFallback: (CGWindowID, CGRect, String?)?
var selected: (CGWindowID, CGRect, String?)?
var selectedApplication: NSRunningApplication?
for candidate in windows {
guard number(candidate, kCGWindowOwnerPID)?.int32Value == app.processIdentifier,
guard let ownerPID = number(candidate, kCGWindowOwnerPID)?.int32Value,
number(candidate, kCGWindowLayer)?.intValue == 0,
(number(candidate, kCGWindowAlpha)?.doubleValue ?? 1) > 0,
(number(candidate, kCGWindowIsOnscreen)?.boolValue ?? true),
Expand All @@ -77,6 +66,15 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result<Selecte
bounds.height >= 2
else { continue }

if let selectedApplication, ownerPID != selectedApplication.processIdentifier {
break
}
guard let application = NSRunningApplication(processIdentifier: ownerPID),
!application.isTerminated,
application.bundleIdentifier != bundleIdentifier
else { continue }
selectedApplication = application

let title = (candidate[kCGWindowName as String] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let title, !title.isEmpty {
selected = (rawWindowID, bounds, title)
Expand All @@ -87,7 +85,13 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result<Selecte
guard let chosen = selected ?? untitledFallback else {
return .failure(AppSnapFailure(
code: "no_eligible_window",
message: "The frontmost application has no visible shareable window."
message: "No visible shareable window is available outside TeaCode."
))
}
guard let app = selectedApplication else {
return .failure(AppSnapFailure(
code: "no_frontmost_application",
message: "There is no frontmost application to capture."
))
}
return .success(SelectedWindow(
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/native/appsnap/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ else {
exit(EXIT_FAILURE)
}

let chord: ModifierChord
if let chordIndex = arguments.firstIndex(of: "--chord"), arguments.indices.contains(chordIndex + 1) {
guard let parsedChord = ModifierChord(rawValue: arguments[chordIndex + 1]) else {
emitter.emitError(AppSnapFailure(
code: "invalid_arguments",
message: "Unknown --chord value; expected option, shift, control, or command."
))
exit(EXIT_FAILURE)
}
chord = parsedChord
} else {
chord = .default
}

let outputDirectory = URL(fileURLWithPath: arguments[outputIndex + 1], isDirectory: true)
do {
try preparePrivateOutputDirectory(outputDirectory)
Expand All @@ -50,7 +64,7 @@ let coordinator = WindowCaptureCoordinator(
outputDirectory: outputDirectory,
excludedBundleIdentifier: arguments[excludedIndex + 1]
)
let monitor = OptionChordMonitor(emitter: emitter) { coordinator.handleGesture() }
let monitor = ModifierChordMonitor(chord: chord, emitter: emitter) { coordinator.handleGesture() }
let parentMonitor = ParentProcessMonitor()
parentMonitor.start()
monitor.start()
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/build-appsnap-helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const defaultOutput = join(
const sources = [
"AppSnapProtocol.swift",
"Permissions.swift",
"OptionChordMonitor.swift",
"ModifierChordMonitor.swift",
"WindowCapture.swift",
"CaptureFeedback.swift",
"ParentProcessMonitor.swift",
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/appSnapIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
// Purpose: Registers validated renderer commands for the desktop AppSnap manager.

import { ipcMain } from "electron";
import type { DesktopAppSnapManager } from "./appSnapManager";
import { isDesktopAppSnapChord, type DesktopAppSnapManager } from "./appSnapManager";
import { DESKTOP_IPC_CHANNELS } from "./ipcChannels";

export function registerAppSnapIpc(manager: DesktopAppSnapManager): () => void {
const channels = DESKTOP_IPC_CHANNELS.appSnap;
ipcMain.removeHandler(channels.getState);
ipcMain.removeHandler(channels.setEnabled);
ipcMain.removeHandler(channels.setChord);
ipcMain.removeHandler(channels.requestPermissions);
ipcMain.removeHandler(channels.listPendingCaptures);
ipcMain.removeHandler(channels.acknowledgeCapture);
Expand All @@ -18,6 +19,10 @@ export function registerAppSnapIpc(manager: DesktopAppSnapManager): () => void {
if (typeof enabled !== "boolean") throw new Error("Invalid AppSnap enabled state.");
return manager.setEnabled(enabled);
});
ipcMain.handle(channels.setChord, (_event, chord: unknown) => {
if (!isDesktopAppSnapChord(chord)) throw new Error("Invalid AppSnap chord.");
return manager.setChord(chord);
});
ipcMain.handle(channels.requestPermissions, () => manager.requestPermissions());
ipcMain.handle(channels.listPendingCaptures, () => manager.listPendingCaptures());
ipcMain.handle(channels.acknowledgeCapture, (_event, captureId: unknown) => {
Expand All @@ -30,6 +35,7 @@ export function registerAppSnapIpc(manager: DesktopAppSnapManager): () => void {
return () => {
ipcMain.removeHandler(channels.getState);
ipcMain.removeHandler(channels.setEnabled);
ipcMain.removeHandler(channels.setChord);
ipcMain.removeHandler(channels.requestPermissions);
ipcMain.removeHandler(channels.listPendingCaptures);
ipcMain.removeHandler(channels.acknowledgeCapture);
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/appSnapManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,84 @@ describe("AppSnap helper protocol and paths", () => {
});
});

describe("AppSnap chord configuration", () => {
it("defaults to the option chord and reports it as the shortcut", () => {
const manager = createManager("/tmp/teacode-appsnap-chord-default");
expect(manager.getState().shortcut).toBe("option");
});

it("restarts a running watcher with the new chord", async () => {
const directory = mkdtempSync(join(tmpdir(), "teacode-appsnap-chord-"));
const checkChild = createFakeChildProcess();
const watchChild = createFakeChildProcess();
const restartedWatchChild = createFakeChildProcess();
const spawn = vi
.fn()
.mockReturnValueOnce(checkChild)
.mockReturnValueOnce(watchChild)
.mockReturnValueOnce(restartedWatchChild) as unknown as typeof ChildProcess.spawn;
const manager = new DesktopAppSnapManager({
platform: "darwin",
helperPath: process.execPath,
captureDirectory: directory,
excludedBundleId: "dev.jow.TeaCode.dev",
spawn,
onState: vi.fn(),
onCaptured: vi.fn(),
onError: vi.fn(),
});
try {
const enable = manager.setEnabled(true);
await flushPromises();
checkChild.stdout.end(
`${JSON.stringify({
type: "permissions",
inputMonitoring: "granted",
screenRecording: "granted",
})}\n`,
);
checkChild.stderr.end();
checkChild.emit("close", 0, null);
await enable;
expect(spawn).toHaveBeenNthCalledWith(
2,
process.execPath,
expect.arrayContaining(["--chord", "option"]),
expect.anything(),
);

await manager.setChord("shift");
expect(watchChild.kill).toHaveBeenCalledWith("SIGTERM");
expect(manager.getState().shortcut).toBe("shift");
expect(spawn).toHaveBeenNthCalledWith(
3,
process.execPath,
expect.arrayContaining(["--chord", "shift"]),
expect.anything(),
);
} finally {
manager.dispose();
rmSync(directory, { recursive: true, force: true });
}
});

it("does not restart or respawn when the chord is unchanged", async () => {
const spawn = vi.fn() as unknown as typeof ChildProcess.spawn;
const manager = new DesktopAppSnapManager({
platform: "darwin",
helperPath: process.execPath,
captureDirectory: "/tmp/teacode-appsnap-chord-noop",
excludedBundleId: "dev.jow.TeaCode.dev",
spawn,
onState: vi.fn(),
onCaptured: vi.fn(),
onError: vi.fn(),
});
await manager.setChord("option");
expect(spawn).not.toHaveBeenCalled();
});
});

describe("durable AppSnap pending captures", () => {
it("restores, caps, and acknowledges private pending pairs", async () => {
const directory = mkdtempSync(join(tmpdir(), "teacode-appsnap-pending-"));
Expand Down
Loading
Loading