Development
Prerequisites
- Node.js 24 (see
.nvmrc; every CI workflow uses Node 24 too). The app runs on the Node embedded in Electron (Electron 44 ships Node 24.x, seenode_versionin Electron'sDEPS), andnode:sqlite, which is still young, changes between Node releases: running the tests on the same major keeps them faithful to what the packaged app does. When bumping Electron, check its embedded Node major and keep.nvmrcand thenode-versionof the workflows in line. Electron Forge 8 needs Node 22.13 or later, which Node 24 satisfies. - npm (the repository ships a
package-lock.json).
Line endings
Every text file uses LF. .editorconfig tells editors so, and .gitattributes (* text=auto eol=lf) makes Git store and check out LF whatever the platform or core.autocrlf setting, so a Windows checkout no longer brings CRLF back. After pulling this change into an existing clone, git add --renormalize . converts any file still committed with CRLF.
Commands
| Command | What it does |
|---|---|
npm install | Installs dependencies. |
npm start | electron-forge start: runs the app from the sources, with HMR on the renderer. Also regenerates src/routeTree.gen.ts. |
npm run dev | The same, in development mode: the app also restarts by itself when main-process code changes, and opens DevTools. |
npm run lint | ESLint (flat config: typescript-eslint, import-x, react-hooks, react-refresh). |
npm run typecheck | tsc --noEmit. |
npm test | Vitest, once (node environment, no DOM). |
npm run test:e2e | Playwright smoke tests of the packaged app (run npm run package first; on a headless Linux, prefix with xvfb-run). See Smoke tests. |
npm run package | electron-forge package: builds the app into out/. |
npm run make | electron-forge make: builds installers for the current platform (Squirrel, ZIP on macOS, deb, rpm). |
npm run icons | Renders assets/icon.svg into assets/icon.png, icon.ico and icon.icns (committed; run it after editing the SVG). |
npm run docs:gen | Generates the database schema and IPC references, and checks the IPC channels of preload and main agree. |
npm run docs:api | Generates the API reference with TypeDoc; fails on an undocumented export or a broken {@link}. |
npm run docs:check | Checks that the npm scripts, file paths and relative links cited in the docs exist. |
npm run docs:build | All of the above, then builds the static site with VitePress into docs/.vitepress/dist (fails on a dead link). |
npm run docs:dev | Generates the references, then serves the docs site locally with hot reload. |
Run a single test file or a single test by name with Vitest directly:
npx vitest run src/main/db/library-repository.test.ts
npx vitest run -t "clamps the current page"Development mode
npm run dev is the command to use while working on the code. What happens on a change depends on the process it belongs to:
| Code changed | Without restarting by hand |
|---|---|
Renderer (src/routes/, src/components/, src/hooks/, src/lib/…) | Hot-replaced by Vite (HMR), the page keeps its state when it can. Same with npm start. |
Preload (src/preload.ts) | Rebuilt, then the window reloads. Same with npm start. |
Main process and decoder process (src/main.ts, src/main/, src/shared/) | Rebuilt, then the whole app restarts (the window closes and reopens). Only in development mode; with npm start, type rs in the terminal. |
DevTools open (detached) only in development mode; with npm start they stay closed but can still be opened from the View menu or with Ctrl+Shift+I.
How it's wired: forge.config.ts recognizes npm run dev from npm_lifecycle_event and sets TANKOBON_DEV=1, which the Electron app inherits from Forge's process; src/main.ts opens DevTools when it is set and the app isn't packaged. The restart is a postStart hook watching .vite/build/main.cjs and decoder-worker.cjs and calling Forge's own restart (the one rs triggers). The Vite plugin's hotRestart option is not used: in Forge 8.0 it has no effect, its watch builds running in a subprocess that never receives it.
A restart kills the app without going through will-quit, so the database isn't closed cleanly; SQLite handles that, but a long write (a folder scan) is interrupted.
Continuous integration
.github/workflows/ci.yml runs on every push to master and on every pull request, as independent parallel jobs: lint, typecheck, test and docs (npm run docs:build).
.github/workflows/e2e.yml runs the smoke tests every night and on demand (workflow_dispatch), not on pull requests: it packages the app and drives it under xvfb.
Four other workflows run on GitHub:
.github/workflows/docs.ymlbuilds the documentation and publishes it to GitHub Pages on every push tomaster..github/workflows/release-please.ymlmaintains a release pull request (version bump +CHANGELOG.md) from the Conventional Commits merged intomaster; merging that pull request tags the release..github/workflows/build.ymlbuilds the installers of a release tag (npm run make) and attaches them to its GitHub release: Squirrel (.exe) on Windows, ZIP on macOS (Apple silicon),.deband.rpmon Linux. Each platform builds on its own runner, since only the native@napi-rs/canvaspackage of the machine that rannpm cigets packaged (see Build gotchas). The job fails if the tag isn'tv+package.json's version. A tag created by release-please doesn't trigger workflows (it is pushed with the defaultGITHUB_TOKEN), sorelease-please.ymlcallsbuild.ymlitself once it has created a release; thepush: tagstrigger covers av*tag pushed by hand (the release is then created if missing), andworkflow_dispatchrebuilds an existing tag..github/workflows/pr-title.ymlchecks that a pull request's title follows the commit convention, since squash-merging turns it into the commit message onmaster.
Token permissions: each workflow declares the least GITHUB_TOKEN permissions it needs. ci.yml, pr-title.yml and build.yml default to contents: read at the workflow level; build.yml grants contents: write to its release job only (it creates the release and uploads the installers), so the make jobs, which run npm ci and third-party scripts, hold a read-only token. docs.yml adds pages: write and id-token: write for the Pages deployment, and release-please.yml needs contents: write and pull-requests: write to maintain the release pull request. A new workflow or job should start from contents: read and add only what it uses.
Dependabot (.github/dependabot.yml) opens one pull request a week, titled ci(deps): bump …, when the GitHub Actions used by these workflows have new versions.
Smoke tests of the packaged app
Vitest runs in a node environment with no DOM, so the React components (the reader above all) are only covered through their pure helpers in src/lib/. npm run test:e2e completes that with a few Playwright tests against the packaged app (out/, built by npm run package), which also catches what only breaks once packaged (see Build gotchas). They are not part of npm test: they need the package and a display, so on a headless Linux run xvfb-run npm run test:e2e.
What they cover (e2e/smoke.e2e.ts): the app starts and window.tankobon exists; a CBZ opens from the library in single-page mode and its progress is saved; the continuous mode follows the scroll, saves the last page and resumes on the saved page without reporting page 1 over it; a book opened from a reading list offers the list's next book on its last page.
How it works:
- Seeded user data: each test starts the app on a throw-away
--user-data-dir, whose databasee2e/app.tsfills through the app's ownmigrate()and repositories (books, reading lists, settings), so no native file dialog is involved. The language is forced to English. - Generated CBZ:
e2e/cbz.tsbuilds the test books on the fly (a few tall PNGs drawn with@napi-rs/canvas, in an uncompressed ZIP): no binary in the repository. - CDP, not
_electron.launch(): Playwright's Electron launcher needs--inspect, which the packaged app refuses (theEnableNodeCliInspectArgumentsfuse is off, on purpose). The executable is started with Chromium's--remote-debugging-portand Playwright attaches over CDP (chromium.connectOverCDP), which leaves the fuses untouched. The cost: the main process can't be reached from the tests (no dialog stubbing), hence the seeding. - A copy in an ASCII path:
e2e/global-setup.tscopies the package to a temporary directory first. On Linux the packaged app exits at once, silently, when it runs fromTankōbon-linux-x64(the "ō" is a decomposed character in the folder name); the same files start fine from an ASCII path, as in the deb's/usr/lib/tankobon. - CI:
.github/workflows/e2e.yml, nightly and on demand, on Linux only (packaging takes a few minutes and a pull request doesn't need it). It uploadstest-results/when it fails. The code ofe2e/app.tsalso handles the Windows and macOS executables, but only Linux has been run.
Documentation
The site's sources are in docs/:
- hand-written pages:
docs/guide/,docs/architecture.md,docs/development.md,docs/decisions/(ADRs); - generated pages, never edited nor committed:
docs/reference/api/(by TypeDoc),docs/reference/schema.mdanddocs/reference/ipc.md(byscripts/gen-reference.mjs).
The API reference covers the modules listed in typedoc.json's entryPoints. Every exported symbol there must have a TSDoc comment, or npm run docs:api fails.
Dependency audit
As of 2026-10-13, npm audit reports 14 findings (8 high, 6 moderate) and npm audit --omit=dev 10 of them. None ships in the packaged app, and none is reachable by a file or an answer the app handles:
upscaler→wireit→chokidar/fast-glob/micromatch→braces(high, a denial of service on deeply nested glob patterns).upscaler1.0.0 declareswireit, a build tool, as a runtime dependency; nothing of it is in the renderer bundle (node_modules/upscaler/dist/browser/esmdoesn't reference it).npm auditproposesupscaler@0.12.3, a downgrade of a major version: not an option.@tensorflow/tfjs→argparse→sprintf-js(moderate, a denial of service through unbounded precision specifiers in a format string): no fix exists. The umbrella package declaresargparsefor its own scripts; the browser entry the renderer imports (dist/index.js) doesn't reference it.- Build and docs tooling, never shipped:
esbuildandvitethroughvitepress1.x (its bundled Vite 5; the advisory only concerns the dev server,npm run docs:dev, and the published site is static; no fix until VitePress 2 leaves pre-release), andbracesagain throughvite-plugin-static-copy'schokidar, which only runs at build time on the project's own files.
Re-read npm audit after each dependency bump: a new finding that is not in this list, or one on a package the renderer or the decoder process really loads, is a real one.
Upgrading Electron Forge to 8 removed the extract-zip findings (@electron/packager 20 no longer depends on it) and made the former tar/tmp overrides unnecessary; npm audit fix took care of brace-expansion, fast-uri and ip-address.
Majors deliberately left behind:
@tensorflow/tfjsstays on~4.11.0, the rangeupscaleritself requires.- TypeScript 7 and
@types/nodeabove 22: not needed by any fix, to be done as their own change.