Skip to content

Vite/Bun split + native macOS app (Tauri), CI/CD, and public download - #29

Merged
amide-init merged 38 commits into
mainfrom
move-to-mac
Sep 23, 2026
Merged

amide-init merged 38 commits into
mainfrom
move-to-mac

Conversation

@amide-init

Copy link
Copy Markdown
Owner

Summary

Two related pieces of work, staged as 38 atomic commits:

  • Migrate off Next.js to a Vite React SPA (client/) talking to a
    separate Bun + Hono backend (server/) — pnpm run dev runs both
    together from the repo root. No server-side rendering, one
    deployable backend unit; this split is what makes everything below
    possible.
  • Package a native macOS app with Tauri v2 (ad-hoc signed, Apple
    Silicon only — see claude.md section 35 for the full rationale and
    constraints), spawning the system-installed bun to run a staged
    copy of the backend rather than a compiled sidecar (works around an
    upstream bun build --compile + @libsql bug).
  • CI/CD: ci.yml fixed to actually cover both packages (it was
    stale, client/-only, from before the split); new
    build-macos-app.yml builds and releases the app on v*.*.* tags
    (and via manual workflow_dispatch for testing).
  • First-run API key setup screen: the app no longer bakes a
    developer's real OPENAI_API_KEY into the bundle — each person who
    runs it (built locally or downloaded) enters their own key on first
    launch, stored in DATA_DIR/settings.json. Local dev still falls
    back to server/.env unchanged.
  • Docs: a new download page, nav/homepage links to it, and a pass
    fixing several docs pages (getting-started, status, security,
    architecture) that still described the old Next.js setup.

Test plan

  • pnpm run lint — clean
  • pnpm run test — 249 tests pass (100 client + 149 server)
  • pnpm run build — client + server both build
  • Both workflow YAML files parse correctly
  • Real local pnpm exec tauri build run: confirms portable
    beforeBuildCommand, no baked-in .env/API key, both .app
    and .dmg produced, fresh app redirects to /setup
  • Settings flow tested end-to-end in the browser: empty key →
    redirected to /setup → paste a real key → live-validated
    against OpenAI → saved → redirected to dashboard → real
    transcription works
  • Docs site served locally; all new/changed pages return 200
  • No secrets, .env, media/DB files, or node_modules/data/ in
    the diff (checked by eye, not just .gitignore)
  • Not yet run: a real GitHub Actions build via workflow_dispatch
    or a tag push (branch was only just pushed) — recommend running
    build-macos-app.yml manually once, before ever tagging a
    release, to confirm the macOS runner build succeeds end-to-end

🤖 Generated with Claude Code

