From 9988d7e14cb50aa84d0e9d34ad8245e6bc986064 Mon Sep 17 00:00:00 2001 From: Rendi Wijaya Date: Sat, 3 Oct 2026 21:31:59 +0700 Subject: [PATCH 1/2] test(extension): settle the two flaky fill.spec popup tests Both failures were races in the tests, not in the extension. openPopup loaded popup.html in a tab that was active at the time, and the tests brought the fixture to the front only afterwards. The popup's first get-state answers for the active tab, and chrome.tabs.query hides the url of a chrome-extension:// tab, so a get-state that ran first got "No active page" and the popup never listed an option. openPopup now takes the page under test and brings it to the front before popup.html loads, so the popup loads in a background tab and its first get-state sees that page. generateAndFill fills the page before it calls save-login, so a log read taken as soon as #password held the generated value could run before the fake app had logged save-login. Both generate tests now wait for the popup's "Password saved to Subclave" notice, which shows only after save-login has answered. Closes #15 --- extension/test/fill.spec.ts | 41 +++++++++++++------------------ extension/test/inline.spec.ts | 3 +-- extension/test/support/harness.ts | 15 +++++++++-- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/extension/test/fill.spec.ts b/extension/test/fill.spec.ts index 57e65fe..7dbd9cc 100644 --- a/extension/test/fill.spec.ts +++ b/extension/test/fill.spec.ts @@ -37,14 +37,13 @@ const setMode = (mode: FakeMode) => harnessSetMode(harness, mode); const readLog = () => harnessReadLog(harness); const readConnects = () => harnessReadConnects(harness); -async function openPopupPage(): Promise { - return openPopup(harness.context, harness.extensionId); +async function openPopupPage(front?: Page): Promise { + 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); @@ -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); @@ -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(); @@ -128,8 +125,7 @@ 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); @@ -137,7 +133,7 @@ test("fills through the executeScript fallback when the content script is absent 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 @@ -146,7 +142,6 @@ test("the fill command fills the newest match", async () => { const action: { openPopup?: () => Promise } = chrome.action; delete action.openPopup; }); - await fixture.bringToFront(); await fixture.keyboard.press("Control+Shift+L"); await fixture.waitForTimeout(750); @@ -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"); @@ -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); @@ -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(); @@ -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(); @@ -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); @@ -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); diff --git a/extension/test/inline.spec.ts b/extension/test/inline.spec.ts index c9f99ad..2dd10d5 100644 --- a/extension/test/inline.spec.ts +++ b/extension/test/inline.spec.ts @@ -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); diff --git a/extension/test/support/harness.ts b/extension/test/support/harness.ts index 9ba29f5..ff0c0f4 100644 --- a/extension/test/support/harness.ts +++ b/extension/test/support/harness.ts @@ -59,9 +59,20 @@ export async function serviceWorker(context: BrowserContext): Promise { 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 { +/** + * 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 { const popup = await context.newPage(); + await front?.bringToFront(); await popup.goto(`chrome-extension://${extensionId}/popup.html`); return popup; } From cc295cce5f21a143c7d6ef64ad48e79c8861a804 Mon Sep 17 00:00:00 2001 From: Rendi Wijaya Date: Sat, 3 Oct 2026 21:32:02 +0700 Subject: [PATCH 2/2] feat(release): attach the extension zips to the draft release A tagged release shipped only the desktop installers, so getting the extension meant building it from source or fetching a CI artifact that expires after 7 days. The new publish-extension job runs after create-release. It builds the chrome and firefox targets, zips them with the same commands as CI's extension job, and uploads subclave-chrome.zip and subclave-firefox.zip into the draft by release_id, as tauri-action does: a re-run creates a second draft for the same tag, so a lookup by tag could pick the wrong one. It needs no Rust toolchain and no proxy build. README.md gains an install section: Load unpacked on Chromium browsers, with the pinned ID fbefjngeldidlcdilbigapddeimhklpn and the manual update, and on Firefox a temporary add-on on release and Beta or a file install with signatures off on ESR, Developer Edition and Nightly. KNOWN-LIMITS.md records the manual install and update; SUBCLAVE.md's release workflow mentions the zips. Closes #16 --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++++++++ KNOWN-LIMITS.md | 1 + README.md | 45 ++++++++++++++++++++++++++++++ SUBCLAVE.md | 4 +-- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7c08d1..6934d67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" }, + }); + } diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index e9b9ba6..0a2380f 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -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 diff --git a/README.md b/README.md index a5dd117..b5bf207 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SUBCLAVE.md b/SUBCLAVE.md index 9c558bf..9ff9f5a 100644 --- a/SUBCLAVE.md +++ b/SUBCLAVE.md @@ -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