Skip to content

feat(permissions): drag-to-grant onboarding for macOS TCC (Stage 1) - #1515

Merged
jackwener merged 4 commits into
mainfrom
feat/permission-drag-onboarding
Jul 27, 2026
Merged

feat(permissions): drag-to-grant onboarding for macOS TCC (Stage 1)#1515
jackwener merged 4 commits into
mainfrom
feat/permission-drag-onboarding

Conversation

@jackwener

Copy link
Copy Markdown
Member

macOS gates Accessibility and Screen Recording behind TCC and offers no programmatic consent dialog for either, so the stock path is: open System Settings → find Privacy & Security → find the pane → press + → navigate a file picker to /Applications → pick the app → tick the box. Six steps, and the file picker is where people give up. Until now maka deep-linked to the pane and left the user there.

System Settings also accepts an .app bundle dropped onto the list — the same explicit consent Apple wants, in one gesture. 引导授权 / "Guide me" opens the right pane and floats a card the user drags the app out of.

Stage 1 is deliberately pure Electron; see docs/permission-onboarding-plan.md.

The load-bearing bits

Four window options only work togetherfocusable: false, type: 'panel', always-on-top at screen-saver, and showInactive(). If the card takes focus, System Settings stops being the key window and drops its drop-target highlight mid-drag, leaving the user dragging into a window that no longer looks like it will accept anything.

The drag is webContents.startDrag({file, icon}) — the only way to hand a file to another process, because it writes a kUTTypeFileURL onto NSPasteboard. An HTML5 dragstart never leaves our process, so System Settings would never see it. It starts on mousedown so the OS drag session picks up mid-motion and the gesture feels continuous.

What the card drags is a canvas replica of a System Settings list row, not the bare app icon: what you drag already looks like what it becomes, which is what makes the gesture read as obvious rather than as a trick.

Lifecycle

permission-overlay-controller.ts is fully injected — 16 tests over a fake clock, no Electron required. It pins the failure modes the reference implementations actually shipped:

  • no second window or second pair of timers on re-entry
  • teardown on grant, on dismiss, and when the window goes on its own
  • a give-up timeout, so an abandoned flow cannot leave a card that is always-on-top across every Space with no way back to it
  • no card at all when the permission is already granted, or when the settings deep-link failed
  • cursor anchor clamped into the work area, so the card can't hang off a screen edge

Degradations are explicit, not silent

With no .app to drag (unpacked tree) the row says so and offers Finder, instead of a gesture that silently does nothing. Screen Recording carries copy about needing a relaunch, because macOS caches the previous denial and no API call fixes it.

The permission id list moved to @maka/core so the flow and the Permission Center row can't disagree about which permissions the gesture applies to. The plain "open System Settings" button stays beside the guided one — the drag is a shortcut, never the only route.

Verification

Verified live over the settings-permissions fixture: 引导授权 renders only on drag-to-grant rows that aren't yet granted — not on microphone, notifications, automation, or granted rows.

Not verified: the card's own pixels. Rendering it needs either a real run (which opens System Settings) or an offscreen Electron harness, and my harness stalled on capture. The layout follows the reference implementation's proportions rather than a screenshot of ours, so it's worth a look before merge.

Stage 2 (not built)

Docking the card to the System Settings window needs CGWindowListCopyWindowInfo — the one permission-free way to locate a foreign window, avoiding the chicken-and-egg of using an Accessibility-gated API to request Accessibility — and therefore native code. Plan documents a prebuilt Swift CLI spawned as a child process, with null falling back to the Stage 1 cursor anchor.

Gates

2871 desktop + 250 ui + 1173 core tests green · biome clean · 0 dead CSS. The two contracts this changes (IPC pairing, permission-card button variants) are re-pinned to the new intent, not relaxed.