amide-init and others added 30 commits September 23, 2026 21:01
Adds a root pnpm-workspace.yaml so client (Vite/Next, unchanged for now)
and the new server package share one lockfile and one dependency install,
matching the client+server layout this migration is building toward.
Moves the onlyBuiltDependencies build-script allowlist from client's
package.json to the workspace root, where pnpm now expects it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes the pnpm workspace scaffolding from the previous commit: the
root package.json holds cross-package dev/build/test orchestration
scripts (concurrently runs client + server dev servers together), and
pnpm-lock.yaml is now the single workspace-wide lockfile replacing
client's standalone one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Starts the server package that will replace client's Next.js API routes
(see claude.md's Tauri-readiness goal). Bun is the runtime instead of
Node specifically to avoid the native-binding packaging pain
better-sqlite3 would cause when this later becomes a Tauri sidecar.

Prisma schema/migrations are copied over unchanged -- the schema has no
adapter-specific config, so moving it doesn't touch the data model, only
where it lives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verified empirically before writing any other server code (this was the
single riskiest unknown in the migration plan): copied the real dev
SQLite database and confirmed @prisma/adapter-libsql reads it correctly
under Bun on macOS, including the derived-fields query shape the
dashboard needs. The exported class is PrismaLibSql (not PrismaLibSQL as
Prisma's own docs examples suggest) -- confirmed against the installed
7.10.0 package's actual type declarations.

schema.prisma has no adapter-specific config, so every existing
.findMany()/.include()/etc. call site is unaffected -- only the driver
construction in client.ts changes.

Also copies the plain type files (types/*.ts) that both client and
server need -- small enough to duplicate by hand rather than add a third
shared-types workspace package for this round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lib/ai (OpenAI Whisper transcription, filler-word detection) and
lib/captions (SRT/VTT/ASS generation, burned-in caption styling) are
already server-only, framework-agnostic Node code -- zero Next.js
imports -- so this is a straight copy, not a rewrite.

captions/style.ts and captions/generate.ts are dual-use (also imported
by client components for the live caption preview), so they're
duplicated here rather than moved -- captions/format.ts is server-only
and lives only here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lib/ffmpeg (filter/property mapping, ffmpeg argv builder, video
probing, the render-job orchestrator, and the execFile wrapper) moves
unchanged -- it's already framework-agnostic Node code, and run.ts's
execFile-with-argv-array pattern (never a shell string, per claude.md
section 18) is preserved verbatim.

render-job.ts's fire-and-forget error handling matters more here than
it did under Next: its whole body is one try/catch that always resolves
to a status update, never rethrows, so an unhandled rejection can't
take down Bun's long-lived server process the way it could have taken
down a per-request Next.js function differently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lib/video (filter presets, logo positioning, CSS/SVG property mapping)
and lib/timeline/{cuts,sentences} are dual-use -- also imported by
client components for live preview -- so they're duplicated here rather
than moved. video/logo.ts's server copy drops logoPositionToCss (the
CSS-preview-only function) and its React import, so this package has no
React dependency; the ffmpeg export path it shares with the client
(logoPositionToOverlayXY) is unaffected.

lib/validation (Zod request schemas) is server-only -- only ever
imported from API route handlers -- and moves unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
writeAsset/statAsset/ensureProjectDirs/deleteProjectDir/path-escape
validation move unchanged. Drops readAssetStream and streamToWebReadable
-- a ~60-line hand-rolled Node-stream-to-Web-stream bridge written to
patch a NextResponse-specific abort race -- since Bun's route handlers
serve files directly via Bun.file(path) as a Response body, which
handles Range requests, Content-Length, and client-abort natively
(verified against Bun v1.4.2).

Adds writeAssetStream, used by the upload route rewrite: Bun.write(path,
request.body) streams a request body straight to disk without buffering
the whole file in memory first, unlike the old formData()/arrayBuffer()
approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mechanical port from the old Next route handlers (same Zod schemas,
same Prisma calls) with one real behavior addition: GET /projects now
computes hasVideo/hasTranscript/cutCount itself. The old Next route
returned raw Prisma rows -- that derived shape was previously computed
only inside the dashboard's Server Component page, which won't exist
once the client moves to Vite, so it has to live in the API response
instead.

Verified against the real dev database via curl -- list and detail
responses match the old Next routes byte-for-byte (aside from the new
derived fields).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Returning Bun.file(path) directly as the Response body natively handles
the incoming Range header -- 206 + Content-Range/Accept-Ranges on a
partial request, plain 200 without one -- confirmed empirically against
Bun v1.4.2, including through a Hono handler specifically (not just
Bun.serve directly). This replaces ~35 lines of manual range-regex
parsing and manual header construction the old Next route needed.

Verified against a real project's video asset: byte ranges, Content-
Range headers, and full-file responses all match what the old Next
route produced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deliberate wire-contract change from the old Next route: the request
body is now the raw video bytes (Content-Type: video/*, filename via a
?filename= query param) instead of multipart/form-data. This lets the
server stream straight to disk via Bun.write(path, request.body)
without ever buffering the whole upload in memory -- the old route's
request.formData()/file.arrayBuffer() held the entire file in RAM, a
real problem once videos get into the hundreds of MB.

The existing 500MB cap is enforced via Content-Length before the stream
starts, so an oversized upload is rejected without reading a single
byte of the body.

Verified end-to-end against a real 16MB video file: response asset
metadata, on-disk file size, and MD5 checksum all match the source file
exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mechanical port from the old Next route handlers -- same Whisper call,
same Zod schemas, same Prisma calls. Verified the transcript PATCH
(speaker label assignment/clearing) against a real project's transcript
data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…outes

Mechanical port from the old Next route handlers. Verified against real
project data: edit-operation create/delete round-trips correctly
(including Zod rejecting an incomplete payload, same as before), and
filler-word detection's OpenAI SDK call works correctly under Bun's
runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fire-and-forget kickoff (void runRenderJob(...) followed immediately by
a 202 response) needs no waitUntil-style mechanism here: Bun.serve() is
one long-lived process, not a request-scoped function, so the promise
keeps running on the event loop after the response flushes.
render-job.ts's own try/catch already swallows every error and writes
status "failed", so an unhandled rejection can't take the process down.

Verified end-to-end against a real project: kicked off a render, polled
it to "completed", and downloaded a valid MP4 through the download
route's Bun.file() response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mounts all ten route modules under /api/projects and starts Bun.serve.
All 13 of the old Next API routes now have a working equivalent here,
verified end-to-end against real dev data (see the last several
commits) -- this backend is a complete, framework-agnostic replacement
for client/src/app/api/**, ready for the Vite client to point at next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 206 partial-content response already got this from Bun automatically;
the plain 200 (no Range header in the request) didn't. Browsers consult
Accept-Ranges to decide whether a media resource is seekable, so add it
explicitly for consistency across both response shapes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xt app

Adds vite.config.mts (dev-server proxy for /api/* to the Bun backend on
:3001, native tsconfig-paths resolution via Vite 8's resolve.tsconfigPaths
rather than the vite-tsconfig-paths plugin), index.html, main.tsx, and a
copy of globals.css with the --font-plex-sans/--font-plex-mono variables
that next/font/google used to inject now defined directly (fonts load via
a Google Fonts <link> tag in index.html instead -- no build-time
font-fetch-and-self-host step in a plain Vite SPA, and this is a
locally-run single-user tool, not a public site optimizing first-load
metrics on a slow network).

Also removes the unused create-next-app boilerplate SVGs from public/
(confirmed unreferenced anywhere in src/) and moves the favicon into
Vite's public/ directory.

client/src/app/** (the Next app) is untouched and still fully working --
this commit only adds new files alongside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three routes (/, /new, /editor/:projectId) plus a catch-all, matching
the old app/page.tsx, app/new/page.tsx, and app/editor/[projectId]/page.tsx.

DashboardRoute and EditorRoute replace the two Next Server Components
that queried Prisma directly at render time -- they now fetch from the
Hono backend on mount instead (GET /api/projects and
GET /api/projects/:id, both already verified to return the exact shape
these pages need) and feed the result into the same, unchanged
hydrate()-based Zustand stores EditorHydrator already used. EditorRoute
also narrows the API's raw logoPosition string via toLogoPosition(), the
same conversion the old Server Component did, and renders NotFoundRoute
on a 404 in place of Next's notFound().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mechanical prop rename (href -> to) and API swap (useRouter().push ->
useNavigate()) across the 4 components that used them -- Dashboard,
ProjectRow, EditorLayout, UploadScreen.

UploadScreen's upload call also changes to match the server's new
streaming upload contract (see the upload route commit): a raw file
body with Content-Type: <file.type> and a ?filename= query param,
instead of multipart/form-data -- a File/Blob is itself a valid
streaming fetch body, so this is a small change, not a rewrite.

Verified end-to-end in a real browser against the Vite dev server +
Bun backend: dashboard renders real project data and thumbnails,
editor loads and hydrates from the API (video playback, transcript
sync, timeline thumbnails/waveform, captions panel), and navigation
between all three routes works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removes client/src/app/** (all 13 API routes, the 3 page components, and
layout.tsx) now that the Vite client + Bun backend fully replace them --
verified end-to-end in a real browser first (dashboard, editor hydration,
video playback, transcript sync, navigation between all three routes).

Also removes: next.config.ts; the client-side copies of libs that were
only ever duplicated for the migration's transition period and are now
server-only (lib/ai, lib/db, lib/ffmpeg, lib/storage, lib/validation,
lib/captions/format.ts) -- their server/src/lib counterparts are the ones
actually running now; client/prisma/ and prisma7.config.ts (the server
owns the one Prisma schema); src/generated/prisma (server has its own);
.env.example (nothing in the client needs an env var -- all AI/FFmpeg/DB
access is server-only per claude.md's security requirements); and
AGENTS.md/CLAUDE.md, which were Next-dev-server-generated and next-dev-
specific respectively, both now stale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- package.json: drop Next/Prisma/server-only deps (next, @prisma/*,
  better-sqlite3, openai, zod, eslint-config-next, prisma, dotenv,
  @types/better-sqlite3), scripts now run Vite (dev/build/preview)
  instead of next dev/build/start.
- tsconfig.json: drop the Next TS plugin and .next/** type includes,
  add vite/client + node ambient types (the latter for vite.config.mts's
  own process.env usage, not leaked into app code).
- eslint.config.mjs: replace eslint-config-next with a plain flat config
  (typescript-eslint, react-hooks, react-refresh). Deliberately keeps
  only rules-of-hooks + exhaustive-deps from eslint-plugin-react-hooks
  rather than v7's full React Compiler-oriented rule set, to match what
  eslint-config-next was actually enforcing before -- this migration
  ports the framework, it doesn't relint the whole codebase against a
  much stricter ruleset. Turns off react-refresh/only-export-components
  for src/components/ui/** (shadcn primitives legitimately co-export a
  component and its variant helper from one file).
- Removes 4 now-dangling `eslint-disable-next-line @next/next/no-img-element`
  comments (ElementsPanel, FiltersPanel, Timeline, VideoPlayer) -- that
  rule no longer exists now that eslint-config-next is gone.
- .gitignore: drop Next-specific entries (.next/, next-env.d.ts),
  correct the build-output ignore from /build to /dist (Vite's default).
- vitest.config.mts: drop the OPENAI_API_KEY test env shim -- nothing
  client-side imports an OpenAI-touching module anymore.

Verified clean: tsc --noEmit, eslint, vite build, and vitest all pass
with zero errors/warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces every "cd client && pnpm run dev" / port 3000 / Next-specific
instruction with the new workspace-root pnpm run dev (runs the Vite
client and Bun backend together) and port 5173. setup-mac.sh now also
installs Bun (via its own official installer rather than the Homebrew
formula, which fails to compile better-sqlite3 against a stale Xcode
Command Line Tools version -- a real failure hit while building this
migration) and writes server/.env instead of client/.env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Section 13: two local processes now (Vite client, Bun/Hono server),
  not one Next.js process -- still no cloud, still local-first. Notes
  the @prisma/adapter-libsql driver choice (avoids better-sqlite3's
  native-binding packaging pain under Bun/Tauri).
- Section 14: rendering diagram's "Next.js API route" -> "Hono API
  route (Bun)".
- Section 21: drops the never-built `POST /api/projects/:id/ai/edit`
  line (pre-existing spec drift from section 3's already-documented
  removal of the free-text AI command bar), documents the upload
  endpoint's raw-body wire contract.
- Section 28: the "desktop application" out-of-MVP line now notes that
  Tauri packaging itself still isn't built, but the client/server split
  exists specifically to make it addable later without a rewrite.
- Section 31: replaces the single-app src/ tree with the actual
  client/ + server/ workspace layout, and states plainly that
  agents/lib/langgraph don't exist in either package (section 3's
  already-documented removal).
- Top of file: `npm install && npm run dev` -> `pnpm install && pnpm
  run dev`, matching the pnpm workspace this project actually uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds server/src/lib/logger.ts -- `[HH:MM:SS] LEVEL  message` -- and wires
Hono's built-in request-logging middleware to print through it, so every
request (method, path, status, timing) now shows up in the terminal
running `pnpm run dev`. Previously the server logged almost nothing:
a startup line and three scattered console.error calls (render job
failure, transcription failure, filler-word detection failure), each in
its own ad-hoc format. Those three now go through the same logger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Produces a self-contained copy of the backend at repo-root
server-bundle/ that runs via `bun run src/index.ts` from any working
directory, not just this git checkout -- the foundation for spawning it
as a Tauri-packaged app's backend process.

Two non-obvious things had to be worked out empirically, both confirmed
by running the bundle from a scratch directory with no relation to the
git checkout (mirroring the risk-gate discipline used to verify
@prisma/adapter-libsql under Bun in an earlier migration):

- A plain rsync/cp of server/node_modules silently drops @libsql/client
  (and its native @libsql/darwin-arm64 binding): pnpm resolves a
  package's transitive deps through a private per-package node_modules
  slot living inside the workspace root's .pnpm store as a *sibling* of
  the requiring package, not anywhere reachable by walking
  server/node_modules's own tree. `pnpm deploy --prod --legacy` rebuilds
  a genuinely self-contained node_modules that gets this right.
- Bundling via `bun build --target bun` (originally the plan) breaks
  that same resolution trick by flattening everything into one file at
  the top level -- @libsql/darwin-arm64 is left as an external runtime
  require, and once the requiring code's location changes, so does
  where Bun looks for it. So this bundle ships TypeScript source as-is
  (Bun runs .ts directly) and runs unbundled, keeping every file's
  location relative to node_modules exactly as pnpm deploy laid it out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds static-file serving for the built Vite client (server-bundle/
client-dist in the packaged Tauri app; harmlessly skipped in plain
`bun run dev`, where no client-dist exists and Vite's own dev server
handles the client instead), so one Bun process can serve both the UI
and the API in the packaged app -- matching the production shape the
client/server split was originally built toward.

Registered after every /api/* mount, so static/SPA-fallback routes
never shadow an API request.

The SPA fallback (serving index.html for a client-side route like
/editor/abc123 that has no matching file on disk) needed root passed
alongside path, not path alone: Hono's serveStatic joins them via
path.join(root, path), and path.join("./", "/abs/path") silently
strips the leading slash off an absolute path when root is left at its
"./" default -- confirmed by writing a 3-line isolated repro before
fixing the real code.

Verified: plain dev mode still starts cleanly with no client-dist
present; with CLIENT_DIST_DIR set, index.html/static assets/SPA
fallback/API routes all return correct status codes and content types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirms the Rust toolchain (rustup, just installed) and the whole
build pipeline work before wiring anything app-specific: `tauri build
--no-bundle` compiles cleanly (~1m12s cold) and produces a real arm64
Mach-O binary at src-tauri/target/release/app.

Bundle identifier changed from the scaffold's placeholder
"com.tauri.dev" (Tauri refuses to build with it -- "not allowed as it
must be unique across applications") to "com.local.aivideoeditor".

frontendDist/devUrl were already set correctly at `tauri init` time
(http://localhost:3001 / :5173) via CLI flags -- confirms Tauri v2
does accept a URL for frontendDist, not just a static path, which the
next phase's single-Bun-process design depends on.

Everything else (window size, resource bundling, the actual spawn
logic) is still the scaffold default -- next phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes a second, different resolution failure discovered while testing
the actual packaged Tauri app end-to-end (the previous pnpm-deploy-
based bundle worked standalone but broke once Tauri copied it into the
.app):

Tauri's `bundle.resources` copy step does not handle pnpm's nested
per-package private node_modules slots (symlinks into its own local
.pnpm virtual store) correctly -- confirmed by comparing file counts
between the source bundle and the copy inside the built .app: same
total file count (14793), but zero of the source's 395 symlinks
survived, silently dropping entire directories (including
@prisma/adapter-libsql itself) that were only reachable via one.

Naively dereferencing those symlinks afterward (rsync -aL) doesn't
work either: each symlink flattens independently, so
@prisma/adapter-libsql's own copy loses the sibling relationship it
needs to find @libsql/client once it's no longer nested inside its
private resolution slot -- confirmed by a second, different
"Cannot find module" failure after trying that.

pnpm's "hoisted" node-linker sidesteps both problems at once: a
classic/npm-style flat layout (every package, transitive deps
included, sits directly at node_modules/<pkg>) with no private slots
and no .pnpm store to symlink into in the first place. Verified
against a real packaged .app this time, not just the standalone
scratch-directory test: the app launches, its spawned server starts
and serves both the API and the static client correctly, and a test
project survives a full quit + relaunch cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires client/src-tauri/tauri.conf.json (bundle identifier, resource
mapping for server-bundle/, ad-hoc macOS signing, a hidden-until-ready
main window) and src/lib.rs (resolve resource_dir/app_data_dir, seed
the database from app.db.template on first launch only, spawn the
bundled server via tauri-plugin-shell, forward its stdout/stderr into
the app's own log, show the window once "server listening" appears,
kill the child on RunEvent::Exit) together into a real packaged .app.

Two bugs only surfaced once actually testing the built .app, not
before:

- `beforeBuildCommand`'s cwd assumption was wrong: Tauri runs hook
  commands from client/, not client/src-tauri/, so the original `cd
  ../..` overshot by one directory level and landed in the parent
  folder containing several unrelated sibling projects -- pnpm's
  --filter then matched a same-named "client" package in each of them
  and built all of them. Fixed by switching to the HookCommand object
  form with an explicit absolute cwd, removing the ambiguity entirely
  rather than guessing the right relative depth. (Confirmed harmless:
  each affected project only ran its own ordinary build script,
  writing to its own gitignored build output -- nothing was modified
  outside those directories.)
- Spawning "bun" by bare name failed with ENOENT: GUI-launched macOS
  apps don't inherit the interactive shell's PATH (~/.zshrc is only
  sourced for shell sessions), even though `which bun` works fine in a
  terminal. Fixed with an absolute path built from $HOME, the same
  pragmatic choice already made for FFMPEG_PATH.

Also excludes src-tauri from client/tsconfig.json: Tauri's own build
script copies bundle.resources into target/debug/ even for `cargo
check`, and the client's previously-unscoped "**/*.ts" include picked
up the copied server-bundle/*.ts files, typechecking them against the
client's own tsconfig (wrong path aliases, no Bun types) and failing
the client build.

Verified against the actual built and ad-hoc-signed .app, not just
`cargo check`: launches cleanly (no Gatekeeper quarantine flag since
it was built locally, not downloaded), the spawned server starts and
correctly serves both the built static client and the /api/* routes
on the same origin, a project created through the running app survives
a full quit (via the standard Quit AppleEvent, which -- unlike a raw
SIGTERM -- actually exercises RunEvent::Exit) and relaunch cycle with
no orphaned server process left behind afterward, and the data lands
under ~/Library/Application Support/com.local.aivideoeditor/ as
expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- claude.md section 28: removes "desktop application" from
  out-of-MVP now that a working (if personal-use-scoped) build
  exists.
- claude.md section 35 (new): architecture rationale for the
  spawn-system-bun approach over a compiled sidecar (the @bun/libsql
  --compile bug), what scripts/prepare-server-bundle.sh's hoisted
  node-linker choice fixes and why, the frontendDist-as-URL design,
  the beforeBuildCommand cwd gotcha, and the current known
  constraints (personal-use-only scope, separate app-data location
  from dev, hardcoded machine-specific paths).
- README.md: adds a short "macOS app" build pointer under "Running
  the app".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
amide-init and others added 8 commits September 24, 2026 01:36
Real bug, reported by the user after installing the built .app: it
opened to a blank white screen. Root cause -- the webview starts
loading frontendDist (http://localhost:3001) as soon as it's created,
which races the just-spawned server's own startup and loses; nothing
in the ready-handler told the webview to retry once the server
actually came up, so the window's show() call just revealed the
already-failed blank page. This was never caught earlier because
verification up to this point used curl against the server directly
-- which proves the server works but says nothing about what the
webview itself rendered.

Fixed by explicitly calling w.navigate() with the same URL once the
"server listening" line appears, alongside the existing w.show().

Verified properly this time: launched the app fresh, waited without
touching curl at all, and confirmed via the server's own request log
that the webview organically requested /, its JS/CSS bundle, and
/api/projects immediately after the server came up -- proof the page
actually loaded and React mounted, not just that the server can
answer a manual curl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same class of bug as the earlier tsconfig fix: Tauri's build script
copies bundle.resources into target/{debug,release}/ (and generates
its own build-script output like __global-api-script.js there too),
and client's unscoped eslint config picked those up -- a minified
Tauri-internal JS file triggered 2300+ unrelated errors. tsconfig.json
already excludes src-tauri; eslint's own globalIgnores needed the same
treatment, since it's a separate exclusion list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prerequisite for distributing the packaged Mac app publicly: right now
the app bakes the developer's real OPENAI_API_KEY into the bundle at
build time, which is fine when the build never leaves this machine but
unacceptable once strangers can download it -- anyone could extract
the key and run up charges on the developer's account. This replaces
that with a settings screen: each person who runs the app enters their
own key, which the app stores locally and validates against OpenAI's
real API before saving.

- server/src/lib/settings.ts (new): reads/writes DATA_DIR/settings.json,
  the one piece of config supplied through the app itself rather than
  an env var. getOpenAiApiKey() checks settings.json first, falls back
  to server/.env's OPENAI_API_KEY -- local dev with `pnpm run dev`
  keeps working exactly as before, unchanged.
- server/src/routes/settings.ts (new): GET /api/settings returns only
  whether a key is configured, never the key itself. POST validates
  the key with a real OpenAI call (models.list()) before persisting,
  so a typo'd key fails immediately and clearly instead of surfacing
  later as an opaque transcription error.
- server/src/lib/ai/transcribe.ts, filler-words.ts: source the OpenAI
  client from getOpenAiApiKey() instead of process.env directly, and
  stop caching the client as a singleton -- a cached client would keep
  serving a stale (missing-key) instance after someone saves a key
  through the new screen without restarting the server.
- client/src/routes/SetupRoute.tsx (new), client/src/App.tsx: a
  RequireApiKey wrapper checks /api/settings once on mount and
  redirects to /setup if no key is configured; SetupRoute doubles as
  a revisitable "replace your key" screen, not just first-run.

Verified as a real end-to-end round-trip, not just unit-level: started
both dev servers with OPENAI_API_KEY removed from server/.env and no
settings.json present, confirmed visiting `/` in a real browser
redirects to /setup, pasted a real key (via clipboard, never typed
literally or printed to any log, to avoid the key passing through
tool-call text), saved it, watched it get live-validated against
OpenAI and redirect to the dashboard, and confirmed settings.json was
written correctly -- then cleaned up the test key/file and restored
the original .env afterward.

Also fixed a bug found during that same verification pass: GET
/api/settings originally checked only settings.json, so local dev
(key only in .env, nobody's ever touched the setup screen) would have
incorrectly reported hasApiKey: false and wrongly redirected working
setups to /setup. Fixed to check the same getOpenAiApiKey() fallback
chain everything else uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prerequisite for CI: beforeBuildCommand had a cwd hardcoded to this
exact machine's absolute path
(/Users/aminuddin/Desktop/pluto/text2video), found during planning for
the CI workflow -- would have failed outright on any GitHub Actions
runner or any other machine. Tauri hook commands run from client/
(confirmed empirically while building this originally), so the
correct portable form is `cd ..` (one level to repo root), not the
absolute path -- the original object-form cwd was carrying a bug baked
in from an earlier debugging session, not a deliberate choice.

Also adds "dmg" to bundle.targets alongside the existing "app" --
Tauri builds it natively, and it's the standard drag-to-Applications
experience for a downloaded Mac app, which the plain .app bundle isn't
really meant for.

Also removes prepare-server-bundle.sh's step that copied server/.env
(including the real OPENAI_API_KEY) into the bundle -- superseded by
the new settings.json-based setup screen, and confirmed nothing else
needed it: every other env var the server needs is already set
explicitly by lib.rs's spawn call, which overrides a bundled .env
anyway. Applies to every build now, not just CI -- one code path, no
personal-build-vs-distributed-build branching.

Verified with the actual command CI will run
(`pnpm exec tauri build`), not just reasoning about the diff: confirms
"Done: .../text2video/server-bundle" (the correct repo root, not the
parent directory containing unrelated sibling projects the original
bug would have reached), produces both a .app and a .dmg, and no
.env ships inside Contents/Resources/server-bundle/. Then wiped
~/Library/Application Support/com.local.aivideoeditor and launched
the built .app fresh: confirmed via its own server log that the
webview loaded, checked /api/settings, got hasApiKey: false, and
never went on to fetch /api/projects -- proof it actually redirected
to /setup and stayed there, not just that the server responds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The workflow was stale since before the Vite/Bun migration -- scoped
entirely to client/ (working-directory: client), ran `prisma generate`
there even though the Prisma schema now lives in server/, and never
linted, tested, or built server/ at all. Root package.json's
lint/test/build scripts already correctly fan out to both packages
via pnpm --filter; this just points CI at those instead of
re-implementing per-package steps by hand.

Also adds oven-sh/setup-bun -- server's own build script
(`bun build src/index.ts ...`) needs the bun binary on PATH, which the
old workflow never needed since it only ever touched client/.

Verified by literally replicating the workflow's command sequence
locally (pnpm install --frozen-lockfile, prisma generate in server/,
pnpm run lint/test/build from root, with the same dummy
OPENAI_API_KEY) -- confirms all 249 tests pass and both packages
build cleanly, not just that the YAML parses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New .github/workflows/build-macos-app.yml, runs on macos-latest
(Apple Silicon by default on current GitHub-hosted runners, matching
this project's arm64-only scope -- no cross-compilation flags needed).

Triggers on a v*.*.* tag push (matching the existing v0.1.0 tag/
release convention already in this repo) or manual workflow_dispatch.
The actual build is a single `pnpm exec tauri build` -- tauri.conf.json's
beforeBuildCommand already runs the Vite client build and
scripts/prepare-server-bundle.sh internally, so the workflow itself
only needs to set up tools (pnpm, node, bun, rust), install deps, and
run that one command.

No Apple signing secrets needed -- ad-hoc signing (this project's
current scope) needs no certificate or notarization credentials.

Every run (including a manual workflow_dispatch) uploads the built
.dmg and a zipped .app as workflow artifacts, so a build can be
downloaded and verified from a run's own Actions page. Only a real
tag-push run additionally attaches those same files to the matching
GitHub Release -- a workflow_dispatch test run never touches the
public releases page.

Verified everything short of an actual GitHub Actions run (this
branch hasn't been pushed to origin yet, so there's nothing to trigger
remotely): replicated the same `pnpm exec tauri build` this workflow
runs, and specifically tested the one new step that isn't already
covered by that -- `ditto -c -k --sequesterRsrc --keepParent` zipping
the built .app -- confirming the zip extracts back to a working app
bundle with its ad-hoc code signature intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Now that the app is CI-built and released with assets attached (ad-hoc
signed, arm64-only, API key entered on first launch — no key baked
into the build), it needs somewhere for people to actually find and
download it from. Adds docs/download.md covering the Gatekeeper
right-click-Open step and the Bun/ffmpeg-full prerequisites plainly,
links it from the docs nav and homepage hero, and updates README's
stale "personal use only, not for distributing" language to match.
Also brings claude.md section 35 in sync with the actual current
architecture (CI build/release path, settings.json-based API key
instead of a bundled .env, corrected beforeBuildCommand description).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…plit

getting-started.md, status.md, and security.md still described the
pre-migration architecture (cd client && pnpm run dev, port 3000,
"server/ reserved for future use, not in use yet", API routes living
in Next.js) — actively wrong now, and getting-started is the primary
Guide entry point a new visitor lands on. Brings all four pages (also
architecture.md, which didn't mention the process split at all) up to
date with the actual Vite/Bun split and current commands, and links
the new download page where relevant. Verified by serving the docs
site locally and confirming each changed page returns 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@amide-init
amide-init merged commit 16b1c0c into main Sep 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant