diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d4b696..e95f4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,3 +52,98 @@ jobs: working-directory: rmgr-viewer - run: npm run format:check working-directory: rmgr-viewer + + # Keeps the Rust side of the desktop app from rotting between releases. + tauri-check: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + path: rmgr-viewer + - uses: actions/checkout@v4 + with: + repository: hopskipnfall/rmgr-ts + path: rmgr-ts + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + working-directory: rmgr-ts + - run: npm run build + working-directory: rmgr-ts + - name: Install Tauri system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: dtolnay/rust-toolchain@stable + - uses: swatinem/rust-cache@v2 + with: + workspaces: rmgr-viewer/src-tauri + - run: npm ci + working-directory: rmgr-viewer + # tauri::generate_context! needs frontendDist (../dist) to exist. + - run: npm run build + working-directory: rmgr-viewer + - run: cargo check --manifest-path src-tauri/Cargo.toml + working-directory: rmgr-viewer + + # Builds the installers on every push to master and every PR, uploading them + # as workflow artifacts (no release is created; that's release.yml on tags). + tauri-build: + needs: tauri-check + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + args: --target universal-apple-darwin + artifact: macos-universal + - platform: windows-latest + args: "" + artifact: windows + - platform: ubuntu-22.04 + args: "" + artifact: linux + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + with: + path: rmgr-viewer + - uses: actions/checkout@v4 + with: + repository: hopskipnfall/rmgr-ts + path: rmgr-ts + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + working-directory: rmgr-ts + - run: npm run build + working-directory: rmgr-ts + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + - uses: swatinem/rust-cache@v2 + with: + workspaces: rmgr-viewer/src-tauri + - name: Install Tauri system dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - run: npm ci + working-directory: rmgr-viewer + - uses: tauri-apps/tauri-action@v0 + with: + projectPath: rmgr-viewer + args: ${{ matrix.args }} + - uses: actions/upload-artifact@v4 + with: + name: rmgr-viewer-${{ matrix.artifact }} + path: | + rmgr-viewer/src-tauri/target/**/release/bundle/dmg/*.dmg + rmgr-viewer/src-tauri/target/release/bundle/msi/*.msi + rmgr-viewer/src-tauri/target/release/bundle/nsis/*.exe + rmgr-viewer/src-tauri/target/release/bundle/appimage/*.AppImage + rmgr-viewer/src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..368bcd7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release desktop app + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + args: --target universal-apple-darwin + - platform: windows-latest + args: "" + - platform: ubuntu-22.04 + args: "" + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + with: + path: rmgr-viewer + - uses: actions/checkout@v4 + with: + repository: hopskipnfall/rmgr-ts + path: rmgr-ts + + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + working-directory: rmgr-ts + - run: npm run build + working-directory: rmgr-ts + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Install Tauri system dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - run: npm ci + working-directory: rmgr-viewer + + # Unsigned: see README "Desktop app" for first-launch steps. + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: rmgr-viewer + tagName: ${{ github.ref_name }} + releaseName: RMGR Viewer ${{ github.ref_name }} + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} diff --git a/.gitignore b/.gitignore index d286a4b..a3a678d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist/ /.worktrees/ /.superpowers/ +src-tauri/target +src-tauri/gen diff --git a/README.md b/README.md index e8c4112..0cc0fcf 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,23 @@ npm run lint npm run format:check ``` +## Desktop app + +A Tauri desktop build (macOS, Windows, Linux) points at a library folder and keeps read access to it, so replays never need re-importing, and new recordings appear automatically. + +**Install.** Download the installer for your OS from the [Releases](https://github.com/hopskipnfall/rmgr-viewer/releases) page (`.dmg`, `.msi`/`-setup.exe`, `.AppImage`/`.deb`). Builds are unsigned, so the first launch needs one extra step: + +- **macOS (Gatekeeper):** right-click the app and choose Open, then Open again. Or run `xattr -dr com.apple.quarantine "/Applications/RMGR Viewer.app"`. +- **Windows (SmartScreen):** click More info, then Run anyway. + +**Library folder.** Defaults to `~/Documents/Replays` (created on first launch). Use Change… in the sidebar to pick another folder; Rescan re-reads it. The folder is scanned on launch and watched while the app runs. Replays are read-only; renaming one outside the app keeps its notes. + +**Project file.** Notes, session and matchup comments, your identity, YouTube links and hand-set perspectives are saved to `rmgr-viewer-project.json` in the library folder (written atomically, with the last 3 backups as `.bak.1` to `.bak.3`), so they survive app updates and reinstalls. The stats cache stays inside the app and is rebuilt from your replays. Each library folder has its own project file. + +**Bringing over web data.** The desktop app has its own storage. In the web app use project export, then project import in the desktop app to carry over notes, comments and settings. + +**Development.** Requires the Rust toolchain and [Tauri's platform prerequisites](https://tauri.app/start/prerequisites/). Run `npm run tauri:dev` (or `npm run tauri:build`). + ## Deployment Pushes to `master` build and deploy automatically to GitHub Pages (see diff --git a/docs/superpowers/plans/2026-10-05-tauri-desktop-app-implementation.md b/docs/superpowers/plans/2026-10-05-tauri-desktop-app-implementation.md new file mode 100644 index 0000000..d6f458c --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-tauri-desktop-app-implementation.md @@ -0,0 +1,237 @@ +# Tauri Desktop App Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Tauri 2 desktop build with a persistent, watched library folder, from the same codebase as the web app. + +**Architecture:** Replace `File` with a lazy `ReplayFileSource` as the unit of import (web and desktop both produce it). A desktop-only module (`src/desktop/`) resolves the library root, scans, and watches it through a mockable `fsAdapter`, feeding the existing `importIntoLibrary`. `main.ts` dynamic-imports it only when `isTauri()`. + +**Tech Stack:** TypeScript, Vite, vitest, Tauri 2 (`plugin-fs` w/ watch, `plugin-dialog`, `plugin-store`, `plugin-persisted-scope`), GitHub Actions `tauri-apps/tauri-action`. + +Spec: `docs/superpowers/specs/2026-10-05-tauri-desktop-app-design.md` (copy from the sibling worktree `tauri-app-bundling-design-b7f787` into this branch in Task 1). + +## Global Constraints + +- Web app at replay.12cb.dev keeps working with today's import flow; `npm run build`, `npm run dev`, GitHub Pages deploy unchanged. +- Existing test suite passes unchanged in behavior (only `fileRef` → `source` mechanical renames). +- Tauri 2; devUrl `http://localhost:5183`, frontendDist `../dist`; default library `$DOCUMENT/Replays`. +- fs capabilities `read-file`, `stat`, `read-dir`, `exists`, `mkdir`, `watch` scoped to `$DOCUMENT/Replays/**`; also `dialog:allow-open`, `store:default`. +- Watch: `watch(root, { recursive: true, delayMs: 1000 })`. Stability check = two `stat`s ~1s apart with same size; retry parse once. +- Plugin code must not enter the web bundle's initial chunk (dynamic import behind `isTauri()`). +- Unsigned release; no commits unless the user asks (workflow hook blocks weekday business hours). + +## File Structure + +- Create `src/data/replayFileSource.ts` — `ReplayFileSource`, `FileMeta`-compatible meta, `fromBrowserFile`. +- Create `src/desktop/fsAdapter.ts` — only file importing `@tauri-apps/plugin-*`; mock seam. +- Create `src/desktop/libraryFolder.ts` — `fromLibraryPath`, root resolution, scan, watcher handling. +- Create `src/desktop/desktopUi.ts` — Rescan button, folder row, banner, first-launch hint, desktop missing-file prompt. +- Create `src-tauri/*`, `.github/workflows/release.yml`. +- Modify `gameSummary.ts`, `importer.ts`, `importPlanner.ts`, `libraryPersistence.ts`, `replaySource.ts`, `missingFilePrompt.ts`, `main.ts`, `searchView.ts`, `i18n.ts`, `index.html`, tests using `fileRef`, `package.json`, `ci.yml`, `README.md`, `.gitignore`. + +--- + +### Task 1: `ReplayFileSource` refactor (web behavior unchanged) + +**Files:** create `src/data/replayFileSource.ts` (+ `.test.ts`); modify `gameSummary.ts` (`fileRef`→`source`, `summarizeReplay` 2nd param), `importer.ts`, `importPlanner.ts` (`FileMeta` re-exported from replayFileSource), `libraryPersistence.ts`, `replaySource.ts`, `main.ts`, `searchView.ts:422`, `missingFilePrompt.ts`, all tests/scripts referencing `fileRef`/`File`. + +**Interfaces — Produces:** + +```ts +// src/data/replayFileSource.ts +export interface FileMeta { + sourcePath: string; + size: number; + lastModified: number; +} +export interface ReplayFileSource { + meta: FileMeta; + name: string; + read(): Promise; +} +export function fromBrowserFile(file: File): ReplayFileSource; +// replaySource.ts +export async function loadReplayFromSource( + source: ReplayFileSource, +): Promise; +// importer.ts +export function importReplayFiles( + sources: readonly ReplayFileSource[], + onProgress?, +): Promise; +// libraryPersistence.ts +export function importIntoLibrary( + store, + sources: readonly ReplayFileSource[], + onProgress?, + onFastPathMatched?, +): Promise; +``` + +`GameSummary.source: ReplayFileSource | null`. `importer.fileMeta` is removed (meta lives on the source). + +- [ ] **Step 1: Failing test** `src/data/replayFileSource.test.ts`: `fromBrowserFile(new File([bytes], "a.rmgr", {lastModified: 5}))` yields `meta {sourcePath:"a.rmgr", size, lastModified:5}`, `name "a.rmgr"`, `read()` equals bytes; with `webkitRelativePath` defined via `Object.defineProperty` → `sourcePath` is that path. Use `globalThis.File ?? NodeFile` as in `importer.test.ts`. +- [ ] **Step 2:** `npx vitest run src/data/replayFileSource.test.ts` → FAIL (module missing). +- [ ] **Step 3: Implement** + +```ts +export function fromBrowserFile(file: File): ReplayFileSource { + return { + meta: { + sourcePath: file.webkitRelativePath || file.name, + size: file.size, + lastModified: file.lastModified, + }, + name: file.name, + read: async () => new Uint8Array(await file.arrayBuffer()), + }; +} +``` + +- [ ] **Step 4:** Mechanical refactor, then `npm run typecheck` until clean: + - `importer.ts`: take `ReplayFileSource[]`; filter on `s.name`; `bytes = await s.read()`; `summarizeReplay(loaded, s)`; `meta: s.meta`. Delete `fileMeta` and its test block (replace with the `fromBrowserFile` test from Step 1). + - `importPlanner.ts`: `import type { FileMeta }` from replayFileSource and `export type { FileMeta }` so existing imports keep working. + - `libraryPersistence.ts`: `summaryFromEntry(entry, source: ReplayFileSource | null)` sets `summary.source`; `importIntoLibrary` uses `source.meta` instead of `fileMeta(file)`; types `File`→`ReplayFileSource`. + - `replaySource.ts`: rename `loadReplayFromFile(file)` → `loadReplayFromSource(source)` using `source.read()` and `source.name`. + - `main.ts`: `handleImport(files: ReplayFileSource[])`; `filePicker`/`folderPicker` change handlers call `handleImport([...files].map(fromBrowserFile))`; `loadReplayForSummary` uses `summary.source` / `loadReplayFromSource`. + - `missingFilePrompt.ts` and `searchView.ts`: `fileRef`→`source`. + - Tests: `fileRef: null` → `source: null`; `importer.test.ts`/`libraryPersistence.test.ts` wrap `File`s with `fromBrowserFile` (change `demoFiles()` to return `ReplayFileSource[]`, assertions `.source`). +- [ ] **Step 5:** `npm run typecheck && npm run lint && npm test` → all PASS. + +--- + +### Task 2: Lazy attach + rename behaviour tests (planner) + +**Files:** modify `src/data/libraryPersistence.test.ts`. + +- [ ] **Step 1:** Add tests using sources whose `read` is a `vi.fn` wrapper over real demo bytes: + 1. After an initial import, re-import with same meta → `read` called 0 times, summaries have `source` set (existing "without parsing" test, extended to count `read` calls). + 2. Rename: same bytes, different `sourcePath` and same size/mtime → parsed once; `store.getAll()` length unchanged (no duplicate), entry `sourcePath` updated, summary id unchanged (existing `decideEntry` "touch" path). +- [ ] **Step 2:** Run `npx vitest run src/data/libraryPersistence.test.ts`. Expected PASS (behavior already exists; if the rename test fails, fix the planner rather than the test). + +--- + +### Task 3: Desktop fs adapter, library folder, scan, watcher + +**Files:** create `src/desktop/fsAdapter.ts`, `src/desktop/libraryFolder.ts`, `src/desktop/libraryFolder.test.ts`; `npm i @tauri-apps/api @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-store` and `npm i -D @tauri-apps/cli`. + +**Interfaces — Produces:** + +```ts +// fsAdapter.ts +export interface DirEntryInfo { + name: string; + isDirectory: boolean; +} +export interface FsAdapter { + documentDir(): Promise; + join(...parts: string[]): Promise; + exists(path: string): Promise; + mkdir(path: string): Promise; + readDir(path: string): Promise; + stat(path: string): Promise<{ size: number; mtime: number }>; + readFile(path: string): Promise; + watch( + root: string, + cb: (e: { + kind: "create" | "modify" | "remove" | "other"; + paths: string[]; + }) => void, + ): Promise<() => void>; + pickDirectory(): Promise; + getSavedRoot(): Promise; + saveRoot(path: string): Promise; +} +export const tauriFs: FsAdapter; // real implementation +// libraryFolder.ts +export function fromLibraryPath( + fs: FsAdapter, + root: string, + relPath: string, + stat: { size: number; mtime: number }, +): ReplayFileSource; +export type RootState = + { kind: "ok"; root: string } | { kind: "missing"; root: string }; +export function resolveRoot(fs: FsAdapter): Promise; +export function scanLibrary( + fs: FsAdapter, + root: string, +): Promise; +export interface LibraryWatcherHandlers { + onSource(source: ReplayFileSource): Promise; // import one stable file; throws on parse failure + onRemove(sourcePath: string): void; +} +export function startWatcher( + fs: FsAdapter, + root: string, + h: LibraryWatcherHandlers, + opts?: { stabilityMs?: number }, +): Promise<() => void>; +``` + +`sourcePath` for desktop sources is `relPath` with `/` separators, relative to root. `mtime` is ms epoch. + +- [ ] **Step 1: Failing tests** (`libraryFolder.test.ts`, with an in-memory fake `FsAdapter` and `vi.useFakeTimers`): + - `fromLibraryPath`: meta = `{sourcePath: "sub/a.rmgr", size, lastModified}`, `name "a.rmgr"`, `read()` returns bytes from `fs.readFile(join(root, rel))`. + - `resolveRoot`: no saved root & default missing → `mkdir` called, `{kind:"ok"}`; saved root that doesn't exist → `{kind:"missing"}`, no `mkdir`; saved existing → ok. + - `scanLibrary`: recurses, keeps `*.RMGR`/`*.rmgr` case-insensitively, ignores other files, no `readFile` calls. + - Watcher: create event → `onSource` is NOT called until two stats ~stabilityMs apart report same size (size changes between stats → waits again); 5 modify events for one path within the window → one `onSource`; `remove` → `onRemove(relPath)`; `onSource` rejecting once → retried once after another stability wait; second rejection → error surfaced via `onError`-style rejection logged (add optional `onError(path, err)` handler to `LibraryWatcherHandlers`); non-`.rmgr` events ignored. +- [ ] **Step 2:** Run → FAIL. +- [ ] **Step 3: Implement.** `tauriFs` uses `@tauri-apps/api/path` (`documentDir`, `join`), `plugin-fs` (`exists`, `mkdir({recursive:true})`, `readDir`, `stat` → `mtime?.getTime()`, `readFile`, `watch(root, cb, {recursive:true, delayMs:1000})` mapping `event.type` (`{create}`/`{modify}`/`{remove}` keys) to `kind`), `plugin-dialog` `open({directory:true})`, `plugin-store` `Store.load("settings.json")` key `libraryRoot`. Watcher keeps `Map` to coalesce; per-path in-flight set prevents concurrent imports. +- [ ] **Step 4:** `npx vitest run src/desktop` → PASS; `npm run typecheck`. + +--- + +### Task 4: Desktop wiring in `main.ts` + UI + +**Files:** create `src/desktop/desktopUi.ts`; modify `main.ts`, `index.html`, `i18n.ts` (en + ja), `missingFilePrompt.ts`. + +**Interfaces — Consumes:** Task 3 exports; `importIntoLibrary`, `attachImportedSummaries`, `libraryController`. + +- [ ] **Step 1:** In `main.ts` add `const isDesktop = isTauri()` (`@tauri-apps/api/core`). After the persisted library loads in `init()`, if desktop: `const lf = await import("./desktop/libraryFolder.js"); const { tauriFs } = await import("./desktop/fsAdapter.js")` and `startDesktopLibrary()`: + 1. `resolveRoot`; if `missing`, show banner "Library folder not found" + **Change…** and stop. + 2. `scanLibrary` → `handleImport(sources)` (reuses fast path, progress, banners). + 3. `startWatcher` with `onSource: s => handleImport([s])` (needs `handleImport` to throw on parse error for a single file: have it return `{errorCount}` and have the handler throw when `errorCount>0`), `onRemove: path => set matching summary `source = null`(match on`summary.source?.meta.sourcePath === path`) then `libraryController.render()`. + 4. Keep `stopWatcher` handle in module state. +- [ ] **Step 2:** `changeLibraryFolder()`: `pickDirectory()` → null = no-op; `saveRoot`; stop watcher; null every summary's `source`; rescan; new watcher. `rescan()`: re-runs scan (no folder change). +- [ ] **Step 3:** UI (`desktopUi.ts` + `index.html` ids `libraryFolderRow`, `rescanBtn`, `libFolderBanner`, `desktopHint`, all `hidden` by default): on desktop hide `#importContainer`, show `#rescanBtn` and the row "Library folder: `` [Change…]"; first-launch hint (dismiss stored in `localStorage` key `rmgr.desktopHintDismissed`) text: notes, comments and settings from the web version can be brought over with project export/import. Add i18n keys `rescan`, `libraryFolder`, `change`, `libraryFolderNotFound`, `desktopHint`, `notInLibraryTitle`, `notInLibraryBody`. +- [ ] **Step 4:** Missing-file prompt: add `variant: "web" | "desktop"` option. Desktop variant (title/body "Not found in your library folder", buttons **Rescan** and **Cancel**) calls `options.rescan()`; reuse the `onImportFinished` listener to resolve once `summary.source` is set. In `loadReplayForSummary`: `if (summary.source) { try { return await loadReplayFromSource(summary.source) } catch { if (!isDesktop) throw; /* fall through to prompt */ } }`. +- [ ] **Step 5:** `npm run typecheck && npm run lint && npm test && npm run build`; check `dist/assets` initial chunk does not contain `plugin-fs` (grep `dist/index*.js`). + +--- + +### Task 5: `src-tauri` scaffold + +**Files:** create `src-tauri/Cargo.toml`, `build.rs`, `tauri.conf.json`, `capabilities/default.json`, `src/main.rs`, `icons/` (via `npx tauri icon public/` or a placeholder), `.gitignore` entries `src-tauri/target`, `src-tauri/gen`; modify `package.json` (`"tauri:dev": "tauri dev"`, `"tauri:build": "tauri build"`). + +- [ ] **Step 1:** `tauri.conf.json`: `productName "RMGR Viewer"`, `identifier "dev.12cb.rmgr-viewer"`, `version "../package.json"`, `build: { devUrl:"http://localhost:5183", frontendDist:"../dist", beforeDevCommand:"npm run dev", beforeBuildCommand:"npm run build" }`, `app.security.csp` including `frame-src https://www.youtube.com https://www.youtube-nocookie.com`, `bundle.targets "all"`, `bundle.resources` none (demo replays already inside `dist`). +- [ ] **Step 2:** `Cargo.toml` deps: `tauri = "2"`, `tauri-plugin-fs = { version="2", features=["watch"] }`, `tauri-plugin-dialog = "2"`, `tauri-plugin-store = "2"`, `tauri-plugin-persisted-scope = "2"`. `main.rs` registers the four plugins only. +- [ ] **Step 3:** `capabilities/default.json`: permissions `core:default`, `dialog:allow-open`, `store:default`, `fs:allow-read-file`, `fs:allow-stat`, `fs:allow-read-dir`, `fs:allow-exists`, `fs:allow-mkdir`, `fs:allow-watch`, `fs:allow-unwatch`, with fs entries scoped `{"allow":[{"path":"$DOCUMENT/Replays/**"},{"path":"$DOCUMENT/Replays"}]}`. +- [ ] **Step 4:** `cargo check --manifest-path src-tauri/Cargo.toml` → success. (Full `npm run tauri:dev` GUI run is the user's manual smoke test.) + +--- + +### Task 6: CI, release workflow, README + +**Files:** modify `.github/workflows/ci.yml`, `README.md`; create `.github/workflows/release.yml`. + +- [ ] **Step 1:** `ci.yml`: new job `tauri-check` on `ubuntu-22.04`: checkout both repos, build `rmgr-ts`, install apt deps (`libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf`), `dtolnay/rust-toolchain@stable`, `npm ci`, `npm run build` (creates `dist` for `generate_context!`), `cargo check --manifest-path src-tauri/Cargo.toml`. +- [ ] **Step 2:** `release.yml`: `on: push: tags: ["v*"]`; matrix `macos-latest` (args `--target universal-apple-darwin`, rust targets `aarch64-apple-darwin,x86_64-apple-darwin`), `windows-latest`, `ubuntu-22.04`; steps per spec (both checkouts, build rmgr-ts, node 22, rust stable, apt on ubuntu, `npm ci`), then `tauri-apps/tauri-action@v0` with `projectPath: rmgr-viewer`, `releaseDraft: true`, `tagName: ${{ github.ref_name }}`, `GITHUB_TOKEN`. Permissions `contents: write`. +- [ ] **Step 3:** README "Desktop app" section: download from Releases, macOS Gatekeeper (right-click → Open / `xattr -dr com.apple.quarantine`), Windows SmartScreen (More info → Run anyway), default folder `~/Documents/Replays` and changing it, importing web data via project export/import, `npm run tauri:dev` for development. +- [ ] **Step 4:** `npm run format:check` (run `npx prettier --write` on touched files if needed). + +--- + +### Task 7: Manual smoke checklist (user-run, per OS) + +1. First launch creates `~/Documents/Replays`; library loads. +2. Record a game in RMG-K into the folder: it appears automatically. +3. Rename a replay in Finder/Explorer: same entry, notes intact, no duplicate. +4. Delete a replay, then open it: desktop prompt; Rescan works. +5. Change the folder: re-scans, new folder watched. +6. Quit/relaunch: custom folder still readable, no prompt. +7. Open a match with a YouTube link: playback works or is labeled unavailable (if not, label video sync unavailable on desktop). + +## Self-Review + +Spec coverage: runtime switch/Task 4; `ReplayFileSource`/Task 1; lazy attach & rename/Task 2; scan/watch/stability/coalesce/remove/root resolution/Task 3; open-game prompt, folder change, Rescan, hint/Task 4; Tauri scaffold/capabilities/scripts/Task 5; release + ci + README/Task 6; YouTube CSP/Task 5; smoke test/Task 7. Chrome backend, folder management, signing: out of scope per spec. diff --git a/docs/superpowers/specs/2026-10-05-tauri-desktop-app-design.md b/docs/superpowers/specs/2026-10-05-tauri-desktop-app-design.md new file mode 100644 index 0000000..4fde3ef --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-tauri-desktop-app-design.md @@ -0,0 +1,190 @@ +# Tauri Desktop App: Persistent Library Folder + +Date: 2026-10-05 +Status: Draft, awaiting review + +## Problem + +Game summaries persist in IndexedDB (`src/data/libraryPersistence.ts`), but replay bytes do not. +`GameSummary.fileRef` is a session-only browser `File`, so after every reload, opening a game hits +`promptForMissingFile` (`src/library/missingFilePrompt.ts`) and the user must re-import the replays +folder. Firefox (the primary user's browser) has no persistent directory-handle API, so the fix is a +desktop build with real filesystem access. + +## Goals + +- Ship a Tauri 2 desktop app (macOS, Windows, Linux) from the same codebase as the web app. +- The desktop app points at a library folder (default `~/Documents/Replays`, user-changeable) and + has permanent read access to it: no re-import, no missing-file prompt for files that exist. +- New recordings appear in the library automatically (scan on launch + live file watching). +- The web app at replay.12cb.dev keeps working with today's import flow. + +## Non-goals (separate sub-projects) + +- **Chrome File System Access backend.** It will be a third `ReplayFileSource` producer (stored + directory handle). The interface below is designed so it slots in, but it is not built here. +- **Folder management (rename, move, organize).** This spec ships read-only access. Entries are + matched by content hash so a later spec can add write operations without re-keying the library. +- Code signing, notarization, auto-update. +- Copying files from outside the library folder into it. + +## 1. Architecture + +### Repo layout + +``` +src-tauri/ + Cargo.toml + tauri.conf.json # devUrl http://localhost:5183, frontendDist ../dist, version from ../package.json + capabilities/default.json + src/main.rs # registers plugins only +src/desktop/ + libraryFolder.ts # root resolution, scan, watch + fsAdapter.ts # thin wrapper over plugin-fs/dialog/store (the mock seam for tests) +src/data/replayFileSource.ts +``` + +Tauri plugins: `fs` (with the `watch` feature), `dialog`, `store`, `persisted-scope`. +Capabilities: fs `read-file`, `stat`, `read-dir`, `exists`, `mkdir`, `watch` scoped to +`$DOCUMENT/Replays/**`. Folders chosen through the dialog are added to the scope at runtime and kept +across restarts by `persisted-scope`. Also `dialog:allow-open` and `store:default`. + +New npm scripts: `tauri:dev` (`tauri dev`) and `tauri:build` (`tauri build`). `npm run build`, `npm run dev`, +and the GitHub Pages deploy are unchanged. + +### Runtime switch + +`main.ts` checks `isTauri()` (`@tauri-apps/api/core`) at startup. On desktop it dynamic-imports +`src/desktop/libraryFolder.ts`, so plugin code never enters the web bundle's initial chunk. + +### `ReplayFileSource`: replaces `File` as the unit of import + +```ts +// src/data/replayFileSource.ts +export interface ReplayFileSource { + /** sourcePath is relative to the library root on desktop, webkitRelativePath || name on web. */ + meta: FileMeta; + name: string; + /** Lazy. Called only when parsing a new/changed file or opening a game. */ + read(): Promise; +} + +export function fromBrowserFile(file: File): ReplayFileSource; +// src/desktop/libraryFolder.ts +export function fromLibraryPath( + root: string, + relPath: string, + stat: { size: number; mtime: number }, +): ReplayFileSource; +``` + +Refactor: + +- `GameSummary.fileRef: File | null` becomes `source: ReplayFileSource | null`. +- `fileMeta(file)`, the importer, and `importPlanner` take `ReplayFileSource[]` instead of `FileList`/`File`. +- `loadReplayFromFile(file)` becomes `loadReplayFromSource(source)` in `src/replaySource.ts`. +- Web pickers (`filePicker`, `folderPicker` in `main.ts`) map `FileList` through `fromBrowserFile`. + Web behavior is unchanged. + +Because `read()` is lazy, the planner can attach an unchanged file to its cached summary from `stat` +data alone (path + size + mtime), with no bytes read. + +## 2. Data flow + +### Desktop launch + +1. Load cached summaries from IndexedDB, as today. The library renders immediately. +2. Resolve the root: the saved `plugin-store` value, else `$DOCUMENT/Replays`. If the default does not + exist, create it. If a saved non-default root no longer exists, show the library with no sources + and a banner: "Library folder not found" plus a **Change…** button. +3. Scan: recursive `readDir`, filter `*.rmgr` (case-insensitive), `stat` each file, build sources, and pass + them to the existing background import: + - path/size/mtime match: attach the source to the cached summary, zero reads. + - new or changed: parse and summarize (existing idle-yielding import). + - path unknown but content hash matches an existing entry (renamed/moved outside the app): + update that entry's `sourcePath`. No duplicate entry. +4. Cached summaries not matched by the scan keep `source = null`. +5. Start `watch(root, { recursive: true, delayMs: 1000 })`. + +### Watcher events + +- **Create/modify of `*.rmgr`:** stability check (two `stat`s ~1s apart, same size), then import that + single source. If parsing fails, retry once after another stability wait. A second failure goes + to the normal import error list. +- **Remove:** set the matching summary's `source` to null. The summary, notes, and comments are kept. + If the file reappears, it re-attaches. +- Events for the same path are coalesced, so a burst of modify events triggers one import. + +### Opening a game + +`loadReplayForSummary` calls `summary.source.read()`. If `source` is null or `read()` throws, the +desktop app shows a desktop variant of the missing-file prompt ("Not found in your library folder") +with **Rescan** and **Cancel**. The web prompt is unchanged. + +### Changing the library folder + +A settings row "Library folder: `` [Change…]": + +1. Opens `dialog.open({ directory: true })`. Cancel means no change. +2. Saves the path to `plugin-store`. +3. Stops the old watcher, sets every summary's `source` to null, rescans, and starts a new watcher. + +Summaries from the old folder remain without sources. + +### UI differences on desktop + +- "Import file" / "Import folder" are replaced by **Rescan** (re-runs the launch scan). +- First launch only: a dismissible one-line hint that notes, comments, and settings from the web + version can be brought over with project export/import (desktop storage is a separate origin). + +Unchanged: bundled demo replays (URL-loaded from the app bundle), notes, comments, project +export/import, hash routing. + +## 3. Release pipeline + +### `.github/workflows/release.yml` (new) + +- Trigger: push of a `v*` tag. +- Matrix: `macos-latest` (`--target universal-apple-darwin`), `windows-latest`, `ubuntu-22.04`. +- Steps: check out `rmgr-viewer` + sibling `rmgr-ts` (same as `ci.yml`), build `rmgr-ts`, install Node + and the stable Rust toolchain. On Ubuntu, install `libwebkit2gtk-4.1-dev libappindicator3-dev +librsvg2-dev patchelf`. Then run `npm ci` and `tauri-apps/tauri-action`. +- Output: a draft GitHub Release with `.dmg`, `.msi`, `-setup.exe`, `.AppImage`, `.deb`. +- Unsigned. The README gets a "Desktop app" section covering install and first-launch Gatekeeper/SmartScreen steps. + +### `ci.yml` + +Add one job: `cargo check --manifest-path src-tauri/Cargo.toml` on `ubuntu-22.04` (same apt +dependencies), so the Rust side cannot rot between releases. + +## 4. Risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| YouTube iframe API refuses to play from `tauri://localhost` / `http://tauri.localhost` | Set the `origin` player param and a CSP `frame-src https://www.youtube.com`. If it still fails, label the video-sync feature unavailable on desktop. | +| Desktop IndexedDB/localStorage start empty (separate origin) | Project export/import as the migration path, plus the first-launch hint. | +| Linux inotify watch limits on large trees | Acceptable for expected folder sizes. Rescan is the fallback. | +| WebView differences (WebKitGTK, WebView2, WKWebView) | Covered by the per-OS smoke test. `requestIdleCallback` already has a fallback. | + +## 5. Testing + +Unit tests (vitest; plugin calls mocked at `src/desktop/fsAdapter.ts`): + +- `fromBrowserFile` and `fromLibraryPath` produce the correct `meta`, and `read()` returns the bytes. +- Planner with lazy sources: unchanged files attach with `read()` called zero times. +- Hash-matched rename updates `sourcePath` and creates no duplicate. +- Watcher handling: the stability check delays the import until size settles. Remove nulls `source`, and + re-add re-attaches. Bursts coalesce into one import. +- Root resolution: default created when missing; a saved-but-missing root shows the banner state. + +The existing test suite passes unchanged on web, which confirms the refactor did not change behavior. + +Manual smoke test on each OS (checklist carried into the plan): + +1. First launch creates `~/Documents/Replays` and the library loads. +2. Record a game in RMG-K into the folder: it appears without user action. +3. Rename a replay in Finder/Explorer: same entry, notes intact, no duplicate. +4. Delete a replay, then open it: desktop missing-file prompt, and Rescan works. +5. Change the folder: library re-scans and the new folder is watched. +6. Quit and relaunch: the custom folder is still readable, with no prompt. +7. Open a match with a YouTube link: playback works or is labeled unavailable. diff --git a/eslint.config.js b/eslint.config.js index 7c00227..ba9e45d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,14 @@ import eslintConfigPrettier from "eslint-config-prettier"; export default tseslint.config( { - ignores: ["dist/**", "scratch/**", "scratch_*.mjs", "logs.txt"], + ignores: [ + "dist/**", + "src-tauri/target/**", + "src-tauri/gen/**", + "scratch/**", + "scratch_*.mjs", + "logs.txt", + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/index.html b/index.html index e5a1c2a..6204f8d 100644 --- a/index.html +++ b/index.html @@ -7686,6 +7686,27 @@

+ + + + +