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
3 changes: 3 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,6 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed.
- Live cue tests must flush passive React effects after microphone and stop state updates before asserting audio feedback. The repo has no local Prettier binary; use its configured ESLint validation instead.
- Real signed Live session reached open/microphone-ready/input-first-packet, then Google emitted an empty top-level envelope at 00:20:01 UTC on 2026-09-17. Rejecting `{}` caused the observed silent disconnect. Admit exact empty envelopes as rate-limited no-ops without extending idle timeout; keep unknown populated fields rejected. Terminal error HUDs must remain visible after active becomes false.
- The Mac's default input was Bose Mini II while output was MacBook speakers. Packet flow alone does not prove intelligible user speech or audible playback; retain separate operator verification.
- Pullfrog completed only after GitHub had already accepted the exact-head merge, and it found release-blocking screen-capture races. Keep publication cancellable until external review finishes, even when first-party CI and the merge gate are green.
- Electron exposes setters but no getters for session permission handlers. Scope the Live handler to the lifetime of exact display bindings, explicitly admit only display capture and microphone for that bound document, then restore the default handlers when the last binding disappears.
- Electron's `media` permission covers camera as well as microphone; check `mediaType` on permission checks and exact `mediaTypes: ["audio"]` on permission requests instead of treating the permission name as audio-only.
2 changes: 2 additions & 0 deletions docs/plans/gemini-live-assistant-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Status: Partial — Phases 0–4 plus the beta-labeled Aiden Live orb/setup shell and dedicated metadata-only session threads are implemented; authorized macOS screen capture and real Google beta receipts remain operator-owned
Date: 2026-09-15
Continuous orb follow-up (2026-09-17): active listening/thinking/speaking/acting now share stable Listening + Weaving canvas layers with a slow complementary blend and connection/rest crossfades. Removed visible dock status text (retained screen-reader status). First active click reveals Stop; the second stops, Escape restores the orb, and closing disables duplicate clicks. Sharing/error surfaces remain visible. Added mapping/click-policy regressions; all 230 Live tests, type-check, lint, and standalone browser interaction/motion checks pass. Downloads duplex-review.html uses the actual orb component; the installed notarized app has not been rebuilt for this change.

Release hardening follow-up (2026-09-17): Pullfrog's post-merge review blocked 0.41.4 publication until screen authority is generation-fenced. Exact binding tokens now prevent stale releases from revoking replacements, late picker streams stop after teardown, rejected frame sends revoke capture, and temporary Electron permission handlers admit only audio-only media for the exact document before restoring defaults when the final binding closes. Source Change/Remove is locked across pending provider startup and start failure releases the exact current binding. Adversarial coverage was added for each path.
Combined artifact verification (2026-09-17): Apple accepted notarization submission `5b7f74d8-4740-47b9-82d8-66704cb75ec7`; the Downloads Combined Notarized app has a stapled ticket and passes the repository notarized-package/Gatekeeper check. Opened with the preserved isolated Test Profile and verified its renderer and Start Aiden Live control. Full Live suites, type-check, lint, audio/settings tests, and Electron selector-layout regression passed. Native-picker and real-provider acceptance remain separate and pending.
Combined build (2026-09-17): integrated the screen-sharing work from the 2f30 worktree into this audio-fix checkout without changing the source worktree. Preserved extended-thinking model selection, dedicated session threads, async thread finalization, direct actions, diagnostic markers, empty-envelope handling, device routing, and dropdown layout. Screen capture remains separately gated and off in the ordinary test launcher pending the exact-build native-picker acceptance receipt. Notarization and attended acceptance are separate gates; this is not a public release.
Audio selector layout follow-up: scoped single-line trigger styling and actual Radix value-span ellipsis fix long device labels pushing chevrons outside the control. Real Electron geometry regression passes at 1280, 600, and 390px; the full label remains available through the dropdown and hover title.
Expand Down
17 changes: 15 additions & 2 deletions main/handlers/assistant-live-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseAssistantLiveAudioIntent,
parseAssistantLiveEmptyIntent,
parseAssistantLiveFrameIntent,
parseAssistantLiveDisplayReleaseIntent,
parseAssistantLiveStartIntent,
parseAssistantLiveStopIntent,
} from "./assistant-live-parse.js";
Expand Down Expand Up @@ -74,8 +75,8 @@ test("Assistant Live frame admission accepts only one bounded copied JPEG byte r
]) assert.throws(() => parseAssistantLiveFrameIntent(value), /frame request/u);
});

