Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6df3337
feat(desktop): resolve Chromium cookie keys on Linux
juliusmarminge Aug 16, 2026
99abc41
fix(desktop): read Linux Chromium secrets from libsecret
juliusmarminge Aug 29, 2026
a9599d2
fix(desktop): preserve Linux Chromium secret whitespace
juliusmarminge Aug 29, 2026
5b71d0a
fix(desktop): preserve Linux keyring denial
juliusmarminge Aug 29, 2026
cfe9f55
fix(desktop): preserve Chromium cookie formats
juliusmarminge Aug 29, 2026
5f6dc48
refactor(desktop): require Chromium key material
juliusmarminge Aug 29, 2026
260103a
fix(desktop): drain secret tool output concurrently
juliusmarminge Aug 29, 2026
62654c1
test(desktop): cover mixed Chromium Linux keys
juliusmarminge Aug 29, 2026
039e6b2
style(desktop): format Chromium key reader
juliusmarminge Aug 29, 2026
85d0aa8
fix(desktop): stabilize secret tool errors
juliusmarminge Aug 29, 2026
6124d03
refactor(desktop): inject secret tool environment
juliusmarminge Aug 29, 2026
08b41dc
fix(desktop): recover Linux cookies written with the empty-passphrase…
juliusmarminge Sep 1, 2026
6a4f2a1
fix(desktop): read legacy cleartext Chromium cookies on Linux too
juliusmarminge Sep 2, 2026
1446db0
fix(desktop): match Chromium's libsecret schema when reading the Linu…
juliusmarminge Sep 2, 2026
262c9c3
fix(desktop): enable Helium browser import on Linux
juliusmarminge Sep 3, 2026
7e4788b
fix(desktop): bundle the Linux browser keyring reader
juliusmarminge Sep 3, 2026
756d99d
feat(desktop): import Helium cookies on Windows
juliusmarminge Sep 4, 2026
0da299e
fix(desktop): detect running Helium on Windows
juliusmarminge Sep 4, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
- name: Typecheck
run: vpr typecheck

- name: Install browser secret helper build libraries
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config

- name: Build desktop pipeline
run: vp run build:desktop

Expand Down Expand Up @@ -85,6 +88,9 @@ jobs:
- name: Ensure Electron runtime is installed
run: vp run --filter @t3tools/desktop ensure:electron

- name: Install browser secret helper build libraries
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config

- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ jobs:
- name: Typecheck
run: vp run typecheck

- name: Install browser secret helper build libraries
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config

- name: Test
run: vp run test

Expand Down Expand Up @@ -524,12 +527,13 @@ jobs:
exit $code
}

- name: Install ImageMagick
- name: Install Linux desktop build libraries
if: matrix.platform == 'linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y libsecret-1-dev pkg-config
if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y imagemagick
fi

Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/scripts/browser-secret-native.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";

// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime.
const hostArch = process.arch;
// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
const hostPlatform = process.platform;

describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {
let directory;
let executable;
beforeAll(() => {
directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-secret-test-"));
executable = NodePath.join(directory, "t3-browser-secret");
const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url));
const flags = NodeChildProcess.execFileSync(
"pkg-config",
["--cflags", "--libs", "libsecret-1"],
{
encoding: "utf8",
},
)
.trim()
.split(/\s+/);
NodeChildProcess.execFileSync(
process.env.CC || "cc",
[
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
NodePath.join(root, "main.c"),
NodePath.join(root, "test.c"),
"-Wl,--wrap=secret_service_search_sync",
"-Wl,--wrap=secret_item_get_locked",
"-Wl,--wrap=secret_item_get_secret",
"-o",
executable,
...flags,
],
{ stdio: "pipe" },
);
});
afterAll(() => {
if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
});

const run = (args) =>
NodeChildProcess.spawnSync(executable, args, {
env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: "unix:path=/unused-test-bus" },
});

it("builds an executable for the requested architecture into a staged resource directory", () => {
const output = NodePath.join(directory, "resources", "browser-secret", "t3-browser-secret");
NodeChildProcess.execFileSync(process.execPath, [
NodeURL.fileURLToPath(new URL("./build-browser-secret.mjs", import.meta.url)),
"--arch",
hostArch,
"--output",
output,
]);
const header = NodeFS.readFileSync(output).subarray(0, 20);
expect(header.toString("hex", 0, 6)).toBe("7f454c460201");
expect(header.readUInt16LE(18)).toBe({ x64: 62, arm64: 183 }[hostArch]);
expect(NodeFS.statSync(output).mode & 0o111).not.toBe(0);
// Invalid arguments exit before the real executable could contact a keyring.
expect(NodeChildProcess.spawnSync(output, []).status).toBe(64);
});

it("preserves the exact secret bytes with no added or removed delimiter", () => {
const result = run(["success"]);
expect(result.status).toBe(0);
expect(result.stdout).toEqual(Buffer.from("secret\0with whitespace \t\r\n"));
expect(result.stderr.length).toBe(0);
});