macOS gates Accessibility and Screen Recording behind TCC, and gives no
programmatic consent dialog for either, so the stock path is: open
System Settings, find Privacy & Security, find the pane, press +,
navigate a file picker to /Applications, pick the app, tick the box. Six
steps, and the file picker is where people give up. Until now maka
deep-linked to the pane and left the user there.
System Settings also accepts an `.app` bundle DROPPED onto the list —
the same explicit consent Apple wants, in one gesture. 引导授权 opens the
right pane and floats a card the user drags the app out of.
Stage 1 is deliberately pure Electron (docs/permission-onboarding-plan.md).
The four window options are load-bearing together — `focusable:false`,
`type:'panel'`, always-on-top at `screen-saver`, and `showInactive()`:
if the card takes focus, System Settings stops being the key window and
drops its drop-target highlight mid-drag, so the user is left dragging
into a window that no longer looks like it will accept anything.
The drag is `webContents.startDrag({file, icon})`, the only way to hand a
file to another process — it writes a `kUTTypeFileURL` onto NSPasteboard.
An HTML5 `dragstart` never leaves our process, so System Settings would
never see it. It starts on `mousedown` so the OS drag session picks up
mid-motion and the gesture feels continuous.
What the card drags is a canvas replica of a System Settings list row
rather than the bare app icon: what you drag already looks like what it
becomes, which is what makes the gesture read as obvious.
Lifecycle lives in `permission-overlay-controller.ts` — fully injected,
16 tests over a fake clock, no Electron needed. It pins the failure modes
the reference implementations actually shipped:
- no second window or second pair of timers on re-entry
- teardown on grant, on dismiss, and when the window goes on its own
- a give-up timeout, so an abandoned flow cannot leave a card that is
always-on-top across every Space with no way back to it
- no card at all when the permission is already granted, or when the
settings deep-link failed
Degradations are explicit rather than silent. With no `.app` to drag
(unpacked tree) the row says so and offers Finder instead of a dead
gesture. Screen Recording gets copy about needing a relaunch, because
macOS caches the previous denial and no API call fixes that.
The permission id list lives in `@maka/core` so the flow and the
Permission Center row cannot disagree about which permissions the gesture
applies to. The plain "open System Settings" button stays beside the
guided one — the drag is a shortcut, never the only route.
Verified live over the `settings-permissions` fixture: 引导授权 renders
only on the drag-to-grant rows that are not yet granted, and not on
microphone, notifications, automation, or granted rows.
NOT verified: the card's own pixels. Rendering it needs either a real
run (which opens System Settings) or an offscreen Electron harness that
stalled on capture; the layout is from the reference implementation's
proportions, not from a screenshot of ours.
Stage 2 — docking the card to the System Settings window — needs
`CGWindowListCopyWindowInfo` and therefore native code, and is unbuilt.
Gates: 2871 desktop + 250 ui + 1173 core tests green, biome clean,
0 dead CSS. The two contracts this changes (IPC pairing, permission-card
button variants) are re-pinned to the new intent, not relaxed.
Reading Alma's actual `main.js` rather than the summary of it turned up
two things the Stage 1 commit got wrong or left unsaid.
**The card was the wrong shape.** I sized it 380x132 — a squarish
dialog — from proportions I invented. The reference is `HS = 530`,
`XS = 109` (main.js:72272): a wide, short bar. The proportion is the
whole point. The card has to span the width of the System Settings
content pane and read as belonging to the list it points at; a squarer
card reads as a floating dialog that happens to be nearby. Relayouts the
card as a horizontal bar — draggable row left, explanation right — and
collapses to a single column once granted, when there is nothing left to
drag.
**Stage 2 has a coordinate-space landmine, documented so we don't
inherit it.** The Swift locator returns AppKit coordinates (origin
bottom-left of the containing screen) with a comment claiming that is
what `setBounds` expects. It isn't — Electron normalises screen
coordinates to top-left origin on macOS. The docking math adds
`y = frame.y + 14` meaning "just below the window top", and the two
spaces agree only when
screenHeight - windowTop - windowHeight == windowTop
i.e. only when the Settings window is vertically centred — exactly where
it opens by default, so the bug never shows in a demo. For a 900px
screen and a 600px window the card lands 180px low at windowTop=60 and
200px high at windowTop=250; it drifts the wrong way by twice the
displacement as soon as the user moves the window.
Recorded in the plan with the arithmetic, plus the requirement that the
Stage 2 tracker be tested with the window OFF-centre — a fixture at the
default position proves nothing.
Gates: 2871 desktop tests green, typecheck clean, biome clean.
Self-review against the repo's existing overlay found two places where
the new panel was looser than the one already in the tree
(`computer-use/cursor-overlay-window.ts`).
**The card's three gestures were on global `ipcMain`.** They start a
native drag of the `.app` bundle, close the card, and open Finder. On
global `ipcMain.on` any renderer in the app can reach them, not just the
overlay page that is supposed to own them. They now bind to that
window's own `webContents` via `ipc-message`, which is the containment
the cursor overlay already uses — and the listeners now die with the
window instead of accumulating one set per card opened.
Blast radius was small (nothing in the app's preload exposes these
channels, so there was no route from app code) but "no caller today" is
not the same as "not reachable", and the fix costs nothing.
The dragged path was already resolved in main and never taken from the
payload; that stays, and is now stated in a comment. The card chooses
the drag IMAGE and nothing else.
**`sandbox: false` had no reason to be there.** The preload only needs
contextBridge + ipcRenderer, both of which work sandboxed. Now `true`,
matching the cursor overlay.
Also folds the dismiss gesture into `win.close()` rather than a second
teardown path — the controller already tears down on the window's
`closed` event, so there is now one way for the card to go away.
Gates: 2871 desktop tests green, typecheck clean, biome clean.
…n quit
Review found `permissions:dismissDragOnboarding` exposed on the preload
bridge with no caller: the card already closes itself three ways (its ×
button, the grant, the give-up timeout), so the app never needs to reach
in. Removed the channel, the bridge method, and the contract entry rather
than leaving renderer surface nothing drives.
That left `dismiss()`/`destroy()` on the controller as two names for the
same teardown, both unused in production. Collapsed to `dismiss()` and
gave it the one real caller it should have had: `before-quit`. A panel at
`screen-saver` level pinned to every Space is visible to the user if it
outlives a slow quit, so closing it explicitly beats racing process
teardown.
Gates: 2871 desktop tests green, typecheck clean, biome clean.
@jackwener
jackwener merged commit 17362dc into mainJul 27, 2026
0 of 3 checks passed
jackwener added a commit that referenced this pull request Jul 27, 2026
`main` has been red since 17362dc (#1515); 0e80fe1 (#1359) inherited it.
All three jobs failed, none of it visible in that PR's own checks because
the earliest failure short-circuits the rest of the job.
typecheck job
- `format:check`: scripts/build-cursor-overlay.mjs was committed
unformatted. Reapplied `biome format`; purely mechanical.
- `knip`: #1515 added a second overlay entry point pair
(permission-overlay{,-preload}.ts) but registered only the cursor
overlay in knip.json, so both files read as unused. Registered them
beside their cursor-overlay twins — they are loaded by path at
runtime (loadFile / preload), never imported.
- `knip`: asOsPermissionId was exported and referenced nowhere in the
repo. It is an identity function (`return id`), so nothing is lost by
dropping it — git history holds it if Stage 2 wants it back. Its sole
import (OsPermissionId) went with it.
test job
- `check-console`: three console.warn sites in
permission-overlay-main.ts (locale fallback, missing .app bundle, the
controller log sink). These are main-process diagnostics that expose
paths, not secrets — the same category as the daily-review and
settings-runtime entries — so they are allow-listed with a reason
per the script's own documented convention.
e2e job
- `settings.spec.ts:141` asserted a permission row draws exactly two
grant buttons. #1515 added a third (拖拽授权) to precisely the row
the fixture aims at — screen_recording is requestable, openable AND
drag-grantable — so the `=== 2` filter matched nothing and the guard
silently measured no rows. Relaxed to `>= 2`, which is what the
contract was always about: a row whose actions track competes with
the body. A wider actions track only makes the squeeze worse, so the
three-button row is strictly the better subject.
Verified locally: lint, format:check, build, typecheck, knip (both
workspaces), check-console, and the full test:dist suite.
jackwener added a commit that referenced this pull request Jul 27, 2026
…ues (#1520)
* fix(design-system): stop the radius scanner from skipping wrapped values
The CSS governance scanner matched `border-radius` values with the class
`[^;}\n]+`. Excluding `\n` meant a declaration whose VALUE wrapped onto a
second line matched nothing at all — the scanner skipped it in silence
rather than reporting it, so any radius could escape the token contract
just by being formatted across two lines:
border-radius: 10px
12px; /* never scanned */
Neither prettier nor biome reflows such a value back onto one line, so a
long four-corner shorthand or calc() reaches this state on its own.
(A newline directly after the colon was always fine — the `\s*` there
already spans it. Only a newline *inside* the value was affected, which
is narrower than it first looks.)
Widening the class to `[^;}]+` cannot make a match outrun its own
declaration: `;` and `}` are still excluded, so it stops at the first
declaration terminator or the end of the rule body either way. The
regression test covering that is asserted alongside the fix.
Found while reviewing #1514, which fixed a separate bug in the same
contract (it accepted only invalid unspaced calc()). This is the
remaining scanner gap.
* fix(ci): restore green main after #1515
`main` has been red since 17362dc (#1515); 0e80fe1 (#1359) inherited it.
All three jobs failed, none of it visible in that PR's own checks because
the earliest failure short-circuits the rest of the job.
typecheck job
- `format:check`: scripts/build-cursor-overlay.mjs was committed
unformatted. Reapplied `biome format`; purely mechanical.
- `knip`: #1515 added a second overlay entry point pair
(permission-overlay{,-preload}.ts) but registered only the cursor
overlay in knip.json, so both files read as unused. Registered them
beside their cursor-overlay twins — they are loaded by path at
runtime (loadFile / preload), never imported.
- `knip`: asOsPermissionId was exported and referenced nowhere in the
repo. It is an identity function (`return id`), so nothing is lost by
dropping it — git history holds it if Stage 2 wants it back. Its sole
import (OsPermissionId) went with it.
test job
- `check-console`: three console.warn sites in
permission-overlay-main.ts (locale fallback, missing .app bundle, the
controller log sink). These are main-process diagnostics that expose
paths, not secrets — the same category as the daily-review and
settings-runtime entries — so they are allow-listed with a reason
per the script's own documented convention.
e2e job
- `settings.spec.ts:141` asserted a permission row draws exactly two
grant buttons. #1515 added a third (拖拽授权) to precisely the row
the fixture aims at — screen_recording is requestable, openable AND
drag-grantable — so the `=== 2` filter matched nothing and the guard
silently measured no rows. Relaxed to `>= 2`, which is what the
contract was always about: a row whose actions track competes with
the body. A wider actions track only makes the squeeze worse, so the
three-button row is strictly the better subject.
Verified locally: lint, format:check, build, typecheck, knip (both
workspaces), check-console, and the full test:dist suite.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener