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 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; }