for (const [scenario, code] of [
["missing", 2],
["empty", 2],
["locked", 3],
["cancelled", 3],
["denied", 3],
["unavailable", 4],
["unloaded", 4],
]) {
it(`reports ${scenario} without emitting a secret`, () => {
const result = run([scenario]);
expect(result.status).toBe(code);
expect(result.stdout.length).toBe(0);
});
}
it("rejects invalid arguments before accessing the keyring", () => {
for (const args of [[], [""], ["chrome", "extra"]]) {
const result = run(args);
expect(result.status).toBe(64);
expect(result.stdout.length).toBe(0);
}
});
});
66 changes: 66 additions & 0 deletions apps/desktop/scripts/build-browser-secret.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import * as NodeUtil from "node:util";

// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime.
const hostArch = process.arch;
// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
const hostPlatform = process.platform;

const { values } = NodeUtil.parseArgs({
options: { output: { type: "string" }, arch: { type: "string", default: hostArch } },
});

if (hostPlatform === "linux") {
const machine = { x64: 62, arm64: 183 }[values.arch];
if (machine === undefined) throw new Error(`Unsupported Linux architecture: ${values.arch}`);
const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url));
const source = NodePath.resolve(root, "main.c");
const output = values.output ?? NodePath.resolve(root, "build", values.arch, "t3-browser-secret");
const matchesArchitecture = (file) => {
const header = NodeFS.readFileSync(file).subarray(0, 20);
return header.toString("hex", 0, 6) === "7f454c460201" && header.readUInt16LE(18) === machine;
};
let current = false;
try {
current =
NodeFS.statSync(output).mtimeMs >=
Math.max(
NodeFS.statSync(source).mtimeMs,
NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs,
) && matchesArchitecture(output);
} catch {
/* The first build has no output yet. */
}
if (!current) {
let flags;
try {
flags = NodeChildProcess.execFileSync("pkg-config", ["--cflags", "--libs", "libsecret-1"], {
encoding: "utf8",
})
.trim()
.split(/\s+/);
} catch (cause) {
throw new Error(
"Building the Linux browser import helper requires pkg-config and libsecret development headers (Ubuntu/Debian: libsecret-1-dev).",
{ cause },
);
}
NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true });
const temporary = `${output}.${process.pid}.tmp`;
try {
NodeChildProcess.execFileSync(
process.env.CC || "cc",
["-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", source, "-o", temporary, ...flags],
{ stdio: "inherit" },
);
if (!matchesArchitecture(temporary))
throw new Error(`C compiler did not produce a Linux ${values.arch} executable.`);
NodeFS.renameSync(temporary, output);
} finally {
NodeFS.rmSync(temporary, { force: true });
}
}
}
6 changes: 6 additions & 0 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
const hostPlatform = NodeOS.platform();

NodeChildProcess.execFileSync(
process.execPath,
[NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
{ stdio: "inherit" },
);

await waitForResources({
baseDir: desktopDir,
files: requiredFiles,
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import * as NodeChildProcess from "node:child_process";
import * as NodePath from "node:path";

import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";

NodeChildProcess.execFileSync(
process.execPath,
[NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
{ stdio: "inherit" },
);

const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
Expand Down Expand Up @@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
const desktopPreviewLayer = PreviewManager.layer.pipe(
// Merged rather than provided so the IPC handlers can reach the import
// service alongside the manager; both sit on the same BrowserSession.
Layer.provideMerge(BrowserImport.layer),
Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
Comment thread
juliusmarminge marked this conversation as resolved.
Layer.provideMerge(BrowserSession.layer),
Layer.provideMerge(desktopFoundationLayer),
);
Expand Down
20 changes: 10 additions & 10 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> {
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
// Chromium's key lives in an OS credential store, and only the macOS one is
// implemented; Firefox needs no key at all, so it works everywhere.
if (definition.engine === "chromium" && context.platform !== "darwin") {
return "unsupportedPlatform";
}
if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
if (yield* isSourceRunning(definition, context)) return "browserRunning";
return undefined;
Expand Down Expand Up @@ -259,21 +254,26 @@ export const make = Effect.gen(function* BrowserImportMake() {
// identifiable and each tag is handled on its own below. The success side
// is normalized to one shape too, so the skipped tally survives either
// engine — Firefox stores plaintext, so nothing there is ever unreadable.
const userDataDirectory = definition.userDataDirectory(pathContext);
const read: Effect.Effect<
CookieReadResult,
ChromiumCookieReadError | FirefoxCookieReadError,
FileSystem.FileSystem | Path.Path | Scope.Scope
FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
> =
definition.engine === "firefox"
? readFirefoxCookies(databasePath).pipe(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: readChromiumCookies({
cookieDatabasePath: databasePath,
// Only reached on macOS: `unavailableReason` rejects Chromium
// elsewhere until those key stores are implemented.
keychainService: definition.keychainService ?? "",
keychainAccount: definition.keychainAccount ?? "",
keychainService: definition.keychainService,
keychainAccount: definition.keychainAccount,
linuxSecretApplication: definition.linuxSecretApplication,
...(platform === "win32" && userDataDirectory !== undefined
? {
windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
}
: {}),
platform,
});

Expand Down
Loading
Loading