Uh oh!
There was an error while loading. Please reload this page.
feat(permissions): drag-to-grant onboarding for macOS TCC (Stage 1) - #1515
Merged
Conversation
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.
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Jul 27, 2026
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.
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
.appbundle 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 together —
focusable: false,type: 'panel', always-on-top atscreen-saver, andshowInactive(). 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 akUTTypeFileURLontoNSPasteboard. An HTML5dragstartnever leaves our process, so System Settings would never see it. It starts onmousedownso 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.tsis fully injected — 16 tests over a fake clock, no Electron required. It pins the failure modes the reference implementations actually shipped:Degradations are explicit, not silent
With no
.appto 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/coreso 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-permissionsfixture: 引导授权 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, withnullfalling 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.