A native (flutter) application that helps you manage your comicbook collection
  • Dart 94.4%
  • Shell 3.9%
  • CMake 0.9%
  • C++ 0.7%
Find a file
2026-10-03 21:14:53 +02:00
android feat: make the resource pills links to their respective pages 2026-10-02 17:28:39 +02:00
assets/icon init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
doc feat: add screenshots of the app 2026-10-03 08:59:52 +02:00
drift_schemas feat: add store connections, state and a new statistic 2026-10-03 21:04:41 +02:00
lib feat: add store connections, state and a new statistic 2026-10-03 21:04:41 +02:00
linux feat: use showAboutDialog() and introduce GPL as our own license 2026-10-03 10:32:32 +02:00
test feat: add store connections, state and a new statistic 2026-10-03 21:04:41 +02:00
tool feat: make the resource pills links to their respective pages 2026-10-02 17:28:39 +02:00
.fvmrc init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
.gitignore feat: add screenshots of the app 2026-10-03 08:59:52 +02:00
.metadata init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
analysis_options.yaml init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
build.yaml init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
LICENSE feat: use showAboutDialog() and introduce GPL as our own license 2026-10-03 10:32:32 +02:00
native-app-recreation-spec.md init: 🎉 first commit for the open source version of my apps 2026-09-26 08:56:48 +02:00
pubspec.lock feat: make the resource pills links to their respective pages 2026-10-02 17:28:39 +02:00
pubspec.yaml release: v1.2.0+8 2026-10-03 21:14:29 +02:00
README.md feat: adding stores as a source of comics 2026-10-03 20:40:28 +02:00

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.

Comicstash Light

Comicstash Dark

Comicstash Android

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

  1. Change the tables in lib/data/tables.dart, raise schemaVersion in lib/data/database.dart, and add a step to onUpgrade.
  2. 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/
    
  3. Run test/data/migration_test.dart. It upgrades databases of every earlier version and checks the result.
  4. If the change affects what is synced (a new field, for example), also raise SyncEngine.schemaVersion in lib/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's versionCode and the pacman pkgrel.
  • Change the version when you cut a release, not right after one.
  • Each release is a commit tagged v<name>+<build>, and package refuses 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 SHA256SUMS file.
  • 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.

  1. Release token. Scope write:repository. It can be limited to this repository.
    secret-tool store --label="Forgejo token" service forgejo host forge.arjenwiersma.nl
    
  2. Package token. Scope write:package only, 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.sh adds it to the application menu for the current user.
  • Comicstash-<version>-x86_64.AppImage: a single file; make it executable and run it. appimagetool is downloaded to build/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 stamps updated_at with the local clock and marks the row dirty.
  • 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.dart defines 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>.json and comicstash/blobs/<sha256>.<ext>.
  • Each sync, per entity folder and in dependency order:
    1. Pull: list the folder with PROPFIND and download only the files whose ETag changed.
    2. Push: upload dirty rows with a conditional PUT: If-Match the last seen ETag, or If-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.
  • 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=ID opens 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_dav and 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 0600 file 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.