You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
All communication between the renderer and main process goes through named IPC
channels. The channel logic lives in the shared data core
(src/shared/dbCore.ts — channelHandlers);
src/main/ipc.js registers each one with ipcMain and adds the
few platform-bound channels (file dialog, file bytes). The renderer calls them via the
store, which uses the api seam over the preload
bridge. (On the web build the same channels run in-page against IndexedDB via
api/backends/local.ts — same names,
same envelopes.)
Calling convention
import{api}from'./api'constres=awaitapi.invoke('persons:getAll')if(res.success){/* use res.data */}
Handlers wrap their body in try/catch and never throw across the process boundary.
Callers must check success before reading data. All write handlers call
save() before returning, so a successful response means the change is on disk.
Converts an absolute image path to an appimg:// URL (each path segment is URL-encoded). Returns null for a falsy path.
Channels
Most write channels operate on the active project implicitly — new records are
tagged with the current activeProjectId, and getAll handlers filter by it.
Projects
Channel
Payload
Returns
projects:getAll
—
{ projects: Project[], activeProjectId }
projects:create
{ name? }
the new Project
projects:rename
{ id, name }
the updated Project
projects:delete
{ id }
{ id, newActiveProjectId } — cascades persons, relationships, tags (+joins +placements), scenes (+placements), images (files unlinked) and project-scoped settings; switches active project if needed
projects:setActive
{ id }
{ activeProjectId }
Persons
Channel
Payload
Returns
persons:getAll
—
Person[], each enriched with primary_image
persons:create
person fields (birth/death are DateValues)
the new Person (primary_image: null)
persons:update
{ id, ...fields }
the updated Person
persons:delete
{ id }
{ id } — cascades the person's relationships, images and entity_tags rows
persons:createMany
{ rows: [{ values?, ...fields }] }
{ persons, values } — one write + one undo step; the plan quota covers the whole batch (all or nothing)
persons:deleteMany
{ ids }
{ ids } (deduped) — same cascade as persons:delete; any id outside the active project throws before anything is removed
fields:setValuesBatch{ updates: [{ personId, values?, removals? }] } → { persons, values }
is the multi-person form of fields:setValues (the Directory Sheet's paste / fill /
clear path): every person is validated first, then all writes land as one atomic,
single-undo-step change. values covers exactly the touched people.
the updated Relationship (only provided fields change)
relationships:delete
{ id }
{ id }
Tags
Channel
Payload
Returns
tags:getAll
—
Tag[] (active project)
tags:create
{ label?, type?, source?, color?, icon? }
the new Tag (defaults filled in)
tags:update
{ id, ...partial }
the updated Tag
tags:delete
{ id }
{ id } — cascades the tag's entity_tags and scene_tags rows; people are untouched
Entity tags (membership join)
Channel
Payload
Returns
entity_tags:getAll
—
EntityTag[] (rows whose tag belongs to the active project)
entity_tags:add
{ entity_id, tag_id }
the join row — idempotent: re-adding an existing pair returns the existing row
entity_tags:remove
{ entity_id, tag_id }
{ entity_id, tag_id, removed }
Scenes
Channel
Payload
Returns
scenes:getAll
{ view? }
Scene[] (active project, optionally one view's)
scenes:create
{ view?, name?, type?, config?, positions? }
the new Scene
scenes:rename
{ id, name }
the updated Scene
scenes:duplicate
{ id, name? }
{ scene, scene_tags } — deep-copies config/positions and the scene's tag placements (membership is shared, never copied)
scenes:save
{ id, type?, name?, config?, positions? }
the updated Scene — how layout autosave persists arrangements
scenes:delete
{ id }
{ id } — cascades the scene's scene_tags rows
Scene tags (Groups placements)
Channel
Payload
Returns
scene_tags:getAll
—
SceneTag[] (rows whose scene belongs to the active project)
scene_tags:add
{ scene_id, tag_id, x?, y?, visible? }
the placement — idempotent per (scene, tag) pair
scene_tags:move
{ id, x, y }
the updated placement
scene_tags:setVisible
{ id, visible }
the updated placement
scene_tags:remove
{ id }
{ id }
Checkpoint (save model)
Channel
Payload
Returns
checkpoint:save
—
the checkpoint — snapshots the project's arrangement state (scenes + placements + Present override) into the checkpoint setting
checkpoint:revert
—
the restored checkpoint — wholesale-replaces the project's scenes and placements with it (original ids); throws if none was ever saved
Images
Channel
Payload
Returns
images:getByPerson
{ personId }
Image[], primary first
images:openDialog
—
selected absolute file path, or null if cancelled
images:add
{ personId, srcPath, isPrimary }
the new Image — copies the file into userData/images/
images:setPrimary
{ imageId, personId }
{ imageId }
images:delete
{ imageId }
{ imageId } — unlinks the file
Maps (the Map view)
Channel
Payload
Returns
maps:getAll
—
the active project's MapDoc[], oldest first
maps:save
a MapDoc (partial). Without id: create (defaults: parchment, sea, 2400×1600). With id: patch only the given fields
the saved MapDoc — every field is sanitized by applyMapPatch (src/shared/maps.ts); throws Map not found for another project's id
maps:delete
{ id }
{ id }
eras:getAll
—
the active project's Era[]
eras:save
an Era (partial). Without id: create; with id: patch the given fields
the saved Era — sanitized by applyEraPatch (src/shared/chronicle.ts; a reversed span is put in order)
eras:saveMany
{ eras: Era[] } (preset packs)
the saved Era[] — one write, one undo step
eras:delete
{ id }
{ id }
events:getAll
—
the active project's StoryEvent[]
events:save
a StoryEvent (partial). Without id: create; with id: patch
the saved StoryEvent — sanitized by applyEventPatch (unknown kind → custom, importance clamped 1–3, cast filtered to the project's people)
events:delete
{ id }
{ id }
Maps are not in the app-wide undo snapshot (the Map view keeps its own stack).
Tokens of a deleted person are kept (and skipped by the renderer) so undoing the
person's deletion puts them back on the map.
Settings (per-project)
Channel
Payload
Returns
settings:getAll
—
flat map of the active project's settings (prefix stripped)
settings:set
{ key, value }
{ key, value } — stored under "<activeProjectId>:<key>"
Global settings
Channel
Payload
Returns
globalSettings:getAll
—
the global settings map (e.g. { theme, programMode })
globalSettings:set
{ key, value }
{ key, value }
Billing (premium plans — sandbox provider only)
Implemented in src/shared/billing.ts. No real
payments exist yet; checkout completes only through the sandbox, and only when the
shell sets env.billingSandbox (dev builds). The hosted-backend work is listed in
MONETIZATION.md.
CheckoutSession — refused for guests, for a duplicate plan, for a bad code, and when the sandbox is off ("Payments aren't live yet")
billing:completeSandboxCheckout
{ id, outcome: 'success' | 'fail' | 'cancel' }
{ session, status } — sandbox only; success sets user.plan and records a paid invoice, fail records a failed one
billing:cancel
—
status — cancel_at_period_end = true; the plan stays until period end
billing:resume
—
status — undoes a pending cancellation
The appimg:// protocol
Registered as a privileged scheme in index.js and handled
at app-ready. It maps an appimg:// URL back to an absolute filesystem path and
streams the file via net.fetch('file:///…'). This lets locally-stored photos
render in the renderer without enabling file:// access or loosening the CSP. Build
these URLs with electronAPI.getImageUrl(path) — never hand-construct them.
Adding a new channel
Add a handler to channelHandlers in
src/shared/dbCore.ts (and the channel to
WRITE_CHANNELS if it mutates) — both shells register it automatically and
persist after writes.
Add a store action in store/index.js that
calls api.invoke(...) and updates reactive state on success.
Call the store action from components — never api.invoke directly from a view.
See conventions.md for the rationale behind these layers.
{ "success": true, "data": <payload> } // ok { "success": false, "error": "<message>" } // failure