- Dart 94.4%
- Shell 3.9%
- CMake 0.9%
- C++ 0.7%
| android | ||
| assets/icon | ||
| doc | ||
| drift_schemas | ||
| lib | ||
| linux | ||
| test | ||
| tool | ||
| .fvmrc | ||
| .gitignore | ||
| .metadata | ||
| analysis_options.yaml | ||
| build.yaml | ||
| LICENSE | ||
| native-app-recreation-spec.md | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
Comicstash
A native comic collection app for Linux desktop and Android. There is no application server: each device keeps a local SQLite database, and a WebDAV share you own is the durable store that keeps devices in sync. Metadata and covers can be imported from Metron.
This follows the principle of local-first; the app stores information only locally or on a WebDav server you control. There is no other party that holds your data.
This is the rebuild described in native-app-recreation-spec.md.
Contributing
Getting started
fvm install # Flutter 3.47.5, pinned in .fvmrc
fvm flutter pub get
fvm flutter run -d linux
fvm flutter test
Android builds need JDK 21 (fvm flutter config --jdk-dir /usr/lib/jvm/java-21-openjdk) and the Android SDK command-line tools with accepted licenses (fvm flutter doctor --android-licenses). The minimum SDK is Flutter's own flutter.minSdkVersion, so it follows the Flutter version.
After changing lib/data/tables.dart, regenerate the database code:
fvm dart run build_runner build
Trying changes on a copy of your collection
tool/sandbox.sh # run on a copy of your data (made on first use)
tool/sandbox.sh --reset # start over from a fresh copy
tool/sandbox.sh --local-dav # also sync, with a local test WebDAV server
tool/sandbox.sh --sync-to comicstash-sandbox # or: sync to a separate folder on your server
tool/sandbox.sh --demo --reset # demo data instead of your collection
tool/sandbox.sh --name small --small # just two series (one tracked, one ended): quick Metron tests
tool/sandbox.sh --info | --delete | --help
The sandbox gets its own copy of the database, covers and settings in ~/.cache/comicstash-sandbox/<name>, and it has no access to the keyring. Your real data, keyring and WebDAV folder are never touched. That makes it the place to try migrations and sync changes before installing a new version.
--local-dav starts tool/dav_server.sh on a free port while the app runs, with its own data in the sandbox cache, and fills in the sandbox's sync settings. The sandbox remembers this, so later runs start the server again. To simulate two devices, run two sandboxes on one share, each in its own terminal:
tool/sandbox.sh --name a --share test
tool/sandbox.sh --name b --empty --share test
The second one reuses the server the first one started. Close the first one last, because it stops the server.
--sync-to takes the server from your real settings, but always with a different folder. It also copies your WebDAV password and Metron key into the sandbox, as a file only you can read. --delete removes that copy too.
Changing the database schema
- Change the tables in
lib/data/tables.dart, raiseschemaVersioninlib/data/database.dart, and add a step toonUpgrade. - Regenerate the code and record the new schema:
fvm dart run build_runner build fvm dart run drift_dev schema dump lib/data/database.dart drift_schemas/ fvm dart run drift_dev schema generate drift_schemas/ test/data/generated_migrations/ - Run
test/data/migration_test.dart. It upgrades databases of every earlier version and checks the result. - If the change affects what is synced (a new field, for example), also raise
SyncEngine.schemaVersioninlib/sync/sync_engine.dart. The first updated device then marks the share with the new version, and older apps refuse to sync instead of dropping the new field. Update every device afterwards.
Local WebDAV server
tool/dav_server.sh # http://127.0.0.1:8765/dav/ user tester / secret
In the app, go to Settings → Sync, enter the URL and credentials, turn on Allow unencrypted HTTP (it's a local server), and press Test & save. To test multi-device sync on one machine, run two app instances with different data directories:
XDG_DATA_HOME=/tmp/dev-b/data XDG_CACHE_HOME=/tmp/dev-b/cache XDG_CONFIG_HOME=/tmp/dev-b/config \
build/linux/x64/debug/bundle/comicstash
To run the sync integration test against the server:
DAV_URL=http://127.0.0.1:8765/dav/ DAV_USER=tester DAV_PASS=secret \
fvm flutter test test/sync/real_server_test.dart
Releasing
tool/release.sh manages versions. It only reads from git and prints the git commands for you to run.
tool/release.sh # show app version, DB schema, sync format, tags
tool/release.sh minor # or patch | major | build; add --dry-run to preview
# review, then run the printed git commands (commit + tag), and:
tool/release.sh package # build all packages for the tagged release
- The version name (
1.2.3) follows semantic versioning. - The build number (
+N) only ever goes up. It becomes Android'sversionCodeand the pacmanpkgrel. - Change the version when you cut a release, not right after one.
- Each release is a commit tagged
v<name>+<build>, andpackagerefuses to build from anything else unless you pass--force.
Publishing to Forgejo
After tool/release.sh package, push the commit and the tag, then:
tool/publish_release.sh --dry-run # see what would be uploaded
tool/publish_release.sh
This does two things:
- It creates the Forgejo release for the tag, with release notes taken from the git log since the previous tag. It attaches the APKs, the tarball, the AppImage, the pacman package and a
SHA256SUMSfile. - It uploads the pacman package to Forgejo's Arch package registry.
Running it again only adds what is missing. The server and repository are read from the git remote; override them with FORGEJO_URL / FORGEJO_REPO.
Tokens. Create these in Forgejo under Settings → Applications, and store each once in the keyring.
- Release token. Scope
write:repository. It can be limited to this repository.secret-tool store --label="Forgejo token" service forgejo host forge.arjenwiersma.nl - Package token. Scope
write:packageonly, with repository access All. Forgejo doesn't allow the package scope on tokens limited to specific repositories, because packages belong to a user, not a repository.secret-tool store --label="Forgejo package token" service forgejo-packages host forge.arjenwiersma.nl
Instead of the keyring you can export FORGEJO_TOKEN and FORGEJO_PACKAGE_TOKEN. If there is no package token, the release token is used for both, so it then needs both scopes and access to all repositories.
Installing from the pacman registry
One-time setup on each Arch/CachyOS machine:
curl -o /tmp/forgejo-arch.key https://forge.arjenwiersma.nl/api/packages/arjen/arch/repository.key
sudo pacman-key --add /tmp/forgejo-arch.key
sudo pacman-key --lsign-key 'arjen@noreply.forge.arjenwiersma.nl'
Then add this to /etc/pacman.conf:
[arjen.comicstash.forge.arjenwiersma.nl]
SigLevel = Required
Server = https://forge.arjenwiersma.nl/api/packages/arjen/arch/comicstash/$arch
After that, sudo pacman -Syu comicstash installs it, and every later pacman -Syu picks up new releases.
Packaging
Packages are written to dist/. The version comes from pubspec.yaml (version: 1.0.0+1 means version 1.0.0, build 1). Raise the build number for every release, because Android won't install an update with a lower or equal one.
Linux: tarball and AppImage
tool/package_linux.sh # both (or: tarball | appimage)
comicstash-<version>-linux-x86_64.tar.gz: unpack it anywhere and run./comicstash../install-desktop-entry.shadds it to the application menu for the current user.Comicstash-<version>-x86_64.AppImage: a single file; make it executable and run it.appimagetoolis downloaded tobuild/tools/on first use.
Arch Linux / CachyOS package
tool/package_arch.sh
sudo pacman -U dist/comicstash-<version>-<build>-x86_64.pkg.tar.zst
This builds a normal pacman package from the release tarball, in the style of an AUR -bin package. The template is linux/packaging/arch/PKGBUILD.in. The app goes to /opt/comicstash, with comicstash in /usr/bin, a menu entry and icons. Upgrade the same way after raising the build number in pubspec.yaml: 1.0.0+2 becomes package version 1.0.0-2. Uninstall with sudo pacman -R comicstash; your collection and settings in ~/.local/share/nl.arjenwiersma.comicstash stay.
Android: APK
tool/package_android.sh
This writes one APK per CPU type. Comicstash-<version>-arm64-v8a.apk is the one for practically every current phone. Copy it to the phone and open it; Android will ask you to allow installs from that source.
Signing. Without android/key.properties, release builds are signed with the debug key. That is fine for sideloading, but not for the Play Store. To use a real key, create one once, keep it safe, and never commit it:
keytool -genkey -v -keystore ~/keys/comicstash-release.jks \
-keyalg RSA -keysize 4096 -validity 10000 -alias comicstash
Then create android/key.properties:
storeFile=/home/you/keys/comicstash-release.jks
storePassword=…
keyAlias=comicstash
keyPassword=…
Android only accepts updates signed with the same key as the installed app. When you switch from the debug key to your own, uninstall the old app first. Your collection comes back from the WebDAV share at the next sync; you do have to enter the Settings again.
Icons
All icons are generated from assets/icon/comicstash.svg (plus comicstash_foreground.svg for Android's adaptive icon) with tool/generate_icons.sh, which needs rsvg-convert.
Layout
lib/
data/ drift schema, repository (all local writes), reactive queries,
deterministic ids, content-addressed blob store
domain/ natural issue-number sort
sync/ WebDAV client, record envelope codec, sync engine
services/ one folder per external service
metron/ API client, rate limiter, importer (import-by-id + tracked sync)
app/ bootstrap, providers, settings/secrets, sync & Metron
controllers, Android background job, router, theme
ui/ screens and widgets
dev/ debug-only demo data and screenshot harness
How it works
Local data
- The UI only ever reads and writes the local database. Every write goes through
Repository, which stampsupdated_atwith the local clock and marks the rowdirty. - Deletes are tombstones (
deleted_at). When a record is deleted, its own link rows go with it (credits, appearances, external ids). Characters and creators linked to a deleted issue are deleted only if nothing else links to them. Publishers and roles are never deleted by cascade. - The database does not enforce foreign keys, because sync can deliver a child before its parent. Queries skip orphaned rows.
IDs
- Records you create by hand get random UUIDv4 ids.
- Records that have a natural key get a deterministic UUIDv5, so two devices that create "the same" thing offline end up with the same record instead of a duplicate.
lib/data/ids.dartdefines these:- link rows use the pair of ids they connect;
- roles use their name;
- external-id rows use (type, service, external id);
- imported records use (service, type, external id).
- Every derived id includes the service name, so other services (Comic Vine, …) can be added without collisions.
WebDAV sync
- The remote layout is
comicstash/manifest.json,comicstash/data/<entity>/<uuid>.jsonandcomicstash/blobs/<sha256>.<ext>. - Each sync, per entity folder and in dependency order:
- Pull: list the folder with
PROPFINDand download only the files whose ETag changed. - Push: upload dirty rows with a conditional
PUT:If-Matchthe last seen ETag, orIf-None-Match: *for a new file. If another device wrote the file in the meantime, the push waits for a second pass, which pulls first.
- Pull: list the folder with
- Conflicts are whole-record last-write-wins on
updated_at. On a tie the remote copy wins, so both devices end up with the same version. - Images are uploaded before any record that references them, and downloaded when first displayed.
- Sync runs on app start and resume, 5 s after the last local edit, every 15 minutes while the app is open, from Sync now, and hourly in the background on Android.
Metron
- Authentication uses an API key (
Authorization: Bearer …), stored in the system keyring. - There are two rate limits. When the burst limit runs out, the client sleeps until it resets, but aborts if that would take more than 120 s. When the sustained limit runs out, the run stops, and the next scheduled run continues.
- Importing a series first shows it with its issue list. Nothing is imported until you choose issues, or "all", which also tracks the series.
/import?series=IDopens the screen with a series already looked up. The chosen issues are created right away and get their full details in the background; if the rate limit stops that, Update from external services on the series page finishes it. - Imports only fill fields that are blank locally; they never overwrite what you typed.
- Metron also reports each record's Comic Vine and Grand Comics Database (GCD) ids. Imports store those as extra external ids, again without replacing ones you entered, and skipping ids another record already has. They are references only; there's no importer for those services.
- The full issue detail is fetched once per issue, guarded by
detail_synced_at. To force a re-fetch, clear that field. - Only series marked Track are checked for new issues.
Known limitations
- Servers with timestamp-based ETags. Apache
mod_davand wsgidav derive ETags from the file's modification time, in whole seconds, plus its size. If two devices write the same record within one second and the two versions happen to be the same size, the second write is invisible to the other devices until that record changes again. Nextcloud and ownCloud use content-based ETags and are not affected. - Servers without ETags fall back to Last-Modified plus size, which has the same one-second limit.
- Conflicts are whole-record last-write-wins, and device clocks decide who wins. If two devices edit different fields of the same issue while offline, one of the edits is lost. This is deliberate for v1.
- No system keyring on Linux. On systems without a Secret Service (GNOME Keyring or KWallet), passwords are stored in a
0600file in the app's data directory, and Settings shows a warning. - Background sync on Linux only happens while the app is open. Headless sync via a systemd timer is not implemented yet.
License
Copyright © 2026 Arjen Wiersma
Comicstash is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Comicstash is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Comicstash; see LICENSE. If not, see https://www.gnu.org/licenses/.
Comic data imported from Metron is licensed separately by Metron under CC BY-SA 4.0; Comic Vine data is subject to Comic Vine's terms of use.