test("Assistant Live display bind/release accept only an exact empty record", () => {
for (const name of ["display-bind", "display-release"]) {
test("Assistant Live display bind accepts only an exact empty record", () => {
for (const name of ["display-bind"]) {
assert.doesNotThrow(() => parseAssistantLiveEmptyIntent({}, name));
assert.throws(
() => parseAssistantLiveEmptyIntent({ sessionId: "forged" }, name),
Expand All @@ -87,3 +88,15 @@ test("Assistant Live display bind/release accept only an exact empty record", ()
);
}
});

test("Assistant Live display release requires one bounded binding token", () => {
assert.deepEqual(parseAssistantLiveDisplayReleaseIntent({ bindingId: "binding-1" }), {
bindingId: "binding-1",
});
for (const value of [{}, { bindingId: "" }, { bindingId: "x", extra: true }, null]) {
assert.throws(
() => parseAssistantLiveDisplayReleaseIntent(value),
/Invalid Assistant Live display-release/u,
);
}
});
15 changes: 15 additions & 0 deletions main/handlers/assistant-live-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,18 @@ export function parseAssistantLiveEmptyIntent(
throw new Error(`Invalid Assistant Live ${name} request.`);
}
}

export function parseAssistantLiveDisplayReleaseIntent(
value: unknown,
): { bindingId: string } {
if (
!isRecord(value) ||
!exactKeys(value, ["bindingId"]) ||
typeof value.bindingId !== "string" ||
value.bindingId.length < 1 ||
value.bindingId.length > 128
) {
throw new Error("Invalid Assistant Live display-release request.");
}
return { bindingId: value.bindingId };
}
16 changes: 4 additions & 12 deletions main/handlers/assistant-live.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { session } from "electron";
import { ipcMain } from "../platform.js";
import {
authorizeAidenLiveComputerUse,
Expand All @@ -7,13 +6,13 @@ import {
import { rendererDocumentOwner } from "../services/renderer-document-owner.js";
import {
bindGeminiLiveDisplayMediaDocument,
installGeminiLiveDisplayMediaGuards,
} from "../services/gemini-live/display-media-contract.js";
import {
parseAssistantLiveStartIntent,
parseAssistantLiveAudioIntent,
parseAssistantLiveEmptyIntent,
parseAssistantLiveFrameIntent,
parseAssistantLiveDisplayReleaseIntent,
parseAssistantLiveStopIntent,
} from "./assistant-live-parse.js";
import { invokeAssistantLiveStart } from "./assistant-live-start.js";
Expand Down Expand Up @@ -53,18 +52,11 @@ export function registerAssistantLiveHandlers(): void {
ipcMain.handle("assistant-live:display-bind", (event, input: unknown) => {
parseAssistantLiveEmptyIntent(input, "display-bind");
const binding = bindGeminiLiveDisplayMediaDocument(event);
if (!geminiLiveService.bindDisplayMedia(owner(event), binding)) return false;
// The guards consult the live binding set, so install them only after this
// document's binding is registered and only once per Electron session.
installGeminiLiveDisplayMediaGuards(session.defaultSession, () =>
geminiLiveService.displayMediaBindings(),
);
return true;
return geminiLiveService.bindDisplayMedia(owner(event), binding);
});
ipcMain.handle("assistant-live:display-release", (event, input: unknown) => {
parseAssistantLiveEmptyIntent(input, "display-release");
geminiLiveService.releaseDisplayMedia(owner(event));
return true;
const intent = parseAssistantLiveDisplayReleaseIntent(input);
return geminiLiveService.releaseDisplayMedia(owner(event), intent.bindingId);
});
ipcMain.handle("assistant-live:frame", (event, input: unknown) => {
const intent = parseAssistantLiveFrameIntent(input);
Expand Down
49 changes: 42 additions & 7 deletions main/services/gemini-live/display-media-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ test("binds both custom-picker and system-picker permission admission to one exa
binding.allowsPermissionRequest(sender as unknown as Electron.WebContents, "media", {
isMainFrame: true,
requestingUrl: frame.url,
mediaType: "audio",
}),
true,
);
assert.equal(
binding.allowsPermissionRequest(sender as unknown as Electron.WebContents, "media", {
isMainFrame: true,
requestingUrl: frame.url,
mediaType: "video",
}),
false,
);
Expand Down Expand Up @@ -158,14 +167,21 @@ test("session guards gate display-capture only and install exactly once", () =>
const frame = new FakeFrame(10, 20, "document-one", "file:///Aiden/main-window.html");
const sender = new FakeWebContents(7, frame);
const bindings: GeminiLiveDisplayMediaBinding[] = [];
installGeminiLiveDisplayMediaGuards(electronSession, () => bindings);
installGeminiLiveDisplayMediaGuards(electronSession, () => bindings);
const dispose = installGeminiLiveDisplayMediaGuards(electronSession, () => bindings);
assert.equal(
installGeminiLiveDisplayMediaGuards(electronSession, () => bindings),
dispose,
);
assert.equal(installed.checks, 1, "re-installation must not stack handlers");
assert.equal(installed.requests, 1);
assert.equal(installed.displays, 1);
assert.deepEqual(installed.opts, { useSystemPicker: true });

const details = { isMainFrame: true, requestingUrl: frame.url } as Electron.PermissionRequest;
const audioCheckDetails = { ...details, mediaType: "audio" } as Electron.PermissionCheckHandlerHandlerDetails;
const videoCheckDetails = { ...details, mediaType: "video" } as Electron.PermissionCheckHandlerHandlerDetails;
const audioRequestDetails = { ...details, mediaTypes: ["audio"] } as Electron.MediaAccessPermissionRequest;
const videoRequestDetails = { ...details, mediaTypes: ["video"] } as Electron.MediaAccessPermissionRequest;
// Without a Live binding every display-capture path denies.
assert.equal(
installed.check?.(
Expand All @@ -187,8 +203,9 @@ test("session guards gate display-capture only and install exactly once", () =>
);
assert.equal(granted, false);

// A bound document admits display-capture; every other permission keeps the
// session's default allow so the guards never shrink unrelated authority.
// A bound document admits only its display capture and microphone. Unrelated
// contents and unrelated permission types remain denied while the temporary
// guard owns the session policy.
bindings.push(bindGeminiLiveDisplayMediaDocument(invokeEvent(sender, frame)));
assert.equal(
installed.check?.(
Expand All @@ -200,15 +217,29 @@ test("session guards gate display-capture only and install exactly once", () =>
true,
);
assert.equal(
installed.check?.(null, "media", "file:///Aiden/", details),
installed.check?.(sender as unknown as Electron.WebContents, "media", "file:///Aiden/", audioCheckDetails),
true,
"non-display permissions keep the default policy",
"the exact bound document keeps microphone access",
);
granted = null;
installed.request?.(sender as unknown as Electron.WebContents, "media", (next) => {
granted = next;
}, details);
}, audioRequestDetails);
assert.equal(granted, true);
assert.equal(
installed.check?.(sender as unknown as Electron.WebContents, "media", "file:///Aiden/", videoCheckDetails),
false,
);
granted = null;
installed.request?.(sender as unknown as Electron.WebContents, "media", (next) => {
granted = next;
}, videoRequestDetails);
assert.equal(granted, false);
assert.equal(
installed.check?.(sender as unknown as Electron.WebContents, "notifications", "file:///Aiden/", details),
false,
);
assert.equal(installed.check?.(null, "media", "file:///Aiden/", details), false);

// Any non-picker dispatch to the fallback handler is denied outright.
const streams: Electron.Streams[] = [];
Expand All @@ -226,6 +257,10 @@ test("session guards gate display-capture only and install exactly once", () =>
),
false,
);
dispose();
assert.equal(installed.check, null);
assert.equal(installed.request, null);
assert.equal(installed.display, null);
});

test("navigation, replacement frames, and unrelated WebContents fail closed", () => {
Expand Down
62 changes: 42 additions & 20 deletions main/services/gemini-live/display-media-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { rendererDocumentOwner, type RendererDocumentOwner } from "../renderer-document-owner.js";
import { randomUUID } from "node:crypto";

export const GEMINI_LIVE_SYSTEM_PICKER_OPTIONS: Electron.DisplayMediaRequestHandlerOpts = {
useSystemPicker: true,
Expand All @@ -7,9 +8,12 @@ export const GEMINI_LIVE_SYSTEM_PICKER_OPTIONS: Electron.DisplayMediaRequestHand
interface DisplayPermissionDetails {
isMainFrame: boolean;
requestingUrl: string;
mediaType?: "video" | "audio" | "unknown";
mediaTypes?: Array<"video" | "audio">;
}

export interface GeminiLiveDisplayMediaBinding {
readonly bindingId: string;
readonly documentId: string;
readonly owner: RendererDocumentOwner;
allowsDisplayRequest(request: Electron.DisplayMediaRequestHandlerHandlerRequest): boolean;
Expand Down Expand Up @@ -68,6 +72,7 @@ export function bindGeminiLiveDisplayMediaDocument(
};

return {
bindingId: randomUUID(),
documentId: owner.documentId,
owner,
allowsDisplayRequest: (request) =>
Expand All @@ -78,12 +83,18 @@ export function bindGeminiLiveDisplayMediaDocument(
request.frame !== null &&
liveFrame(request.frame) &&
sameFrame(request.frame, frame),
allowsPermissionRequest: (webContents, permission, details) =>
current() &&
webContents === sender &&
permission === "display-capture" &&
details.isMainFrame === true &&
details.requestingUrl === requestingUrl,
allowsPermissionRequest: (webContents, permission, details) => {
const audioOnly =
details.mediaType === "audio" ||
(details.mediaTypes?.length === 1 && details.mediaTypes[0] === "audio");
return (
current() &&
webContents === sender &&
(permission === "display-capture" || (permission === "media" && audioOnly)) &&
details.isMainFrame === true &&
details.requestingUrl === requestingUrl
);
},
};
}

Expand Down Expand Up @@ -115,43 +126,53 @@ interface DisplayMediaGuardSession {
): void;
}

const guardedSessions = new WeakSet<object>();
const guardedSessions = new WeakMap<object, () => void>();

/**
* Installs the display-capture boundary once per Electron session. Every
* permission other than display-capture keeps Electron's default allow, and a
* display request succeeds only while a currently bound Live document admits
* it. The system-picker session never dispatches to the fallback handler; any
* display or microphone permission succeeds only for a currently bound Live
* document. Every unrelated permission is denied while this temporary guard
* owns the session policy, and disposal restores Electron's default handlers.
* The system-picker session never dispatches to the fallback handler; any
* non-picker dispatch is denied rather than trusted to select a source.
*/
export function installGeminiLiveDisplayMediaGuards(
electronSession: DisplayMediaGuardSession,
getBindings: () => readonly GeminiLiveDisplayMediaBinding[],
): void {
if (guardedSessions.has(electronSession)) return;
guardedSessions.add(electronSession);
): () => void {
const installed = guardedSessions.get(electronSession);
if (installed) return installed;
let active = true;
const dispose = () => {
if (!active || guardedSessions.get(electronSession) !== dispose) return;
active = false;
guardedSessions.delete(electronSession);
electronSession.setPermissionCheckHandler(null);
electronSession.setPermissionRequestHandler(null);
electronSession.setDisplayMediaRequestHandler(null);
};
guardedSessions.set(electronSession, dispose);
electronSession.setPermissionCheckHandler(
(webContents, permission, _requestingOrigin, details) => {
if (String(permission) !== "display-capture" || !webContents) return true;
if (!webContents) return false;
return getBindings().some((binding) =>
binding.allowsPermissionRequest(webContents, "display-capture", {
binding.allowsPermissionRequest(webContents, String(permission), {
isMainFrame: details.isMainFrame === true,
requestingUrl: details.requestingUrl ?? "",
mediaType: details.mediaType,
}),
);
},
);
electronSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
if (String(permission) !== "display-capture") {
callback(true);
return;
}
const mediaDetails = details as Electron.MediaAccessPermissionRequest;
callback(
getBindings().some((binding) =>
binding.allowsPermissionRequest(webContents, "display-capture", {
binding.allowsPermissionRequest(webContents, String(permission), {
isMainFrame: details.isMainFrame === true,
requestingUrl: details.requestingUrl ?? "",
mediaTypes: mediaDetails.mediaTypes,
}),
),
);
Expand All @@ -163,4 +184,5 @@ export function installGeminiLiveDisplayMediaGuards(
},
GEMINI_LIVE_SYSTEM_PICKER_OPTIONS,
);
return dispose;
}
16 changes: 16 additions & 0 deletions main/services/gemini-live/service-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import * as path from "node:path";
import { randomUUID } from "node:crypto";
import type { RendererDocumentOwner } from "../renderer-document-owner.js";
import { Behavior } from "@google/genai";
import { session } from "electron";
import { installGeminiLiveDisplayMediaGuards } from "./display-media-contract.js";

const aidenLiveThreadStore = new AidenLiveThreadStore(() =>
path.join(app.getPath("userData"), "aiden-live"),
Expand Down Expand Up @@ -70,6 +72,8 @@ export async function authorizeAidenLiveComputerUse(
const LIVE_COMPUTER_USE_DESCRIPTION =
"Use Aiden's Computer Use controller during this user-started Live session. Capture an exact window first. You may operate Aiden itself to focus its main composer, choose the current web model or Actions menu, send a prompt, and create or review scheduled tasks. Execute the user's requested actions directly without per-action approval prompts. Keep speaking naturally while work is in progress. Stop when the session ends or the user cancels. Never claim success before the tool result confirms it.";

let disposeDisplayMediaGuards: (() => void) | null = null;

/**
* The acceptance-gated beta resolves only the recorded
* `gemini-3.8-live-extended-thinking` model; it
Expand All @@ -95,6 +99,18 @@ export const geminiLiveService = new GeminiLiveService({
},
resolveModel: () => experimentalGeminiLiveModel(),
screenShareEnabled: () => geminiLiveScreenEnabled(),
onDisplayBindingsChanged: (bindings) => {
if (bindings.length > 0 && !disposeDisplayMediaGuards) {
disposeDisplayMediaGuards = installGeminiLiveDisplayMediaGuards(
session.defaultSession,
() => geminiLiveService.displayMediaBindings(),
);
} else if (bindings.length === 0 && disposeDisplayMediaGuards) {
const dispose = disposeDisplayMediaGuards;
disposeDisplayMediaGuards = null;
dispose();
}
},
createConnector: (apiKey) => createOwnedGoogleGenAIConnector({ apiKey }),
prepareComputerUse: async ({ authorization, owner, sessionId, signal }) => {
if (!authorization || signal.aborted || owner.isDestroyed()) return null;
Expand Down
Loading