mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
Fix secrets bootstrap + modal record UX; add ROADMAP lifecycle tracker
Secrets: - Warm the secret-storage probe before loadSettings so a fresh install actually defaults to Obsidian secret storage instead of caching an unconfigured passphrase envelope first. - setEncryptionMode no longer early-returns on same-mode: an unconfigured passphrase store now always builds its kdf/verifier, so creating a passphrase (incl. on Linux without a keyring) writes secrets.json.nosync instead of silently no-opping. Modal: - The Record tab closes the modal on Stop and runs the pipeline detached with Notice progress (per-stage setMessage) and Notice errors, mirroring the reprocess-audio flow. The persisted recording is the recovery path, so no inline Retry. Paste / From note keep the in-modal Retry flow. Docs: - Replace FEATURES.md with ROADMAP.md: Planned / Unreleased / Released lifecycle, wired into the RELEASING.md release steps. - Update SECRETS.md, CLAUDE.md, and the Commands-and-Menus wiki page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9fedc23b1d
commit
f3b93d5ab9
10 changed files with 118 additions and 21 deletions
|
|
@ -129,6 +129,8 @@ src/
|
|||
|
||||
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer).
|
||||
|
||||
The modal has two pipeline-running paths. The **Record** tab closes the modal the moment recording stops and runs the pipeline detached via `startRecordingPipeline` ([src/ui/modal.ts](src/ui/modal.ts)), reporting progress through a sticky `Notice` (`setMessage` per stage) and surfacing errors as a `Notice` (no inline Retry) — mirroring `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). This is safe because the `persist-audio` stage saved the recording before transcription, so the vault file is the recovery path. The **Paste** and **From note** tabs instead use `execute`, which keeps the modal open with an inline `.rewrite-progress` line and renders an inline **Retry** button on error (they have no persisted recovery, so closing would lose the input). Destination override and context hint are captured into locals before `startRecordingPipeline` closes the modal, so a recorded run still honors them.
|
||||
|
||||
The `PipelineSource` union has three variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
|
||||
|
||||
## Provider system
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change diarization behavior, update this file in the same change, and keep the one-line summary in [CLAUDE.md](../CLAUDE.md) accurate.
|
||||
|
||||
Opt-in per-profile flag `TranscriptionConfig.diarize?: boolean` ([src/types.ts](../src/types.ts)). When on, the capable adapter embeds `Speaker X:` labels into the returned transcript string (the v1 shape from FEATURES.md item 4; the `transcribe(): Promise<string>` interface is unchanged, and cleanup/insert treat the labels as ordinary text). Capability is centralized in `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](../src/transcription/index.ts)), true only for `assemblyai` / `deepgram` / `revai`. The settings tab ([src/settings/tab.ts](../src/settings/tab.ts)) shows an "Identify speakers" toggle in the per-profile transcription block gated on that helper; the setup card is intentionally left without it (not a "fill in the basics" field).
|
||||
Opt-in per-profile flag `TranscriptionConfig.diarize?: boolean` ([src/types.ts](../src/types.ts)). When on, the capable adapter embeds `Speaker X:` labels into the returned transcript string (the v1 shape from the diarization item in the [ROADMAP.md](ROADMAP.md) archive; the `transcribe(): Promise<string>` interface is unchanged, and cleanup/insert treat the labels as ordinary text). Capability is centralized in `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](../src/transcription/index.ts)), true only for `assemblyai` / `deepgram` / `revai`. The settings tab ([src/settings/tab.ts](../src/settings/tab.ts)) shows an "Identify speakers" toggle in the per-profile transcription block gated on that helper; the setup card is intentionally left without it (not a "fill in the basics" field).
|
||||
|
||||
## Per-template override
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ Releases are automated by [.github/workflows/release.yml](../.github/workflows/r
|
|||
```bash
|
||||
# 0. On master, clean working tree, everything you want shipped is committed.
|
||||
npm run build && npm run lint # must both pass
|
||||
# 0a. Roll docs/ROADMAP.md: move the Unreleased items into a new
|
||||
# "### <version> — <YYYY-MM-DD>" block under Released, leave Unreleased empty.
|
||||
# 1. Bump version files (no auto commit/tag so we control the message)
|
||||
npm version patch --no-git-tag-version # or minor / major
|
||||
# 2. Commit the bump
|
||||
git add manifest.json package.json package-lock.json versions.json
|
||||
# 2. Commit the bump (include the rolled roadmap)
|
||||
git add manifest.json package.json package-lock.json versions.json docs/ROADMAP.md
|
||||
git commit -m "1.0.1" # use the new version as the subject
|
||||
# 3. Tag with the BARE version (no leading v) and push
|
||||
git tag -a 1.0.1 -m "Release 1.0.1"
|
||||
|
|
@ -40,6 +42,7 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi
|
|||
2. `npm run lint` passes with zero warnings. The local `eslint-plugin-obsidianmd` is looser than the official review bot, so also eyeball the conflict checklist below.
|
||||
3. Manual smoke test in a real vault for anything you touched. At minimum for a code change: record + Quick Record, run a template insert (cursor / new file / append), and on desktop start the local whisper.cpp server. Install by copying `main.js` / `manifest.json` / `styles.css` into `<Vault>/.obsidian/plugins/rewrite-voice-notes/` (folder name must match the plugin `id`) and reloading.
|
||||
4. Update docs for any behavioral change ([CLAUDE.md](../CLAUDE.md), the user-facing [`wiki/`](../wiki/) pages, and the [README](../README.md)), per the doc-maintenance rules in CLAUDE.md.
|
||||
5. Roll [ROADMAP.md](ROADMAP.md): every item shipping in this release should already have an **Unreleased** entry. Move them into a new `### <version> — <YYYY-MM-DD>` heading at the top of the **Released** archive, and leave `## Unreleased` empty for the next cycle. The version + date must match the tag.
|
||||
|
||||
## Guideline-conflict checklist (what the review bot flags)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Plan: Remove the plaintext secrets mode; defer SecretStorage to GA
|
||||
|
||||
Status: IMPLEMENTED 2026-05-30 (as part of FEATURES.md item 4, alongside keychain verification + Argon2id/entropy hardening). Kept for reference. Captured 2026-05-28.
|
||||
Status: IMPLEMENTED 2026-05-30 (as part of the secrets-encryption hardening tracked in [ROADMAP.md](ROADMAP.md), alongside keychain verification + Argon2id/entropy hardening). Kept for reference. Captured 2026-05-28.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,28 @@
|
|||
# ReWrite — Features To Add
|
||||
# ReWrite — Roadmap
|
||||
|
||||
Forward-looking backlog. Not committed to a release. Add items as they come up; move to a phase plan when picking one up.
|
||||
Single tracker for the lifecycle of every feature and bug fix. Each item moves through three states:
|
||||
|
||||
## Open
|
||||
- **Planned** — agreed/backlogged, not yet built. Not committed to a release.
|
||||
- **Unreleased** — implemented and merged to `master`, but not yet shipped under a version tag. This is the staging area that becomes the next release's notes.
|
||||
- **Released** — shipped under a tagged version (see the per-version headings).
|
||||
|
||||
This file replaces the old `FEATURES.md` (which only had "Open" and "Done"). The extra **Unreleased** state is the point: it makes "done in code" vs "available to users" an explicit, visible distinction, and it doubles as the draft changelog for the next release.
|
||||
|
||||
## How this drives the release process
|
||||
|
||||
The release mechanics live in [RELEASING.md](RELEASING.md); this file is the content side. The two interlock:
|
||||
|
||||
1. **While building:** when you finish a feature or fix, add an entry under **Unreleased** (newest first). Keep the same engineering-detail style as the Released archive below, so the entry is still useful long after it ships.
|
||||
2. **When cutting a release** (the version-bump step in [RELEASING.md](RELEASING.md)): rename the `## Unreleased` heading's items into a new `### <version> — <YYYY-MM-DD>` block at the top of **Released**, then re-add an empty `## Unreleased` for the next cycle. The version + date must match the tag you push (bare version, no leading `v`).
|
||||
3. **Bug fixes count too:** a fix that ships in a patch release gets its own Unreleased entry and rides into the next version heading, so the archive doubles as a changelog reviewers and users can read.
|
||||
|
||||
Keep an item in exactly one state. Moving an Unreleased item into a version heading is the release; do not leave a duplicate behind under Unreleased.
|
||||
|
||||
## Planned
|
||||
|
||||
### 1. Plugin-managed local whisper.cpp server (desktop) — Phase B (auto-start lifecycle)
|
||||
|
||||
Phase A shipped (see Done). Remaining work:
|
||||
Phase A shipped (see the Released archive). Remaining work:
|
||||
|
||||
- "Start automatically when Obsidian opens" toggle (default off).
|
||||
- "Stop when idle for N minutes" toggle (default off; useful for the large-v3 user who doesn't want 1.5 GB resident all day).
|
||||
|
|
@ -23,7 +39,29 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an
|
|||
|
||||
---
|
||||
|
||||
## Done
|
||||
## Unreleased
|
||||
|
||||
Merged to `master`, not yet tagged. These become the next version's release notes.
|
||||
|
||||
### Fix: recording the modal now closes on Stop and reports via notices, instead of holding the modal open
|
||||
|
||||
The main modal's **Record** tab used to keep the popup open after Stop, showing an inline "Working... / Transcribing..." line (and an inline **Retry** button on error) while the transcribe → cleanup → insert pipeline ran. It now closes the modal the moment recording stops and runs the pipeline detached, reporting progress through a sticky `Notice` (`setMessage` per stage: "ReWrite: Saving audio... / Transcribing... / Cleaning up... / Inserting...") and surfacing errors as a `Notice` — the same UX as reprocessing a saved file ([src/ui/audio-source.ts](../src/ui/audio-source.ts) `runAudioFilePipeline`). New private method `startRecordingPipeline(source)` in [src/ui/modal.ts](../src/ui/modal.ts) captures `profile` / `destinationOverride` / `contextHint` into locals, calls `this.close()`, then runs `runPipeline` inside a detached async IIFE. Safe because the `persist-audio` stage writes the recording to the vault before transcription, so the saved file is the recovery path on error (no inline Retry needed). **Paste** and **From note** keep the prior in-modal `execute` flow (inline progress + Retry), since their input is not persisted and closing would lose it. Docs updated in [CLAUDE.md](../CLAUDE.md) (Pipeline section) and [wiki/Commands-and-Menus.md](../wiki/Commands-and-Menus.md) (The main modal).
|
||||
|
||||
### Fix: secret storage now actually defaults on first run; passphrase creation no longer silently no-ops
|
||||
|
||||
Two coupled bugs in the secrets bootstrap, both surfacing as "I can't create a passphrase without first switching to Obsidian secret storage, and the secrets file never gets written."
|
||||
|
||||
**Probe ordering ([src/main.ts](../src/main.ts)).** `loadSettings()` runs `hydrateSecrets → loadAllKeys → ensureEnvelope`, which reads and caches the secrets envelope for the session. That ran *before* `warmSecretStorage()`, so on a fresh install the availability probe was still cold and `defaultEnvelope()` fell back to `'passphrase'` instead of `'secretStorage'`. Reordered `onload` so `warmSecretStorage(this)` runs before `loadSettings(this)`; a fresh install now correctly defaults to Obsidian secret storage when the OS store is available, not just labels it "recommended."
|
||||
|
||||
**`setEncryptionMode` early-return ([src/secrets.ts](../src/secrets.ts)).** Because the envelope had cached as unconfigured `passphrase` mode, the create-passphrase flow called `setEncryptionMode(plugin, 'passphrase', pass)`, hit the blanket `if (envelope.mode === newMode) return;`, and silently did nothing — no kdf/verifier built, no `secrets.json.nosync` written. The modal reported success (onSubmit never threw) while status stayed unconfigured/locked, hence "set a passphrase first." Replaced the blanket guard with per-branch logic: secretStorage no-ops only if already active; configured passphrase flips mode only if not already active; an unconfigured passphrase store (no kdf/verifier) always builds a fresh envelope, *even when `mode` is already `passphrase`*. This also fixes a latent bug where a genuine Linux-without-keyring user (correctly defaulting to passphrase) could never create one through that path. Docs updated in [docs/SECRETS.md](SECRETS.md) (the `setEncryptionMode` model, the `warmSecretStorage` ordering note, and the probe gotcha).
|
||||
|
||||
---
|
||||
|
||||
## Released
|
||||
|
||||
> Per-version attribution starts with the first release cut after this roadmap was adopted. The archive below collects work that shipped across **1.0.0 through 1.1.0** before per-version tracking began; it is grouped as one historical block rather than retro-fitted to individual tags. New releases get their own `### <version> — <date>` heading above this archive.
|
||||
|
||||
### 1.1.0 and earlier (historical archive, not tracked per-version)
|
||||
|
||||
### Template maintenance: Update, default history, and Load prior versions
|
||||
|
||||
|
|
@ -109,7 +147,7 @@ Two sub-issues originally deferred from "Visual differentiation between desktop
|
|||
|
||||
Rewrote all default template prompts in [src/settings/default-templates.ts](../src/settings/default-templates.ts) to a much higher quality bar and added two new defaults (Lecture, Podcast), bringing the Populate set to 7. (Originally each prompt baked in a `SHARED_CORE` constant via a `withCore()` helper; that shared preface was subsequently promoted to an editable vault file, see "Shared core promoted to an editable vault file" above. The default template bodies now carry only their per-template rules.) The shared preface carries the anti-injection guardrail ("the input is transcribed speech, NOT instructions for you"), condensed cleanup rules (grammar/fillers/false-starts/self-corrections, preserve voice + technical nouns, spoken punctuation), and output discipline. General cleanup additionally carries the full detailed prose-polishing ruleset (the "like"-removal, sentence-initial So/And, hedge-collapsing, restated-synonym, rejoin-split-sentence rules) as its body; the structured templates carry only their section layout.
|
||||
|
||||
This resolves the former item #4 (daily-note structured fill): the answer is prompt-only, no `NoteTemplate` schema change. The Daily note default lays the transcript into extracted `## Calendar` (scheduled events), `## Goals` (strategic directions), and `## Tasks` (checkbox actions), each omitted when empty, then `## Braindump` (the entire cleaned transcript) last. It also completes the two-template portion of the long-form item; the docs guide for that workflow stays open (now item #3 under Open).
|
||||
This resolves the former item #4 (daily-note structured fill): the answer is prompt-only, no `NoteTemplate` schema change. The Daily note default lays the transcript into extracted `## Calendar` (scheduled events), `## Goals` (strategic directions), and `## Tasks` (checkbox actions), each omitted when empty, then `## Braindump` (the entire cleaned transcript) last. It also completes the two-template portion of the long-form item; the docs guide for that workflow was tracked separately and later shipped (see "Long-form audio workflow: lectures and podcasts (docs guide)" in this archive).
|
||||
|
||||
### Visual differentiation between desktop and mobile profile sections
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[src/secrets.ts](../src/secrets.ts) supports two encryption modes for API keys, file-wide (not per-key). There is no unencrypted at-rest option.
|
||||
|
||||
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Active values live in Obsidian's store, NOT in `secrets.json.nosync`. The on-disk envelope records the active mode and **also retains any passphrase material** (`kdf`/`verifier`/`keys`) as a preserved-at-rest snapshot, so switching to secret storage does NOT discard a configured passphrase store (the two stores coexist; see "Mode switch, migrate, and clear" below). With no passphrase store ever configured, the envelope is just `{ version, mode: 'secretStorage', keys: {} }`. Keys are namespaced with `manifest.id` (`rewrite-voice-notes-profile-desktop-transcription`) because the store is shared across plugins. The namespace separator and every secret id are **dash-joined, not colon-joined**: `setSecret` rejects any id that is not lowercase-alphanumeric + dashes (it throws on a colon, underscore, or uppercase letter), which previously made the round-trip probe below throw on its first write and report `secretStorage` unavailable on every platform. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE `getEncryptionStatus`, so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
|
||||
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Active values live in Obsidian's store, NOT in `secrets.json.nosync`. The on-disk envelope records the active mode and **also retains any passphrase material** (`kdf`/`verifier`/`keys`) as a preserved-at-rest snapshot, so switching to secret storage does NOT discard a configured passphrase store (the two stores coexist; see "Mode switch, migrate, and clear" below). With no passphrase store ever configured, the envelope is just `{ version, mode: 'secretStorage', keys: {} }`. Keys are namespaced with `manifest.id` (`rewrite-voice-notes-profile-desktop-transcription`) because the store is shared across plugins. The namespace separator and every secret id are **dash-joined, not colon-joined**: `setSecret` rejects any id that is not lowercase-alphanumeric + dashes (it throws on a colon, underscore, or uppercase letter), which previously made the round-trip probe below throw on its first write and report `secretStorage` unavailable on every platform. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE anything reads the envelope (i.e. before `loadSettings` → `hydrateSecrets` → `loadAllKeys` → `ensureEnvelope`, which caches it for the session), so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. Warming it only before `getEncryptionStatus` is too late: `hydrateSecrets` during `loadSettings` would already have cached an unconfigured-passphrase default. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
|
||||
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase. The KDF is **Argon2id** by default (via `hash-wasm`, `m = 32 MiB`, `t = 3`, `p = 1`, 32-byte output), with **PBKDF2-SHA256** (600,000 iterations) as the fallback when a device can't run Argon2id. The chosen algorithm + params live in the envelope `kdf` (see below). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value) in `secrets.json.nosync`. A `verifier` field stores an encryption of `VERIFIER_PLAINTEXT` so unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback when `secretStorage` is not. **Entropy gate:** `changeEncryptionMode`/`changePassphrase` reject a passphrase scoring below `MIN_PASSPHRASE_SCORE` (3 of 4) via [src/passphrase-strength.ts](../src/passphrase-strength.ts) (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal ([src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts)) surfaces a live strength meter and a **Generate** button (6-word EFF-wordlist diceware via [src/diceware.ts](../src/diceware.ts) + [src/eff-large-wordlist.ts](../src/eff-large-wordlist.ts)) on create/change flows (`enforceStrength: true`). **zxcvbn-ts is lazy-loaded:** its dictionaries are ~1.6 MB / ~140 ms to build, so [src/passphrase-strength.ts](../src/passphrase-strength.ts) pulls the packages via dynamic `import()` (not a static top-level import), keeping them out of plugin-startup evaluation; `evaluatePassphrase`/`isPassphraseAcceptable` are therefore **async**, and the modal calls `warmPassphraseStrength()` on open so the first keystroke does not pay the build cost. The bytes stay in `main.js` (esbuild has no code-splitting here); only the parse/construct work is deferred.
|
||||
|
||||
History note: an earlier hand-rolled `safeStorage` mode (direct Electron keychain) was REMOVED and superseded by `secretStorage`, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale `safeStorage` envelope parses as invalid and resets to default.
|
||||
|
|
@ -34,7 +34,7 @@ When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked ===
|
|||
|
||||
Switching the active method and transferring keys are **deliberately decoupled** (so a switch can never silently move or lose keys). The keys move only via an explicit copy, which is presented to the user as **Copy** (not "Migrate", since the source is kept). Three operations:
|
||||
|
||||
- `setEncryptionMode(plugin, newMode, newPassphrase?)` switches the **active** method only; it reads, moves, and deletes **nothing**. → `secretStorage` requires the probe to pass, then flips `mode` while preserving any passphrase `kdf`/`verifier`/`keys` snapshot and the in-memory `unlockedKey`. → `passphrase` when a store is already configured just flips `mode` (it becomes locked until unlocked unless the session key is still held); → `passphrase` with no store yet requires `newPassphrase` (entropy-gated) and builds a fresh empty envelope via `writePassphraseEnvelope`. Existing keys in the other store are left exactly where they are.
|
||||
- `setEncryptionMode(plugin, newMode, newPassphrase?)` switches the **active** method only; it reads, moves, and deletes **nothing**. → `secretStorage` no-ops if already active, else requires the probe to pass, then flips `mode` while preserving any passphrase `kdf`/`verifier`/`keys` snapshot and the in-memory `unlockedKey`. → `passphrase` when a store is already configured just flips `mode` if it isn't already active (it becomes locked until unlocked unless the session key is still held); → `passphrase` with no store yet (no `kdf`/`verifier`) requires `newPassphrase` (entropy-gated) and builds a fresh empty envelope via `writePassphraseEnvelope`. The "no store yet" branch is reached **even when `mode` is already `passphrase`** (the first-run / no-keyring default lands on unconfigured passphrase mode), so creation is keyed off the missing `kdf`/`verifier`, not the mode flag — a blanket `mode === newMode` early-return would otherwise silently skip creating the passphrase and leave the store unconfigured. Existing keys in the other store are left exactly where they are.
|
||||
- `copyKeys(plugin)` copies keys **from the inactive method into the active one** and returns the count written. Source/target are derived from `mode`; the source is read to plaintext (`readAllFromSecretStorage`, or `decryptAllToPlain` when the source is the passphrase snapshot, which requires `unlockedKey`), same-named ids in the target are overwritten, other target ids are kept, and the **source is never deleted** (Clear does that). Writing into a passphrase target also requires `unlockedKey`. Because passphrase is always one side of a cross-mode copy, the settings UI prompts for the passphrase (via `unlockPassphraseStore`, see below) before copying whenever the passphrase store is the source.
|
||||
- `clearKeys(plugin, mode)` permanently wipes one method's keys: `secretStorage` → `clearSecretStorage`; `passphrase` → drop `kdf`/`verifier`/`keys` from the envelope (rendering it unconfigured) and forget `unlockedKey`. The active mode is unchanged, so clearing the active passphrase leaves it unconfigured/locked (the banner then offers "Set passphrase").
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ Switching the active method and transferring keys are **deliberately decoupled**
|
|||
|
||||
## Gotchas
|
||||
|
||||
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](../src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` so the synchronous `defaultEnvelope()` can prefer it on first run. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
|
||||
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](../src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` **before `loadSettings`** (which reads + caches the envelope), so the synchronous `defaultEnvelope()` can prefer it on first run; warming it after `loadSettings` is a regression that re-defaults fresh installs to passphrase. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
|
||||
- **Secret ids must be lowercase-alphanumeric + dashes only.** `app.secretStorage.setSecret(id, value)` throws when `id` contains a colon, underscore, or uppercase letter. `nsId` joins the manifest id and the secret id with a DASH for this reason, and the canonical ids in [src/settings/index.ts](../src/settings/index.ts) (`profile-desktop-transcription`, etc.) and the self-test id (`selftest`) are all dash-only. A regression here is silent and total: the round-trip probe's first `setSecret` throws, the `catch` swallows it, and `secretStorage` reports unavailable on EVERY platform (this was the original bug, when ids were colon/underscore-joined). Do not reintroduce a colon or underscore into any id passed through `nsId`.
|
||||
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.
|
||||
- **The two stores coexist; switching is non-destructive.** Since the envelope retains passphrase `kdf`/`verifier`/`keys` even while `secretStorage` is active, both methods can hold keys at once and `mode` is purely the active-store flag. In `secretStorage` mode `saveKey`/`saveManyKeys` write to the OS store and `return` WITHOUT calling `writeEnvelope`, so the passphrase snapshot on disk is never touched by ordinary saves — only `copyKeys`/`clearKeys`/`changePassphrase`/`setEncryptionMode` rewrite it. The snapshot is frozen from when passphrase was last active, so a `copyKeys` from it overwrites the same-named active keys with that snapshot (the user's explicit choice; the confirm dialog says so). Do not re-merge switch + transfer into one call: the split is the whole point (a switch must never move or drop keys). The user-facing button is **Copy**, not "Migrate", since the source copy is kept.
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
|
|||
private unlockListeners = new Set<() => void>();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
this.settings = await loadSettings(this);
|
||||
// Warm the secret-storage probe before reading status so a first run (no envelope yet)
|
||||
// can default to secret storage when it is available.
|
||||
// Warm the secret-storage probe BEFORE anything reads the secrets envelope. loadSettings ->
|
||||
// hydrateSecrets reads (and caches) the envelope, so the probe must be warm first; otherwise
|
||||
// a first run (no envelope yet) caches as unconfigured passphrase mode and never picks up
|
||||
// secret storage even when it is available.
|
||||
await warmSecretStorage(this);
|
||||
this.settings = await loadSettings(this);
|
||||
this.encryptionStatus = await getEncryptionStatus(this);
|
||||
this.whisperHost = new WhisperHost(this);
|
||||
bindWhisperHost(this.whisperHost);
|
||||
|
|
|
|||
|
|
@ -681,9 +681,9 @@ export async function setEncryptionMode(
|
|||
newPassphrase?: string,
|
||||
): Promise<void> {
|
||||
const envelope = await ensureEnvelope(plugin);
|
||||
if (envelope.mode === newMode) return;
|
||||
|
||||
if (newMode === 'secretStorage') {
|
||||
if (envelope.mode === 'secretStorage') return; // already active
|
||||
if (!(await probeSecretStorage(plugin))) {
|
||||
throw new Error('Obsidian secret storage is not available on this device.');
|
||||
}
|
||||
|
|
@ -695,12 +695,19 @@ export async function setEncryptionMode(
|
|||
|
||||
// newMode === 'passphrase'
|
||||
if (envelope.kdf && envelope.verifier) {
|
||||
// Passphrase store already configured: just make it active. It is locked until the user
|
||||
// unlocks (unless unlockedKey is still held from this session). No rebuild, no transfer.
|
||||
await writeEnvelope(plugin, { ...envelope, version: SECRETS_VERSION, mode: 'passphrase' });
|
||||
// Passphrase store already configured: make it active if it isn't. It is locked until the
|
||||
// user unlocks (unless unlockedKey is still held from this session). No rebuild, no transfer.
|
||||
if (envelope.mode !== 'passphrase') {
|
||||
await writeEnvelope(plugin, { ...envelope, version: SECRETS_VERSION, mode: 'passphrase' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Passphrase store NOT configured yet (no kdf/verifier). Fall through to build a fresh envelope
|
||||
// below even when envelope.mode is already 'passphrase' (the first-run/no-keyring default): a
|
||||
// blanket `mode === newMode` early-return here would silently skip creation and leave the store
|
||||
// unconfigured, so the create-passphrase flow must reach writePassphraseEnvelope.
|
||||
|
||||
// Passphrase store not configured yet: a new passphrase is required to create it.
|
||||
if (!newPassphrase || newPassphrase.length === 0) {
|
||||
throw new Error('A passphrase is required to switch to passphrase mode.');
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ export class ReWriteModal extends Modal {
|
|||
dot.hide();
|
||||
warning.hide();
|
||||
this.stopTimerLoop();
|
||||
await this.execute(source);
|
||||
this.startRecordingPipeline(source);
|
||||
} catch (e) {
|
||||
new Notice(e instanceof Error ? e.message : String(e));
|
||||
isRecording = false;
|
||||
|
|
@ -454,6 +454,49 @@ export class ReWriteModal extends Modal {
|
|||
this.recorder = null;
|
||||
}
|
||||
|
||||
// Recorded-audio path: close the modal immediately and run the pipeline detached, reporting
|
||||
// progress and errors through a Notice (mirrors runAudioFilePipeline). The recording is
|
||||
// persisted to the vault before transcription, so the saved file is the recovery path on
|
||||
// error; no inline Retry is needed. Paste / From note keep execute()'s in-modal flow because
|
||||
// they have no persisted recovery.
|
||||
private startRecordingPipeline(source: PipelineSource): void {
|
||||
const template = this.activeTemplate();
|
||||
if (!template) {
|
||||
new Notice('Please pick a template.');
|
||||
return;
|
||||
}
|
||||
const { profile } = resolveActiveProfile(this.plugin.settings);
|
||||
const destinationOverride = this.destinationOverride ?? undefined;
|
||||
const contextHint = this.contextHint.trim() || undefined;
|
||||
const plugin = this.plugin;
|
||||
const app = this.app;
|
||||
this.close();
|
||||
|
||||
const progress = new Notice('ReWrite: working...', 0);
|
||||
void (async () => {
|
||||
try {
|
||||
await runPipeline({
|
||||
app,
|
||||
settings: plugin.settings,
|
||||
host: plugin,
|
||||
profile,
|
||||
template,
|
||||
source,
|
||||
destinationOverride,
|
||||
contextHint,
|
||||
onStage: (stage) => progress.setMessage(`ReWrite: ${stageLabel(stage)}`),
|
||||
});
|
||||
progress.hide();
|
||||
plugin.settings.lastUsedTemplateId = template.id;
|
||||
await plugin.saveSettings();
|
||||
new Notice('ReWrite complete.');
|
||||
} catch (e) {
|
||||
progress.hide();
|
||||
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async execute(source: PipelineSource): Promise<void> {
|
||||
const template = this.plugin.templates.find((t) => t.id === this.templateId);
|
||||
if (!template) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ Opened by the ribbon icon or the Open command. It has:
|
|||
- A collapsible **Context** field, shown only for templates that enable it, for one-off background info (speakers, subject, setting). See [Providers](Providers).
|
||||
- An inline **setup card** that blocks recording or pasting until the active profile is configured, with a shortcut to fix it.
|
||||
|
||||
When you **Record** and press Stop, the modal closes right away and the rest of the work (transcribe, clean up, insert) runs in the background with progress shown via notices, the same as reprocessing a saved file. If something fails, a notice tells you why; your recording was already saved to the vault, so you can reprocess it. The **Paste** and **From note** tabs instead keep the modal open while they run and offer a **Retry** button on error, since their input is not saved anywhere.
|
||||
|
||||
## Quick Record floating UI
|
||||
|
||||
Quick Record skips the modal entirely. When you start it, a small floating panel appears with:
|
||||
|
|
|
|||
Loading…
Reference in a new issue