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
52 changes: 52 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,55 @@ jobs:
releaseId: ${{ needs.create-release.outputs.release_id }}
releaseBody: ${{ steps.notes.outputs.body }}
args: ${{ matrix.args }}

# The extension zips land in the same draft release as the installers. The
# extension is Vite builds plus web-ext, so this job needs no Rust toolchain
# and no proxy build.
publish-extension:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v6
with:
version: 10

- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

- name: Build (chrome)
run: pnpm --filter subclave-extension build --target chrome

- name: Build (firefox)
run: pnpm --filter subclave-extension build --target firefox

- name: Package the zips
run: pnpm --filter subclave-extension zip

# By `release_id`, as tauri-action does above: a re-run creates a second
# draft for the same tag, so a lookup by tag could pick the wrong one.
- name: Upload the zips to the draft release
uses: actions/github-script@v9
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const fs = require("fs");
for (const name of ["subclave-chrome.zip", "subclave-firefox.zip"]) {
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
name,
data: fs.readFileSync(`extension/dist/${name}`),
headers: { "content-type": "application/zip" },
});
}
1 change: 1 addition & 0 deletions KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ lives, and what would change it.
- **A Domain-rule login still prompts on other hosts**: a login stored only under a Domain rule still gets a save prompt on another host of that domain, because the check, like every inline request, sees exact-host entries only. `src-tauri/src/modules/browser/actions.rs` (`check_login`). Changes when: wider matches get a browser-owned save path.
- **A typed password waits in session storage**: a pending sign-in is dropped after 3 page loads in its tab (loads on another site count, though the prompt shows only on the submit's site), and after 5 minutes once its tab loads another page. Until then, or until the tab closes or the browser exits, the typed password stays in `chrome.storage.session`, which the browser keeps in memory. `extension/src/background.ts` (`PENDING_SAVE_TTL_MS`, `PENDING_SAVE_PAGE_LOADS`). Changes when: the service worker can expire it on a timer that survives a restart.
- **Sandboxed browsers**: for Snap and Flatpak installs the manifests are also written into the sandboxed profile path and Settings > Browser marks them, but native messaging from Snap Firefox is not tested yet. `src-tauri/src/modules/browser/manifests.rs` (`BrowserSlot`, `sandboxed`). Changes when: a run on Snap Firefox confirms it, or shows what it needs.
- **The extension is installed and updated by hand**: releases attach `subclave-chrome.zip` and `subclave-firefox.zip` instead of a store listing, so Chromium runs it as an unpacked extension that never updates itself, and release and Beta Firefox, which require Mozilla's signature, load it only as a temporary add-on that a restart removes. `.github/workflows/release.yml` (`publish-extension`), `README.md` (Install the browser extension). Changes when: the extension is listed on the Chrome Web Store and signed by Mozilla (listed, or unlisted with an `update_url`).

## Import and backup

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,51 @@ your machine and syncs end to end through storage you own.

v0.1.0 is the first release: an encrypted vault, sync to your own S3 bucket or WebDAV share, and a browser extension for Chromium browsers and Firefox.

## Install the browser extension

Releases after v0.1.0 attach the extension next to the installers:
`subclave-chrome.zip` and `subclave-firefox.zip`. It is not in the Chrome Web
Store or signed by Mozilla yet, so it is loaded by hand.

First, in Subclave, open Settings > Browser and turn on "Chromium browsers" or
"Firefox". That writes the native messaging manifest the extension connects
through.

### Chromium browsers (Chrome, Chromium, Edge, Brave, Vivaldi)

1. Unzip `subclave-chrome.zip` into a folder you keep. The browser loads the
extension from that folder every time it starts.
2. Open `chrome://extensions`, turn on Developer mode and click Load unpacked.
3. Pick the unzipped folder. The extension's card shows the ID
`fbefjngeldidlcdilbigapddeimhklpn`, the only origin Subclave's native
messaging manifest allows. The manifest pins a `key`, so every unpacked
copy gets this ID.
4. With Subclave running and unlocked, open the browser's Extensions menu on
the toolbar, click Subclave (pin it to keep it on the toolbar), then Pair
with Subclave. Click Allow in Subclave once its code matches the one in the
popup.

To update, replace the folder's contents with the next release's
`subclave-chrome.zip` and click reload on the extension's card. The browser
does not update an unpacked extension by itself.

### Firefox

Release and Beta Firefox install only add-ons signed by Mozilla, and this one
is not signed yet:

- Release or Beta Firefox: open `about:debugging#/runtime/this-firefox`, click
Load Temporary Add-on and pick `subclave-firefox.zip`. Firefox removes it
when it restarts, so it has to be loaded again after every restart.
- Firefox ESR, Developer Edition or Nightly: set
`xpinstall.signatures.required` to `false` in `about:config`, then in
`about:addons` pick Install Add-on From File in the gear menu and choose
`subclave-firefox.zip`. It stays installed.

Pair from the Extensions menu as in step 4 above. If the popup offers Pair
with Subclave again after a restart, pair again and revoke the old entry under
Settings > Browser.

## Build from source

Needs Rust stable, Node 20.19+ with pnpm, and
Expand Down
4 changes: 2 additions & 2 deletions SUBCLAVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ Agent memory and contributor reference for Subclave. Build and PR rules:
isolating stores and logs in a `.dev` data dir.
- Release: feature branch -> PR into `dev` (squash) -> PR `dev` -> `main`
(merge commit) -> annotated tag `vX.Y.Z` on `main`. The tag (`v*`) triggers
`.github/workflows/release.yml`, which builds signed updates and a draft
GitHub Release; notes are generated from `CHANGELOG.md` via
`.github/workflows/release.yml`, which builds signed updates, the extension
zips and a draft GitHub Release; notes are generated from `CHANGELOG.md` via
`scripts/release-notes.mjs`, which reads the heading
`## [X.Y.Z] - DD-MM-YYYY`. The file holds only the latest release; every
earlier version lives in GitHub Releases, so a new draft replaces the old
Expand Down
41 changes: 17 additions & 24 deletions extension/test/fill.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,13 @@ const setMode = (mode: FakeMode) => harnessSetMode(harness, mode);
const readLog = () => harnessReadLog(harness);
const readConnects = () => harnessReadConnects(harness);

async function openPopupPage(): Promise<Page> {
return openPopup(harness.context, harness.extensionId);
async function openPopupPage(front?: Page): Promise<Page> {
return openPopup(harness.context, harness.extensionId, front);
}

test("lists every match and fills username and password", async () => {
const fixture = await openFixture("login.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

const options = popup.getByRole("option");
await expect(options).toHaveCount(FAKE_ENTRIES.length);
Expand All @@ -68,8 +67,7 @@ test("lists every match and fills username and password", async () => {

test("never fills hidden, zero-size or decoy password inputs, on any path", async () => {
const fixture = await openFixture("hidden-fields.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

await popup.getByRole("option").first().click();
await expect(fixture.locator("#username")).toHaveValue(FAKE_ENTRIES[0].username);
Expand Down Expand Up @@ -105,8 +103,7 @@ test("never fills hidden, zero-size or decoy password inputs, on any path", asyn

test("reports a page with no fillable inputs", async () => {
const fixture = await openFixture("no-fields.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

await popup.getByRole("option").first().click();
await expect(popup.getByText("No login fields found on this page.")).toBeVisible();
Expand All @@ -128,16 +125,15 @@ test("fills through the executeScript fallback when the content script is absent
expect(missing).toBe(true);
}

const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);
await popup.getByRole("option").first().click();
await expect(fixture.locator("#username")).toHaveValue(FAKE_ENTRIES[0].username);
await expect(fixture.locator("#password")).toHaveValue(FAKE_PASSWORD);
});

test("the fill command fills the newest match", async () => {
const fixture = await openFixture("login.html");
const popup = await openPopupPage();
const popup = await openPopupPage(fixture);
// Model the browsers without `chrome.action.openPopup` (Firefox, Chrome
// before 127), which is the condition the newest-lastUsedAt fallback exists
// for. Headless Chromium resolves openPopup() without ever rendering one, so
Expand All @@ -146,7 +142,6 @@ test("the fill command fills the newest match", async () => {
const action: { openPopup?: () => Promise<void> } = chrome.action;
delete action.openPopup;
});
await fixture.bringToFront();

await fixture.keyboard.press("Control+Shift+L");
await fixture.waitForTimeout(750);
Expand All @@ -173,11 +168,13 @@ test("generates a password, fills the signup form and saves a new entry", async
const fixture = await openFixture("signup.html");
await fixture.locator("#username").fill("newuser");

const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);
await popup.getByRole("button", { name: "Generate for this site" }).click();
await expect(fixture.locator("#password")).toHaveValue(FAKE_GENERATED);
await expect(fixture.locator("#password-confirm")).toHaveValue(FAKE_GENERATED);
// The page is filled before save-login is called; the notice shows only
// after save-login answers, so the log holds it from here on.
await expect(popup.getByText("Password saved to Subclave")).toBeVisible();

const saved = (await readLog()).find((entry) => entry.action === "save-login");
expect(saved?.params?.username).toBe("newuser");
Expand All @@ -188,13 +185,13 @@ test("generates a password, fills the signup form and saves a new entry", async

test("offers an update per match and keeps the entry id", async () => {
const fixture = await openFixture("signup.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

const update = popup.getByRole("button", { name: `Update ${FAKE_ENTRIES[1].title}` });
await expect(update).toBeVisible();
await update.click();
await expect(fixture.locator("#password")).toHaveValue(FAKE_GENERATED);
await expect(popup.getByText("Password saved to Subclave")).toBeVisible();

const saved = (await readLog()).find((entry) => entry.action === "save-login");
expect(saved?.params?.entryId).toBe(FAKE_ENTRIES[1].id);
Expand Down Expand Up @@ -227,8 +224,7 @@ test("offers pairing and shows the six-digit code", async () => {
// `chrome-extension://` tab (no `tabs` permission), so the service worker
// needs a real page to be the active tab before it can call `get-logins`.
const fixture = await openFixture("login.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

const pair = popup.getByRole("button", { name: "Pair with Subclave" });
await expect(pair).toBeVisible();
Expand All @@ -243,8 +239,7 @@ test("offers pairing and shows the six-digit code", async () => {
test("a generate on a page with no password field saves nothing", async () => {
await setMode("no-fields");
const fixture = await openFixture("no-fields.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);

await popup.getByRole("button", { name: "Generate for this site" }).click();
await expect(popup.getByText("No login fields found on this page.")).toBeVisible();
Expand All @@ -255,8 +250,7 @@ test("a page load opens no connection and the first popup fill opens one", async
const fixture = await openFixture("login.html");
expect(await readConnects()).toBe(0);

const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);
await popup.getByRole("option").first().click();
await expect(fixture.locator("#password")).toHaveValue(FAKE_PASSWORD);

Expand All @@ -265,8 +259,7 @@ test("a page load opens no connection and the first popup fill opens one", async

test("re-handshakes a replaced connection instead of forcing a re-pair", async () => {
const fixture = await openFixture("login.html");
const popup = await openPopupPage();
await fixture.bringToFront();
const popup = await openPopupPage(fixture);
// Wait for the popup's own state read (a get-logins) to finish, so the drop
// below lands on the fill's get-credential.
await expect(popup.getByRole("option")).toHaveCount(FAKE_ENTRIES.length);
Expand Down
3 changes: 1 addition & 2 deletions extension/test/inline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ test("with Show in login fields off there are no icons and the popup still fills
await page.waitForTimeout(1000);
expect(await inlineOf(page).count(".icon")).toBe(0);

const popup = await openPopup(harness.context, harness.extensionId);
await page.bringToFront();
const popup = await openPopup(harness.context, harness.extensionId, page);
await popup.getByRole("option").first().click();
await expect(page.locator("#username")).toHaveValue(FAKE_ENTRIES[0].username);
await expect(page.locator("#password")).toHaveValue(FAKE_PASSWORD);
Expand Down
15 changes: 13 additions & 2 deletions extension/test/support/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,20 @@ export async function serviceWorker(context: BrowserContext): Promise<Worker> {
return existing ?? (await context.waitForEvent("serviceworker"));
}

/** The popup runs as an ordinary tab; its id comes from the service worker URL. */
export async function openPopup(context: BrowserContext, extensionId: string): Promise<Page> {
/**
* The popup runs as an ordinary tab; its id comes from the service worker URL.
* The service worker answers for the active tab, and a real popup is not a tab,
* so `front` (the page under test) is brought to the front before popup.html
* loads: the popup's first `get-state` then sees that page, never the popup's
* own tab.
*/
export async function openPopup(
context: BrowserContext,
extensionId: string,
front?: Page,
): Promise<Page> {
const popup = await context.newPage();
await front?.bringToFront();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
return popup;
}
Expand Down
Loading