Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ const startup = Effect.gen(function* () {
});
}
yield* appIdentity.configure;
yield* appIdentity.configureWebAuthn;
yield* applicationMenu.configure;
yield* updates.configure;
yield* DesktopRemoteUpdates.listen;
Expand Down
77 changes: 77 additions & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInp
};

interface ElectronAppCalls {
readonly configureWebAuthn: Array<Electron.ConfigureWebAuthnOptions>;
readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>;
readonly setDockIcon: string[];
readonly setName: string[];
Expand Down Expand Up @@ -63,6 +64,10 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
Effect.sync(() => {
calls.setDockIcon.push(iconPath);
}),
configureWebAuthn: (options) =>
Effect.sync(() => {
calls.configureWebAuthn.push(options);
}),
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
removeCommandLineSwitch: () => Effect.void,
Expand Down Expand Up @@ -116,6 +121,7 @@ const withIdentity = <A, E, R>(
} = {},
) => {
const calls: ElectronAppCalls = input.calls ?? {
configureWebAuthn: [],
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
Expand Down Expand Up @@ -186,6 +192,7 @@ describe("DesktopAppIdentity", () => {

it.effect("configures app identity from the environment commit override", () => {
const calls: ElectronAppCalls = {
configureWebAuthn: [],
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
Expand Down Expand Up @@ -218,6 +225,7 @@ describe("DesktopAppIdentity", () => {

it.effect("sets the dock icon only when running unpackaged", () => {
const calls: ElectronAppCalls = {
configureWebAuthn: [],
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
Expand All @@ -239,4 +247,73 @@ describe("DesktopAppIdentity", () => {
},
);
});

describe("configureWebAuthn", () => {
const signedPackageJson = JSON.stringify({
t3codeCommitHash: "abcdef1234567890",
t3codeWebAuthnKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn",
});

const configuredWebAuthn = (input: {
readonly environment?: TestEnvironmentInput;
readonly packageJson?: string;
}) => {
const calls: ElectronAppCalls = {
configureWebAuthn: [],
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
};
return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configureWebAuthn;
return calls.configureWebAuthn;
}),
{ ...input, calls },
);
};

it.effect("enables Touch ID with the group the signed macOS build embedded", () =>
Effect.gen(function* () {
const configured = yield* configuredWebAuthn({ packageJson: signedPackageJson });

assert.deepEqual(configured, [
{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } },
]);
}),
);

it.effect("skips builds that were not signed with a keychain access group", () =>
Effect.gen(function* () {
assert.deepEqual(
yield* configuredWebAuthn({ packageJson: '{"t3codeCommitHash":"abcdef1234567890"}' }),
[],
);
assert.deepEqual(yield* configuredWebAuthn({ packageJson: "not json" }), []);
}),
);

it.effect("skips unpackaged runs and other platforms", () =>
Effect.gen(function* () {
assert.deepEqual(
yield* configuredWebAuthn({
environment: { isPackaged: false },
packageJson: signedPackageJson,
}),
[],
);
for (const platform of ["win32", "linux"] as const) {
assert.deepEqual(
yield* configuredWebAuthn({
environment: { platform },
packageJson: signedPackageJson,
}),
[],
platform,
);
}
}),
);
});
});
40 changes: 26 additions & 14 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12;

const AppPackageMetadata = Schema.Struct({
t3codeCommitHash: Schema.optional(Schema.String),
t3codeWebAuthnKeychainAccessGroup: Schema.optional(Schema.String),
});
const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));

Expand All @@ -35,6 +36,8 @@ export class DesktopAppIdentity extends Context.Service<
{
readonly resolveUserDataPath: Effect.Effect<string, DesktopUserDataPathResolutionError>;
readonly configure: Effect.Effect<void>;
/** Enables Touch ID passkeys in web content. Call once the app is ready. */
readonly configureWebAuthn: Effect.Effect<void>;
}
>()("@t3tools/desktop/app/DesktopAppIdentity") {}

Expand Down Expand Up @@ -74,20 +77,17 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none());

const resolveEmbeddedCommitHash = Effect.gen(function* () {
const packageJsonPath = environment.path.join(environment.appRoot, "package.json");
const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option);
return yield* Option.match(raw, {
onNone: () => Effect.succeed(Option.none<string>()),
onSome: (value) =>
decodeAppPackageMetadata(value).pipe(
Effect.map((parsed) =>
Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)),
),
Effect.orElseSucceed(() => Option.none<string>()),
),
});
});
const readEmbeddedPackageMetadata = fileSystem
.readFileString(environment.path.join(environment.appRoot, "package.json"))
.pipe(Effect.flatMap(decodeAppPackageMetadata), Effect.option);

