Skip to content

Commit 889e30e

Browse files
fix(desktop): anchor permission helper inside System Settings
1 parent 4fbb874 commit 889e30e

4 files changed

Lines changed: 254 additions & 18 deletions

File tree

‎apps/desktop/src/snapShot/MacPermissionHelper.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
import * as Electron from "electron";
33
import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts";
4+
import type { SettingsWindow } from "./MacSettingsWindow.ts";
45
import { SNAP_SHOT_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts";
56

67
const mocks = vi.hoisted(() => ({
@@ -10,12 +11,17 @@ const mocks = vi.hoisted(() => ({
1011
showItemInFolder: vi.fn(),
1112
send: vi.fn(),
1213
loadURL: vi.fn(),
14+
stopTracking: vi.fn(),
15+
settingsChanged: undefined as ((state: SettingsWindow) => void) | undefined,
1316
}));
1417
const windows = vi.hoisted(
1518
() =>
1619
[] as Array<{
1720
destroyed: boolean;
1821
webContents: { mainFrame: object };
22+
setBounds: ReturnType<typeof vi.fn>;
23+
hide: ReturnType<typeof vi.fn>;
24+
showInactive: ReturnType<typeof vi.fn>;
1925
}>,
2026
);
2127
vi.mock("electron", async () => {
@@ -44,6 +50,10 @@ vi.mock("electron", async () => {
4450
}
4551
loadURL = mocks.loadURL;
4652
showInactive = vi.fn();
53+
hide = vi.fn();
54+
setBounds = vi.fn();
55+
isVisible = () => false;
56+
isFocused = () => false;
4757
show = vi.fn();
4858
focus = vi.fn();
4959
getBounds = () => ({ x: 0, y: 0, width: 800, height: 600 });
@@ -67,6 +77,16 @@ vi.mock("electron", async () => {
6777
shell: { showItemInFolder: mocks.showItemInFolder },
6878
};
6979
});
80+
vi.mock("./MacSettingsWindow.ts", async (importOriginal) => {
81+
const actual = await importOriginal<typeof import("./MacSettingsWindow.ts")>();
82+
return {
83+
...actual,
84+
watchMacSettingsWindow: (onChange: (state: SettingsWindow) => void) => {
85+
mocks.settingsChanged = onChange;
86+
return mocks.stopTracking;
87+
},
88+
};
89+
});
7090
let helper: MacPermissionHelper;
7191
beforeEach(() => {
7292
vi.useFakeTimers();
@@ -189,3 +209,26 @@ it("uses the packaged PNG when an earlier resource candidate is absent", async (
189209
expect(mocks.startDrag).toHaveBeenCalled();
190210
expect(mocks.createFromPath).toHaveBeenLastCalledWith(iconPaths[0]);
191211
});
212+
213+
it("docks inside Settings and hides when it is covered or closed", async () => {
214+
await open();
215+
const window = windows[0]!;
216+
const settings = { x: 367, y: 100, width: 723, height: 719, frontmost: true };
217+
mocks.settingsChanged!(settings);
218+
expect(window.setBounds).toHaveBeenLastCalledWith(
219+
{ x: 599, y: 663, width: 475, height: 140 },
220+
false,
221+
);
222+
expect(window.showInactive).toHaveBeenCalledOnce();
223+
mocks.settingsChanged!({ ...settings, x: -800, y: 200 });
224+
expect(window.setBounds).toHaveBeenLastCalledWith(
225+
{ x: -568, y: 763, width: 475, height: 140 },
226+
false,
227+
);
228+
mocks.settingsChanged!({ ...settings, frontmost: false });
229+
expect(window.hide).toHaveBeenCalledOnce();
230+
mocks.settingsChanged!(null);
231+
expect(window.destroyed).toBe(true);
232+
helper.close();
233+
expect(mocks.stopTracking).toHaveBeenCalledOnce();
234+
});

‎apps/desktop/src/snapShot/MacPermissionHelper.ts‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
import * as Electron from "electron";
33
import { SNAP_SHOT_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts";
44

5+
import {
6+
settingsHelperBounds,
7+
watchMacSettingsWindow,
8+
type SettingsWindow,
9+
} from "./MacSettingsWindow.ts";
10+
511
type Permission = "screen-recording" | "accessibility";
612

713
const permissionGranted = (permission: Permission) =>
@@ -34,24 +40,26 @@ function helperHtml(permission: Permission, icon: string) {
3440
const title = permission === "screen-recording" ? "Screen Recording" : "Accessibility";
3541
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
3642
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
37-
<title>Set up snapshots</title><style>
43+
<title>Set up ${title}</title><style>
3844
:root { color-scheme: light dark; --base: #fff; --row: #e7e7e7; --text: #292929; --line: #e3e3e3; }
3945
@media (prefers-color-scheme: dark) { :root { --base: #242424; --row: #383838; --text: #f5f5f5; --line: #484848; } }
4046
* { box-sizing: border-box; }
41-
body { margin: 0; padding: 20px; height: 136px; border: 1px solid var(--line); border-radius: 24px; background: var(--base); color: var(--text); font: 15px/22px -apple-system, BlinkMacSystemFont, sans-serif; user-select: none; }
42-
header { -webkit-app-region: drag; font-weight: 600; white-space: nowrap; }
47+
html { background: transparent; }
48+
body { margin: 0; background: transparent; color: var(--text); font: 15px/22px -apple-system, BlinkMacSystemFont, sans-serif; user-select: none; }
49+
#panel { position: relative; margin: 2px; padding: 20px; height: 136px; border: 1px solid var(--line); border-radius: 24px; background: var(--base); }
50+
header { font-weight: 600; white-space: nowrap; }
4351
button { font: inherit; color: inherit; }
4452
button:focus-visible { outline: 2px solid #007aff; outline-offset: 3px; }
4553
#close { position: absolute; right: 8px; top: 6px; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; background: var(--base); font-size: 18px; cursor: pointer; opacity: 0; }
46-
body:hover #close, #close:focus-visible { opacity: 1; }
54+
#panel:hover #close, #close:focus-visible { opacity: 1; }
4755
#app { display: flex; align-items: center; gap: 12px; width: 100%; height: 52px; margin-top: 20px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--row); cursor: grab; text-align: left; font-size: 16px; font-weight: 600; }
4856
#app:active { cursor: grabbing; }
4957
img { width: 32px; height: 32px; pointer-events: none; }
50-
</style></head><body>
58+
</style></head><body><main id="panel">
5159
<button id="close" aria-label="Close permission helper">×</button>
52-
<header>↑ Drag T3 Code into the ${title} list above</header>
60+
<header>↑ Drag T3 Code into the list above</header>
5361
<button id="app" draggable="true" aria-label="Drag T3 Code to System Settings, or click to reveal in Finder"><img src="${escapeHtml(icon)}" alt="" draggable="false">T3 Code</button>
54-
</body></html>`;
62+
</main></body></html>`;
5563
}
5664

5765
/** Owns one temporary panel and its IPC listener. Closing it releases all resources. */
@@ -81,20 +89,13 @@ export class MacPermissionHelper {
8189
.find((image) => !image.isEmpty());
8290
if (!appIcon) throw new Error("The packaged T3 Code icon is missing.");
8391
const icon = appIcon.resize({ width: 64, height: 64 });
84-
const display = owner
85-
? Electron.screen.getDisplayMatching(owner.getBounds())
86-
: Electron.screen.getDisplayNearestPoint(Electron.screen.getCursorScreenPoint());
87-
const area = display.workArea;
88-
const width = Math.min(560, area.width);
89-
const height = 136;
9092
const window = new Electron.BrowserWindow({
91-
width,
92-
height,
93-
x: Math.round(area.x + (area.width - width) / 2),
94-
y: Math.max(area.y, area.y + area.height - height - 28),
93+
width: 560,
94+
height: 140,
9595
show: false,
9696
frame: false,
9797
transparent: true,
98+
roundedCorners: false,
9899
backgroundColor: "#00000000",
99100
hasShadow: false,
100101
resizable: false,
@@ -136,12 +137,39 @@ export class MacPermissionHelper {
136137
finish();
137138
}
138139
};
140+
let settingsWindow: SettingsWindow = null;
141+
let foundSettings = false;
142+
const syncPosition = () => {
143+
if (window.isDestroyed()) return;
144+
if (!settingsWindow && foundSettings) {
145+
window.close();
146+
return;
147+
}
148+
if (!settingsWindow || (!settingsWindow.frontmost && !window.isFocused())) {
149+
window.hide();
150+
return;
151+
}
152+
const bounds = settingsHelperBounds(settingsWindow);
153+
const current = window.getBounds();
154+
if (
155+
current.x !== bounds.x ||
156+
current.y !== bounds.y ||
157+
current.width !== bounds.width ||
158+
current.height !== bounds.height
159+
) {
160+
window.setBounds(bounds, false);
161+
}
162+
if (!window.isVisible()) window.showInactive();
163+
};
164+
let stopTracking = () => {};
165+
window.on("blur", syncPosition);
139166
const onOwnerClosed = () => window.destroy();
140167
Electron.ipcMain.on(SNAP_SHOT_PERMISSION_HELPER_CHANNEL, onMessage);
141168
const timer = setInterval(check, 1_000);
142169
owner?.once("closed", onOwnerClosed);
143170
window.once("closed", () => {
144171
clearInterval(timer);
172+
stopTracking();
145173
Electron.ipcMain.removeListener(SNAP_SHOT_PERMISSION_HELPER_CHANNEL, onMessage);
146174
owner?.removeListener("closed", onOwnerClosed);
147175
if (this.window === window) this.window = undefined;
@@ -152,7 +180,13 @@ export class MacPermissionHelper {
152180
await window.loadURL(
153181
`data:text/html;charset=utf-8,${encodeURIComponent(helperHtml(permission, icon.toDataURL()))}`,
154182
);
155-
if (!window.isDestroyed()) window.showInactive();
183+
if (!window.isDestroyed()) {
184+
stopTracking = watchMacSettingsWindow((current) => {
185+
settingsWindow = current;
186+
if (current) foundSettings = true;
187+
syncPosition();
188+
});
189+
}
156190
} catch (error) {
157191
if (!window.isDestroyed()) window.destroy();
158192
throw error;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as NodeEvents from "node:events";
2+
import { beforeEach, expect, it, vi } from "vite-plus/test";
3+
import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts";
4+
5+
const mocks = vi.hoisted(() => ({ spawn: vi.fn() }));
6+
vi.mock("node:child_process", () => ({ spawn: mocks.spawn }));
7+
let child: NodeEvents.EventEmitter & {
8+
stdout: NodeEvents.EventEmitter & { setEncoding: ReturnType<typeof vi.fn> };
9+
kill: ReturnType<typeof vi.fn>;
10+
};
11+
beforeEach(() => {
12+
child = Object.assign(new NodeEvents.EventEmitter(), {
13+
stdout: Object.assign(new NodeEvents.EventEmitter(), { setEncoding: vi.fn() }),
14+
kill: vi.fn(),
15+
});
16+
mocks.spawn.mockReturnValue(child);
17+
});
18+
it("places the helper inside small and large Settings windows across displays", () => {
19+
for (const x of [-900, 20]) {
20+
for (const width of [668, 1000]) {
21+
const state = { x, y: 30, width, height: 700, frontmost: true };
22+
const helper = settingsHelperBounds(state);
23+
expect(helper.x).toBeGreaterThanOrEqual(x + 216);
24+
expect(helper.x + helper.width).toBeLessThanOrEqual(x + width - 16);
25+
expect(helper.y + helper.height).toBe(714);
26+
}
27+
}
28+
});
29+
it("decodes partial updates and stops the one watcher process on disposal", () => {
30+
const changed = vi.fn();
31+
const stop = watchMacSettingsWindow(changed);
32+
const state = { x: 10, y: 20, width: 723, height: 719, frontmost: true };
33+
const line = JSON.stringify(state);
34+
child.stdout.emit("data", line.slice(0, 8));
35+
expect(changed).not.toHaveBeenCalled();
36+
child.stdout.emit("data", line.slice(8) + "\nnull\n");
37+
expect(changed.mock.calls).toEqual([[state], [null]]);
38+
stop();
39+
expect(child.kill).toHaveBeenCalledOnce();
40+
child.emit("exit", 0);
41+
expect(changed).toHaveBeenCalledTimes(2);
42+
});
43+
it("clears the anchor when the watcher fails or emits invalid bounds", () => {
44+
const changed = vi.fn();
45+
const stop = watchMacSettingsWindow(changed);
46+
child.stdout.emit("data", '{"x":"bad"}\n');
47+
expect(changed).toHaveBeenLastCalledWith(null);
48+
child.emit("error", new Error("spawn failed"));
49+
expect(changed).toHaveBeenCalledTimes(2);
50+
stop();
51+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// @effect-diagnostics nodeBuiltinImport:off -- This native boundary keeps one JXA process open while the permission helper tracks System Settings.
2+
import * as NodeChildProcess from "node:child_process";
3+
import * as Schema from "effect/Schema";
4+
import type * as Electron from "electron";
5+
6+
const SettingsWindow = Schema.NullOr(
7+
Schema.Struct({
8+
x: Schema.Finite,
9+
y: Schema.Finite,
10+
width: Schema.Finite,
11+
height: Schema.Finite,
12+
frontmost: Schema.Boolean,
13+
}),
14+
);
15+
export type SettingsWindow = typeof SettingsWindow.Type;
16+
const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow));
17+
18+
// Window bounds and owner PIDs are available before Screen Recording is granted.
19+
// Use the bundle identifier rather than the localized app/window title. A single
20+
// process avoids launching osascript repeatedly while the user moves Settings.
21+
const SETTINGS_WINDOW_SCRIPT = `
22+
ObjC.import("CoreGraphics");
23+
ObjC.import("AppKit");
24+
function run() {
25+
let previous = "";
26+
while (true) {
27+
const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences");
28+
let result = null;
29+
if (apps.count > 0) {
30+
const pid = apps.objectAtIndex(0).processIdentifier;
31+
const front = $.NSWorkspace.sharedWorkspace.frontmostApplication;
32+
const list = $.CGWindowListCopyWindowInfo(
33+
$.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements,
34+
$.kCGNullWindowID
35+
);
36+
if (list) {
37+
$.CFMakeCollectable(list);
38+
const count = $.CFArrayGetCount(list);
39+
for (let i = 0; i < count; i++) {
40+
const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i));
41+
if (w.objectForKey("kCGWindowOwnerPID").js !== pid || w.objectForKey("kCGWindowLayer").js !== 0) continue;
42+
const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds"));
43+
if (b.Width < 500 || b.Height < 350) continue;
44+
result = { x: b.X, y: b.Y, width: b.Width, height: b.Height, frontmost: !front.isNil() && front.processIdentifier === pid };
45+
break;
46+
}
47+
}
48+
}
49+
const line = JSON.stringify(result);
50+
if (line !== previous) {
51+
const data = $(line + "\\n").dataUsingEncoding($.NSUTF8StringEncoding);
52+
$.NSFileHandle.fileHandleWithStandardOutput.writeData(data);
53+
previous = line;
54+
}
55+
$.NSThread.sleepForTimeInterval(result && result.frontmost ? 0.5 : 1);
56+
}
57+
}`;
58+
59+
/** Place the panel inside Settings' content column, above its bottom edge. */
60+
export function settingsHelperBounds(settings: NonNullable<SettingsWindow>): Electron.Rectangle {
61+
const sidebarWidth = 216;
62+
const inset = 16;
63+
const width = Math.min(560, settings.width - sidebarWidth - inset * 2);
64+
return {
65+
x: Math.round(settings.x + sidebarWidth + (settings.width - sidebarWidth - width) / 2),
66+
y: Math.round(settings.y + settings.height - 140 - inset),
67+
width: Math.round(width),
68+
height: 140,
69+
};
70+
}
71+
72+
/** Track only metadata; this does not request Accessibility or Screen Recording. */
73+
export function watchMacSettingsWindow(onChange: (window: SettingsWindow) => void): () => void {
74+
const child = NodeChildProcess.spawn(
75+
"/usr/bin/osascript",
76+
["-l", "JavaScript", "-e", SETTINGS_WINDOW_SCRIPT],
77+
{
78+
stdio: ["ignore", "pipe", "ignore"],
79+
},
80+
);
81+
let pending = "";
82+
let closed = false;
83+
child.stdout.setEncoding("utf8");
84+
child.stdout.on("data", (chunk: string) => {
85+
pending += chunk;
86+
let end: number;
87+
while ((end = pending.indexOf("\n")) !== -1) {
88+
const line = pending.slice(0, end);
89+
pending = pending.slice(end + 1);
90+
if (closed) return;
91+
try {
92+
onChange(decodeSettingsWindow(line));
93+
} catch {
94+
onChange(null);
95+
}
96+
}
97+
});
98+
const onExit = () => {
99+
if (!closed) onChange(null);
100+
};
101+
child.on("error", onExit);
102+
child.on("exit", onExit);
103+
return () => {
104+
closed = true;
105+
child.stdout.removeAllListeners("data");
106+
child.kill();
107+
};
108+
}

0 commit comments

Comments
 (0)