const resolveEmbeddedCommitHash = readEmbeddedPackageMetadata.pipe(
Effect.map(
Option.flatMap((metadata) =>
Option.fromNullishOr(metadata.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)),
),
),
);

const resolveAboutCommitHash = Effect.gen(function* () {
const cached = yield* Ref.get(commitHashCache);
Expand Down Expand Up @@ -143,9 +143,21 @@ export const make = Effect.gen(function* () {
}
}).pipe(Effect.withSpan("desktop.appIdentity.configure"));

// Only signed macOS packaging writes the keychain group into package.json, so
// other builds skip this; Chromium also reports no platform authenticator if
// the running binary lacks the matching entitlement.
const configureWebAuthn = Effect.gen(function* () {
if (environment.platform !== "darwin" || !environment.isPackaged) return;
const metadata = yield* readEmbeddedPackageMetadata;
const keychainAccessGroup = Option.getOrUndefined(metadata)?.t3codeWebAuthnKeychainAccessGroup;
if (!keychainAccessGroup) return;
yield* electronApp.configureWebAuthn({ touchID: { keychainAccessGroup } });
}).pipe(Effect.withSpan("desktop.appIdentity.configureWebAuthn"));

return DesktopAppIdentity.of({
resolveUserDataPath: userDataPath,
configure,
configureWebAuthn,
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function makeElectronAppLayer(
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
configureWebAuthn: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
removeCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener),
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export class ElectronApp extends Context.Service<
) => Effect.Effect<boolean>;
readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
/** macOS only. */
readonly configureWebAuthn: (options: Electron.ConfigureWebAuthnOptions) => Effect.Effect<void>;
readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
readonly onBeforeQuitForUpdate: (
listener: () => void,
Expand Down Expand Up @@ -183,6 +185,10 @@ export const make = ElectronApp.of({
Effect.sync(() => {
Electron.app.dock?.setIcon(iconPath);
}),
configureWebAuthn: (options) =>
Effect.sync(() => {
Electron.app.configureWebAuthn(options);
}),
appendCommandLineSwitch: (switchName, value) =>
Effect.sync(() => {
if (value === undefined) {
Expand Down
116 changes: 114 additions & 2 deletions apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

import * as ElectronDialog from "../electron/ElectronDialog.ts";

const { fromPartition, sessions } = vi.hoisted(() => ({
fromPartition: vi.fn(),
sessions: new Map<
Expand All @@ -14,6 +17,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
readonly clearCache: ReturnType<typeof vi.fn>;
readonly clearStorageData: ReturnType<typeof vi.fn>;
readonly getUserAgent: ReturnType<typeof vi.fn<() => string>>;
readonly on: ReturnType<typeof vi.fn>;
readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
readonly setUserAgent: ReturnType<typeof vi.fn>;
Expand All @@ -29,17 +33,41 @@ vi.mock("electron", () => ({

import * as BrowserSession from "./BrowserSession.ts";

const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));
const messageBoxes: Array<Electron.MessageBoxOptions> = [];
let answerMessageBox: (
options: Electron.MessageBoxOptions,
) => Effect.Effect<
Electron.MessageBoxReturnValue,
ElectronDialog.ElectronDialogShowMessageBoxError
> = () => Effect.die("unexpected message box");

const dialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.die("unexpected folder picker"),
pickFiles: () => Effect.die("unexpected file picker"),
showMessageBox: (options) => {
messageBoxes.push(options);
return answerMessageBox(options);
},
showErrorBox: () => Effect.die("unexpected error box"),
} satisfies ElectronDialog.ElectronDialog["Service"]);

const layer = BrowserSession.layer.pipe(
Layer.provide(NodeServices.layer),
Layer.provide(dialogLayer),
);

describe("BrowserSession", () => {
beforeEach(() => {
sessions.clear();
messageBoxes.length = 0;
answerMessageBox = () => Effect.die("unexpected message box");
fromPartition.mockReset();
fromPartition.mockImplementation((partition: string) => {
const browserSession = {
clearCache: vi.fn(() => Promise.resolve()),
clearStorageData: vi.fn(() => Promise.resolve()),
getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
on: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn(),
Expand Down Expand Up @@ -120,6 +148,7 @@ describe("BrowserSession", () => {
clearCache: vi.fn(() => Promise.resolve()),
clearStorageData: vi.fn(() => Promise.resolve()),
getUserAgent: vi.fn(() => userAgent),
on: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn((next: string) => {
Expand Down Expand Up @@ -189,6 +218,85 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

const passkeyAccounts: ReadonlyArray<Electron.WebAuthnAccount> = [
{ credentialId: "cred-alice", name: "alice@example.com", displayName: "Alice" },
{ credentialId: "cred-bob", name: "bob@example.com" },
{ credentialId: "cred-anonymous" },
];

/** Fires the session's `select-webauthn-account` event and waits for its answer. */
const selectPasskey = Effect.fn("selectPasskey")(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-a");
yield* browserSessions.getSession("scope-a");
const listener = sessions
.get(partition)
?.on.mock.calls.find(([eventName]) => eventName === "select-webauthn-account")?.[1];
assert.isFunction(listener);

const answers: Array<string | null | undefined> = [];
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
listener(
{},
{ relyingPartyId: "example.com", accounts: passkeyAccounts, frame: null },
(credentialId?: string | null) => {
answers.push(credentialId);
resolve();
},
);
}),
);
return answers;
});

it.effect("answers a multi-passkey sign-in with the account the user picks", () =>
Effect.gen(function* () {
answerMessageBox = () => Effect.succeed({ response: 1, checkboxChecked: false });

const answers = yield* selectPasskey();

assert.deepEqual(answers, ["cred-bob"]);
assert.strictEqual(messageBoxes.length, 1);
assert.equal(messageBoxes[0]?.message, "Choose a passkey for example.com");
assert.deepEqual(messageBoxes[0]?.buttons, [
"Alice (alice@example.com)",
"bob@example.com",
"Passkey 3",
"Cancel",
]);
assert.strictEqual(messageBoxes[0]?.cancelId, 3);
}).pipe(Effect.provide(layer)),
);

it.effect("cancels a multi-passkey sign-in when the user dismisses the chooser", () =>
Effect.gen(function* () {
answerMessageBox = (options) =>
Effect.succeed({ response: options.cancelId ?? -1, checkboxChecked: false });

assert.deepEqual(yield* selectPasskey(), [null]);
}).pipe(Effect.provide(layer)),
);

it.effect("cancels a multi-passkey sign-in when the chooser cannot open", () =>
Effect.gen(function* () {
answerMessageBox = (options) =>
Effect.fail(
new ElectronDialog.ElectronDialogShowMessageBoxError({
type: options.type ?? null,
titleLength: null,
messageLength: options.message.length,
detailLength: null,
buttonCount: options.buttons?.length ?? 0,
cause: new Error("no window server"),
}),
);

assert.deepEqual(yield* selectPasskey(), [null]);
}).pipe(Effect.provide(layer)),
);

it.effect("preserves partition scope and the platform failure chain", () => {
const nativeCause = new Error("native digest failed");
const platformCause = PlatformError.systemError({
Expand Down Expand Up @@ -218,7 +326,11 @@ describe("BrowserSession", () => {
"Failed to derive a desktop preview browser partition for scope environment-a.",
);
assert.notInclude(error.message, nativeCause.message);
}).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer))));
}).pipe(
Effect.provide(
BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer), Layer.provide(dialogLayer)),
),
);
});

it.effect("preserves session scope, partition, and the Electron failure", () =>
Expand Down
Loading
Loading