Commit graph halkyon/tool
Author SHA1 Message Date
c8d94d0ceb
fix(release): a snap built in a clone of its own is packed without --output, into the clone
All checks were successful
CI / check (push) Successful in 40m26s
CI / deploy (push) Successful in 1m4s
snapcraft builds in LXD with the project mounted at /root/project and passes --output
on unchanged: naming the clone itself, it took the clone for a file in the folder above,
outside the project, and refused it again. Without --output it packs into the clone
(checked with snapcraft 9.2.1 in Todo's clone), from where the release tool moves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 10:35:49 +01:00
34001b1aff
fix(release): a snap built in a clone of its own is packed inside it, then moved into the release's folder
All checks were successful
CI / check (push) Successful in 39m47s
CI / deploy (push) Successful in 59s
snapcraft packs nowhere outside the project it builds, so Todo 0.1.0's release stopped
after its snap was built ("Cannot create packages outside of the project tree").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 09:21:34 +01:00
ac387b5250
feat(icons): every icon fades from cobalt into its app's backdrop; Learn shows its own inside the app; Train's dumbbell (ADR 0031, D5 to D7)
All checks were successful
CI / check (push) Successful in 41m0s
CI / deploy (push) Successful in 4m37s
Each icon now ends in the colour its app's screens wash down from: Learn cobalt into
purple, Todo through violet into strawberry (Learn's former gradient), Train unchanged
into carrot, Meditate's to come through pacific into fern (Todo's former). Train's glyph
is Lucide's dumbbell, Meditate's will be the sprout.

Learn shows its graduation cap atop the rail, on the launch screen and on halkyon_app's
screens, as Todo and Train show theirs; the kingfisher leaves the app and stays the
F-Droid repository's icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 23:12:20 +01:00
3219742dbd
build(todo): Todo's Android key pinned (ADR 0033, step 5)
All checks were successful
CI / check (push) Successful in 40m36s
CI / deploy (push) Successful in 3m37s
The certificate of the key Todo's releases are signed with (alias
halkyon-todo), made in memory and kept in Proton Pass alone; the release
tool releases no APK signed otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 16:37:27 +01:00
e61d3abb4f
build(todo): Todo deployable beside Learn's server, and released as an APK, on F-Droid and as a snap (ADR 0033, step 5)
deploy/compose.todo.yaml adds Todo's server at todo.halkyon.eu, a member
of Learn's register, its database halkyon_todo in the instance's
PostgreSQL, its image built as Train's. redeploy.sh and backup.sh go over
every app server .env switches on (train, todo). Each compose file names
its server to the register in a variable of its own
(INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO), which halkyon_base adds to
INSTANCE_MEMBERS, each address once, as a second compose file's default
would have replaced Train's.

apps/todo/release.dart configures the release tool for Todo's APK and
snap: a key of its own (pinned once made; until then nothing is
released), the F-Droid listing (Time, NonFreeNet), and the snap from
apps/todo/snap/, which the tool builds in a clone of the repository at the
tag with that folder in place of snap/, as snapcraft builds one recipe a
folder and the root's is Learn's. What's new for 0.1.0 in English and
German, now shown. Android's backup and transfer are off, as Train's
(D53): the reminders scheduled keep their tasks' titles unsealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 16:04:00 +01:00
d981747728
fix(release): F-Droid's upload resumable alone, by content, and Forgejo asked again
Train's first release (31 Oct 2026) stopped twice after its tag was pushed:

- Forgejo answered 500 (a unique constraint) to the release: it enters a
  pushed tag in its releases itself, a moment after the push. Asked again a
  few seconds later, it makes the release of that entry.
- The F-Droid upload filled the halkyon.app server's disk: rsync compared by
  time, and this laptop's working folder, restored from the repository, had
  every APK anew, so all 1.9 GB went again. It compares by content now.

tool/with_signing_keys.sh fdroid [APK] runs tool/fdroid_repo.sh alone with
the index's key, for an upload that failed after its release was made, and
the script skips an APK the working folder holds already rather than adding
a second file of the same build, which fdroid refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 23:28:49 +01:00
90edf2ec84
feat(release): the Forgejo token from Proton Pass as well
All checks were successful
CI / check (push) Successful in 40m37s
CI / deploy (push) Successful in 56s
tool/with_signing_keys.sh reads the token releases publish with from its
login in the vault Infra and hands it over as HALKYON_FORGEJO_TOKEN, but
for a dry run, so a release asks for no secret at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 21:53:08 +01:00
7aa8ec7304
feat(release): the keystores' passwords from Proton Pass too, and F-Droid's tools checked first
All checks were successful
CI / check (push) Successful in 38m54s
CI / deploy (push) Successful in 56s
tool/with_signing_keys.sh reads each keystore's password from its item's
hidden field "Password" and hands it to the release tool in its
environment (<keystore variable>_PASSWORD), but for a dry run, which asks
for no secret; the release tool takes a secret from there where it is
set, else asks as before (the Forgejo token from HALKYON_FORGEJO_TOKEN).
Typing them guarded nothing the Pass session didn't already open.

The release tool checks fdroid, rsync, curl and jq with the other tools,
so a dry run says when one is missing, rather than the F-Droid script
after the passwords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 20:51:21 +01:00
50c0d28194
fix(release): the dry run goes on without snapcraft rather than crashing
All checks were successful
CI / check (push) Successful in 38m27s
CI / deploy (push) Successful in 56s
Where snapcraft is missing, the release is refused already; asking it for
its login then threw, so the dry run stopped before listing the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 19:36:35 +01:00
7727317c68
feat(app): what's new after an update, drafted by the release tool (ADR 0030, D61)
halkyon_app's WhatsNew reads what each release changed from the app's
assets/whats_new/ (<version>.<language>.txt, English and German, a change
a line): after an update a sheet lists every release since the one the
device showed last, newest first, once; Settings shows the running
version's again, above About. Nothing on a fresh install.

tool/release.dart drafts the missing texts from the commits that count
after "Go on?" (German marked TRANSLATE:), opens them in the editor until
they fit (no mark left, F-Droid's 500 characters) and commits them with
the version. The English is the Forgejo notes' What's new, each language
F-Droid's changelog of the build, written at publishing and copied in by
tool/fdroid_repo.sh. Learn and Train bundle their texts, Learn's 0.22.0
and Train's 0.1.0 written to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 19:36:20 +01:00
97642b26aa
build: the F-Droid script takes back every APK released before it updates, so no laptop's run deletes one
Some checks failed
CI / deploy (push) Has been cancelled
CI / check (push) Has been cancelled
Its upload mirrors the working folder with --delete-after, and a folder on
another laptop held none of the APKs released from the first: its run would
have published an index without them and deleted them from halkyon.app.
Each APK the published indexes (repo and archive) name now comes from the
repository first unless the folder has it, its SHA-256 checked against the
index's either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 17:05:11 +01:00
cd3b88c174
build: releases fetch their signing keys from Proton Pass into memory, never onto the disk
tool/with_signing_keys.sh downloads the app's Android keystore and the
F-Droid index's from the vault Infra · Signing into a folder under
$XDG_RUNTIME_DIR, runs tool/release.dart with their paths, and removes
the folder however the run ends. The passwords are asked for as ever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 17:05:11 +01:00
48951c1e1a
build(train): Train is released as Learn is, with a key of its own, beside it in the F-Droid repository (ADR 0030, D58)
apps/train/release.dart configures the release tool for Train's APK on
train.halkyon.eu; its release builds sign with its own key when the tool
hands it over. Its F-Droid listing declares NonFreeDep for Polar's SDK and
NonFreeNet; the repository's script copies each app's icon. The key's
certificate is pinned once the key is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 17:05:10 +01:00
f749acb9ec
build(learn): the F-Droid repository keeps the kingfisher as its icon
The repository is all of Halkyon's apps', so its icon stays Halkyon's
emblem (ADR 0031, D3), kept in tool/release/fdroid/ now that the snap's
icon is Learn's graduation cap. Learn's listing keeps the snap's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 15:52:45 +01:00
ece9ce9697
feat: the apps' icons as one family, drawn by one tool (ADR 0031)
tool/app_icons.py at the root replaces Learn's and Todo's scripts: each
app's Lucide glyph (lucide-static 1.50.0) drawn by librsvg, fading from
cobalt through the colours ADR 0031 names, then every platform's
launcher icon through flutter_launcher_icons, Windows' .ico with every
size, and Learn's snap icon.

Learn's icon becomes the graduation cap, its window's on Linux too, with
Lucide's notice in its licence list; the kingfisher stays the emblem on
the rail and the launch screen. Todo's becomes list-todo, Train's bike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 15:52:45 +01:00
87671208df
feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped
The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.

- The module signs nobody in anonymously: `anonymousIdp` and
  `gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
  `firstPassword` code) or who signs in with GitHub alone (a fresh
  sign-in and a `signUp` code); an anonymous account is refused
  `wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
  (`wrongCaller`), the member's endpoint too, so nothing turns an
  anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
  new, never one identified. Learn and Todo schedule it as
  `prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
  entry once.
- Learn: someone without `identified` keeps no files;
  `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
  up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
  development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 16:14:42 +02:00
3def5126a3
build(check): the checks compare the generated code, as CI did alone
On 23 Oct a push went red in CI because an endpoint's changed comment
hadn't been generated into the module's client: only CI compared the
committed generated code with the generator's output. tool/check.dart now
runs every server's generation (Melos' `generate`, about 15 s) after
fetching the packages and fails, naming the files, where the working
tree's generated code changed by it; the files stay written anew, to be
committed. It compares the files that differ from the committed ones
before and after by their content, so uncommitted changes count as they
do everywhere else in the checks.

It refuses a Serverpod CLI at another version than the lock holds
Serverpod at (`dart install serverpod_cli@<version>`), and the root's
tests hold CI's SERVERPOD_VERSION to the lock. CI keeps installing the
CLI at that version and puts it on the job's PATH; its own comparison
step goes, as the checks run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00
87ae5fca89
chore(release): tool/release.sh goes, and the snap goes to the Store's edge
Build 21 (v0.18.0) was the release tool/release.sh was kept for, the one
going with ADR 0014's breaking deploy; every release since is
`dart run tool/release.dart learn`. The comments that named the script
(the F-Droid script, the certificate pin, the Gradle signing) now name
the Dart tool.

Learn's release uploads the snap to the Snap Store's edge channel last,
where v0.18.0 went by hand; the release refuses to start unless
`snapcraft whoami` shows a login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00
bc499713b8
feat(crypto): SRP-6a, the register's half and the device's
ADR 0017's step 1. Generic over group and hash (SrpGroup); production
takes RFC 5054's 3072-bit group with SHA-256. k = H(N | PAD(g)),
u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K),
M2 = H(PAD(A) | M1 | K). A and B must lie in 1..N-1 and u must not be
0; a and b are 256 bits from Random.secure(); proofs are compared in
constant time. x comes from the caller (halkyon_keys makes it); RFC
5054's own x is there for the vectors only.

- The register's half in halkyon_base_server (base.dart: srpChallenge,
  srpVerify), the device's written by hand in halkyon_base_client
  (srp.dart: SrpClient, srpVerifier). Serverpod's generator cleans and
  writes only a client's lib/src/protocol/ (serverpod_cli 4.0.4's
  _getDirectoriesRequiringCleaning; a generate leaves srp/ untouched).
- Tests: RFC 5054 Appendix B on both halves; the client against the
  register, succeeding and every refusal.
- The module's tests depend on its client, and pub resolves every
  member's dev dependencies, so Learn's server image takes the module's
  client into its workspace too.
- The format check covers the hand-written half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:08 +02:00
bf20134819
fix(tool): the check limited to changes sees uncommitted ones too
Melos' --diff compares commits alone (<commit>...HEAD), so a check with
--changed-since before committing tested nothing and passed. The check
now works out the packages touched itself, from git diff against the
commit and the files new to git, each file counted in its innermost
package, and hands them to Melos as --scope with --include-dependents;
a changed root pubspec, lock or lint rule means every package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:05 +02:00
e65f61bdd1
build(release): a Dart release tool for every app
tool/release.dart releases one app: `dart run tool/release.dart learn
[major | minor | patch] [--dry-run]`, each app configured in Dart beside
it (apps/learn/release.dart). Every step and check of tool/release.sh
is kept: the repository as pushed, resuming after a release commit and
tag, Flutter as CI pins it, the tools, the key and its certificate pin,
secrets asked for without echo and never written down, CI green on the
newest commit, the signed release commit and tag by a key deploys
accept, the APK built from a checkout of the tag and checked, the snap,
the signed checksums, pushing only after both builds passed, the
release on Forgejo, the F-Droid repository, the Snap Store once a
channel is set.

New: tags per app (learn-v0.19.0, Learn's v… counting as its
history); only commits touching the app decide its version (its folder,
the shared packages it uses by path, the root pubspec and lock, its
release files, where its packages were before the move); --dry-run
reads all of it, lists every step and the notes, and what a release
would refuse, changing nothing. The pure parts are tested in
test/release/. Learn releases build 21 with tool/release.sh, then
switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:05 +02:00
af1dbee509
build: Melos, a Dart check and every path for the single repository
- Melos 8.9 as the root's dev dependency, with scripts across the
  workspace (analyze, test:dart, test:flutter, generate, check) and its
  filters (--diff, --include-dependents); cli_util overridden to 0.5.2
  for it, since Learn's flutter_launcher_icons asks for 0.4 and uses only
  its logger, unchanged in 0.5.
- tool/check.dart in place of tool/check.sh: packages, formatting of our
  own files, analysis, the tools' tests, then every package's tests
  through Melos (--changed-since=<commit> for only what changed and what
  depends on it). tool/pre-push is the hook: the signature check, then
  the checks. CI generates every server's code and runs the same check.
- The images build from the root with a workspace narrowed to what they
  need (tool/workspace_only.sh); compose, the deploy's migration test,
  the snap, the release script, the editor's launch files and Learn's
  asset tools (run from apps/learn) follow the new layout.
- The project's source is named as project-halkyon/halkyon (source link,
  snap, F-Droid listing, release uploads); the AGPL packages carry the
  licence's own text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:04 +02:00
649499be23
chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:02 +02:00
19347ef4fc
docs(store): say studysets are the learners' own and instances anyone's
All checks were successful
CI / check (push) Successful in 20m48s
CI / deploy (push) Has been skipped
The Snap Store and F-Droid listings now say what the demo video says:
every studyset is made by its learners (build one for any subject, or
copy one someone published), the app connects to any instance, the
repository holds everything to run one, and all the code is public
(app AGPL-3.0, server SSPL-1.0). Signing in later keeps everything with
GitHub or an email address, now that both are ways in.

The F-Droid listing takes the snap's description again, as its header
says; it had kept the text from before v0.17.0. AGENTS.md no longer
says it declares TetheredNet, which it dropped once the app could
choose its instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 23:09:23 +02:00
971af90ba0
fix(tool): the checks fetch the packages first
All checks were successful
CI / check (push) Successful in 21m0s
CI / deploy (push) Has been skipped
After a merge or a switch of branch the workspace's resolution could
still be the one from before, and the pre-push hook's analysis failed on
packages it couldn't find (v0.13.0's push, and the stack's on 17 Oct).
The checks now fetch what the checked-out pubspecs and lock name before
anything else, with --enforce-lockfile, so a check never changes the
lock. About 3 s when nothing is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 20:49:50 +02:00
496c844989
fix(tool): the checks run as a hook from a worktree too
All checks were successful
CI / check (push) Successful in 20m15s
CI / deploy (push) Has been skipped
Pushing from a worktree failed before any test ran: "Because
halkyon_learn_flutter requires the Flutter SDK, version solving
failed." Git hands a hook in a worktree an absolute GIT_DIR, which
overrides the -C that pub runs git with on its git dependencies, so pub
judged the workspace's resolution stale and resolved it with dart alone.
The checks now clear git's variables first; git finds the repository
from the folder it runs in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 19:46:41 +02:00
28d22f3e94
chore: the SSH aliases and CI's runner label keep the servers' names
Some checks failed
CI / check (push) Successful in 15m47s
CI / deploy (push) Failing after 1m9s
The servers' names are no secret, so the settings that carry them stay
as they were, and nothing on a server or a laptop needs changing: CI
runs on `sahaquiel` again, the release uploads to `ramiel-fdroid`, and
deploys by hand go through `sahaquiel-deploy`. Prose still names a
server by the domain it serves, which says what a reader needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 00:33:51 +02:00
93b0c0a6b0
chore: the servers are named by the domain they serve
Files called the servers by their own host names, which are the
operator's and say nothing a reader needs. They now say which domain a
server serves: learn.halkyon.eu's server (the instance and its CI
runner), the halkyon.app server (Forgejo and the F-Droid repository).

Two names are settings rather than words. CI's runner label becomes
`instance` (`runs-on: instance`, `labels: ["instance:host"]`): the
runner reads its installed copy of deploy/runner/config.yml, so jobs
wait in the queue until that copy is updated and the runner restarted.
The SSH aliases the scripts and docs use become `learn-deploy` and
`fdroid-upload`. AGENTS.md says to keep it so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 00:33:50 +02:00
e5e0de62b3
feat(map): every country on earth, picked continent first
All checks were successful
CI / check (push) Successful in 14m52s
CI / deploy (push) Successful in 19s
Map questions can now be on the world's countries besides Japan's
prefectures: a new question chooses its map above it.

The world is Natural Earth's 1:50m countries (public domain), as they are
on the ground, one region per ISO 3166-1 code (239; Gibraltar and the US
Outlying Islands from the 1:10m layer, which has them), named in every
language a studyset can be in from the Unicode CLDR (Unicode License v3,
its notice in the licence list). tool/maps/world_countries.py builds it;
the outline code it shares with the Japan script moved to outlines.py,
which still writes Japan's file byte for byte. The 1.8 MB file is parsed
off the screen's isolate.

Picking goes in layers on both maps: on the whole map one taps a group
(a continent, or Kantō, Kansai …; chips name them too) and the map flies
there, then one taps the region; Back returns. A group of one (Hokkaidō,
Okinawa) and what is in none (Antarctica) are picked at once. A country
in two groups (Russia, Türkiye, Cyprus) opens the one nearer the tap.
Oceania's frame runs past 180° so the Pacific stays in one piece.

What is still too small to hit gets a ring, 48 dp of reach as Material's
touch target: on the ring it is picked; near it, where the tap could mean
it or its neighbours, the map zooms in rather than guess. Pinching and a
mouse's wheel zoom as before.

Names show in the app's language, beside them the map's own (Japanese for
Japan) or the studyset's (キプロス beside Cyprus in a Japanese studyset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-12 07:51:15 +02:00
7621afaa98
feat(instances): choose the instance, and keep accounts on several
All checks were successful
CI / check (push) Successful in 13m50s
CI / deploy (push) Successful in 1m25s
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.

As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.

The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.

The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 09:46:50 +02:00
89a3784df4
chore: the website is halkyon.app/learn
Halkyon's website is one site for all its apps, as Proton's is, so
Halkyon Learn's page is halkyon.app/learn rather than a site of its own
at learn.halkyon.app, which redirects there. The snap's and the F-Droid
repository's listings point to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 09:46:49 +02:00
3bbdb9d747
chore: the pre-push hook refuses unsigned commits for main, as the deploy does
All checks were successful
CI / check (push) Successful in 11m56s
CI / deploy (push) Successful in 19s
deploy/redeploy.sh deploys main only if every new commit is signed by a key
in deploy/allowed-signers, and finds out after CI. On 2026-10-09 that held
learn.halkyon.eu at v0.2.2 for two days: 37 commits had reached main
unsigned, and every deploy since refused them. tool/check_signatures.sh
applies the same rule to a push, to the commits it brings to main that the
remote doesn't have yet (the list as the pushed commit has it), and
tool/check.sh runs it first when it runs as the pre-push hook: a second,
where the rest takes minutes. CI runs check.sh under its own name and
skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 19:14:15 +02:00
dd36424917
feat(app): Anki's bracket furigana becomes readings where its templates show them
A field a note type shows through Anki's furigana filters
({{furigana:Reading}}, kanji:, kana:) holds 漢字[かんじ]; the import now
turns it into readings over the text before the brackets, as Anki's own
filters read it (back to a space, which goes). Other fields stay as
written; a sound stays a sound. The note types' templates and field
names are read from both package formats (Anki's unicase collation
registered so their tables can be read at all). New fixtures exported
by Anki itself (tool/anki/make_fixtures.py) test both formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 18:09:30 +02:00
a875a91f40
feat(curator): turn JMdict and the JLPT lists into dictionary files
A package of its own in the workspace, without Serverpod (AGPL, like the
tooling): an adapter per source writes one format for all, documented in
its README, which the server will load. The format is JSON Lines: a
header (source, version, languages, licence, credits), then entries with
a headword and reading, labels (`common`, `jlpt:n5`), a rank, the keys a
search matches, and the entry as cards take it. Everything specific to a
language happens in its adapter (Japanese: forms, readings in kana and
Hepburn romaji, long vowels typed once, meanings without "to" and
articles), so the server's search knows no language.

JMdict comes as jmdict-simplified's JSON, read one entry at a time: all
218,863 entries in some 20 seconds and 300 MB, 16.8 MB gzipped. The JLPT
lists (yomitan-jlpt-vocab, CC BY-SA 4.0, from Waller's CC BY lists) add
levels by JMdict entry, the easiest when a word is on several, and give
the headword its common spelling. Words rank N5 to N1, then the other
common words, then the rest.

The server's image leaves the package out, as it does the client's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 18:09:25 +02:00
e291d3af7a
fix(app): the app has its own icon instead of Flutter's
All checks were successful
CI / check (push) Successful in 5s
CI / deploy (push) Successful in 1m3s
The launcher showed Flutter's logo: the project template's icons were
never replaced. tool/app_icons.py now makes every icon from the
kingfisher (assets/icon/halkyon_icon.png), through flutter_launcher_icons
v0.14.4 configured in the app's pubspec: Android's adaptive icon (the
kingfisher inset 16 % on white, for each launcher to cut to its shape)
and the square one before Android 8; iOS's, without transparency;
macOS's, the rounded tile on Apple's grid (824 of 1024 pixels); and
Windows', the rounded tile, its .ico with every size from 16 to 256
pixels (the tool writes only 256). It keeps the iOS project file as it
was, which the tool would break (flutter_launcher_icons issue 634), and
writes the snap's icon as tool/snap_icon.py did, which it replaces.

The Linux runner gives its window the bundled icon too, for desktops
that don't take it from the desktop file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-06 22:49:25 +02:00
a1e11068e1
build: the F-Droid repository shows the app's icon
All checks were successful
CI / check (push) Successful in 5s
CI / deploy (push) Successful in 15s
fdroid finds a launcher icon only under res/<folder>/<name>.png, and
release APKs shorten the resources' paths (res/9w.png; fdroidserver
issue 885), so the app showed no icon in the F-Droid app.
tool/fdroid_repo.sh now gives it the snap's icon in the metadata
(metadata/app.halkyon.learn/en-US/icon.png), as the repository's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-06 14:22:24 +02:00
b60752feff
fix: the release script checks its token on the repository
All checks were successful
CI / check (push) Successful in 9m36s
CI / deploy (push) Successful in 15s
It asked Forgejo for the token's user, which needs the scope read:user;
the token the script asks for only has write:repository, so the check
would have refused a token that works. It now reads the repository
itself, which that scope covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 21:47:03 +02:00
4d3d4d9592
feat: releases work out their version from the commits
All checks were successful
CI / check (push) Successful in 9m17s
CI / deploy (push) Successful in 14s
tool/release.sh no longer takes a version: it works it out from the
commits since the last release, following semantic versioning (feat
raises the minor number, fix or perf the patch number, a breaking change
the major number from 1.0 on and the minor one before; other types
release nothing), or takes major, minor or patch as chosen. It shows the
version and its commits and asks first, checks that CI is green on the
newest commit with code, writes the version and a raised build number
into the pubspec in a signed release commit with a signed tag, and
pushes only once the APK and the snap passed their checks. The release
notes list the features and fixes. A run that stopped after its release
commit and tag carries on with them.

Channels for now: the release on code.halkyon.app (APK, snap, checksums)
and the F-Droid repository; the Snap Store upload is off until
snap_store_channel names a channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 21:22:50 +02:00
d149d4a5e3
feat: the F-Droid repository moves to halkyon.app
All checks were successful
CI / check (push) Successful in 9m23s
CI / deploy (push) Successful in 15s
The repository is Halkyon's, for all of its apps, so its address is the
brand's: https://halkyon.app/fdroid/repo. learn.halkyon.app stays Halkyon
Learn's own address, for its website later. Moved before anyone but the
maintainer had added it; the address is permanent once people have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 20:41:54 +02:00
ae2496335d
feat: an F-Droid repository at learn.halkyon.app
All checks were successful
CI / check (push) Successful in 9m16s
CI / deploy (push) Has been skipped
Releases also publish the APK in the project's own F-Droid repository,
https://learn.halkyon.app/fdroid/repo. tool/fdroid_repo.sh adds the APK,
signs the indexes with the repository's own key and uploads them to
ramiel as the user fdroid, whose key can only write into the folder
Caddy serves (rrsync), the APKs first and the indexes last.
tool/release.sh checks that key and its password before building and
publishes after the Forgejo release.

- tool/release/fdroid/ holds the repository's settings (Halkyon, the
  snap's icon) and the app's listing (the snap's text), with the
  anti-features TetheredNet (learn.halkyon.eu only, for now) and
  NonFreeNet (YouTube, the server's SSPL) declared with their reasons,
  in F-Droid's own definitions and with its icons (fdroiddata, AGPL v3).
- The index key's certificate is pinned: a new key would make the F-Droid
  app refuse the repository.
- fdroidserver v2.4.5, since Ubuntu's v2.2.1 knows neither TetheredNet
  nor reasons.

README and AGENTS.md describe it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 20:25:02 +02:00
6da776e6ba
feat: the Linux app as a Snap
All checks were successful
CI / check (push) Successful in 9m31s
CI / deploy (push) Successful in 11s
snap/snapcraft.yaml builds the Linux app as a strictly confined Snap on
core24 with the GNOME extension (GTK, WebKitGTK, GStreamer and libsecret
come from its runtime; files and the browser go through the desktop's
portals), connecting to learn.halkyon.eu, the project's instance. It
builds what is committed, with Flutter pinned as in CI.

- The session: libsecret's keyring over D-Bus needs the
  password-manager-service plug, which the Store never connects by
  itself, so unless it is connected the app keeps the session in an
  encrypted file whose key comes from the Secret portal
  (snap/local/secret-backend).
- network-manager-observe lets Serverpod hear that the network went and
  end the sync's connection at once; until the Store grants it, it too
  is connected by hand.
- The listing points to the project, not the instance: the website
  learn.halkyon.app, contact and issues support@halkyon.app.
- The icon is the app's with rounded corners (tool/snap_icon.py), for
  the Store and the dock alike.
- The app's version is 0.1.0, build 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 14:27:54 +02:00
88b82cda4b
feat: a release script for the APK and the snap
All checks were successful
CI / check (push) Successful in 9m8s
CI / deploy (push) Successful in 1m5s
tool/release.sh builds a release on the maintainer's laptop: one APK for
every phone, from a checkout of the signed tag and signed with the app's
own key, and the snap, both connecting to learn.halkyon.eu, the project's
instance; their checksums signed with GPG; the release on code.halkyon.app
through Forgejo's API, and the snap in the Store's edge channel.

Before building it checks that main is clean and pushed, that the tag is
signed by a key deploys accept, the version and a raised build number,
the pinned Flutter, and that the keystore holds the key whose certificate
tool/release/android-certificate.sha256 names. After building it checks
the APK's signer, package, version, that it isn't debuggable and that it
names the server, and the snap's version. The key's password and the
Forgejo token are asked for at each run and never stored.

Release builds take the app's key from the environment and otherwise fall
back to the debug key, so `flutter run --release` works anywhere. The
native libraries go into the APK compressed: an APK for three
architectures was 110 MB with them stored as they are, 48 MB now. The
list of dependencies Gradle adds for Google Play is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-05 13:00:39 +02:00
079179d2ed
Fix what the first Android run found
All checks were successful
CI / check (push) Successful in 6m44s
CI / deploy (push) Successful in 59s
The flow ran on a Pixel 9a and its emulator (2026-10-02); what it found:

- The composer stayed on the card just written after "New flashcard", and
  on the original after "Duplicate": leaving a card changed the state
  before the new one was in it, so the screen took the new card for gone.
  It now waits for the card it asked for.
- Bottom sheets reached under the status bar with the keyboard open
  (useSafeArea).
- The field being written stayed behind the keyboard: the editor scrolls
  only within itself, so the card face now keeps it in view when it opens
  and when the keyboard rises, as a text field does.
- A tap on read-only rich text put a caret in it and opened the keyboard on
  phones, and in the composer never opened the field. RichContentView is
  display only now: no selection, taps go to what surrounds it, a sound's
  button still plays (decided 2026-10-02).
- The card types broke their names on a phone; they are choice chips that
  wrap (decided 2026-10-02).
- The week overview marked days before the person's first activity as
  missed; they show nothing now. An import starts the activity too, without
  counting as studying (decided 2026-10-02).
- A composer preview cut short gave no sign of it; it fades out over its
  last 32 dp, only when cut short (decided 2026-10-02).
- "Isn't on this device yet" showed for any image or sound that failed;
  a file that is here but can't be decoded now says so, and offers no retry.
- The signed-in account card was narrower than the anonymous one.
- The composer's title said "Create flashcards" also when opened on a
  stored card; it says "Edit flashcards in …" then (decided 2026-10-02).
- The Anki fixtures carry a valid PNG and a silent MP3, so they also serve
  to try the import by hand (decided 2026-10-02); regenerated with Anki
  26.9.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 19:52:39 +02:00
a6d5769a2e
Import Anki decks with their files and history
Files in content: the person's images and sounds as asset rows synced like
all content, their files kept by hash on each device, uploaded after each
sync, downloaded on demand and checked against their hash
(core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in
the editor's image block and an audio block of ours; any other URL is never
fetched. Sounds play through audioplayers, one at a time. Content holding
only an image or a sound isn't blank, and lists show it as such.

The Anki import ("Import from Anki" under Add material): .apkg and .colpkg
packages in all three formats Anki has written, read on an isolate of their
own. Modern packages compress everything with zstd in frames that don't
record their size, which pure_zstd v1.0.0 refuses; our fork decodes them.
Fields become rich text by a converter of our own (lines, lists, formatting,
images, sounds, cloze deletions hidden on the front). One card per note,
the files first, studysets as chosen (flatten, sub-studysets, or tags by
subdeck), the notes' tags, and, if wanted, the review history, which FSRS
replays: the first template's reviews to the card's schedule, a reversed
note type's second to the reverse one.

Imported cards say where they came from in their first revision (card
schema 3); the dashboard doesn't count them as written. The test fixtures
are exported by Anki itself (tool/anki/make_fixtures.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 05:11:50 +02:00
5a5b2feead
feat(app): map questions on Japan's prefectures
A map question is a multiple-choice card drawn on a map: the composer's
Map kind takes the region tapped as its answer, and studying it means
tapping a region and checking, the right one green and a wrong pick red,
each named. The map of Japan's 47 prefectures comes from Natural Earth
(public domain), built by tool/maps/japan_prefectures.py from release
v5.1.2 (its download checked by hash): simplified, small islands
dropped, rounded; about 107 KB. Maps are drawn to fit, tappable and
zoomable for small regions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 21:10:07 +02:00
c21ce6232b
feat(app): the UI foundation: theme, tabs and routes, strings
The app gets its look and navigation before more features land on them.

- Theme (core/theme/): hand-written light and dark Material 3 colour schemes around
  cobalt and copper; ThemeExtras for the colours Material has no role for (states, the
  four FSRS ratings); the 20 colours a person can give content. Tests hold text to WCAG
  AA contrast on each palette colour and on the dark theme's primary colour and surface.
- Layout and shared widgets: Material 3's window-size classes (core/layout/);
  SectionCard, AppLoadingIndicator, confirmDestructiveAction and FoundById
  (core/widget/). Lucide icons.
- Navigation with go_router: four tabs (Home, Library, Explore, Profile), each keeping
  its own stack of screens, as a navigation rail with the app's icon from 600 dp and as
  a navigation bar below; choosing the open tab again returns to its first screen. A
  studyset's screen sits in the library; studying, cards and settings cover the tabs.
- Routes carry ids: FoundById finds the item in the repository's live list, so a
  studyset deleted on another device, or an old link, says it no longer exists. A
  screen that deletes its own item keeps showing it while it closes, rather than
  flashing that message.
- Every person gets an app of their own (PersonApp, lib/src/app/): a
  MaterialApp.router with its own GoRouter and PersonScope around the navigator, built
  by SessionGate inside the person's keyed subtree. Screens read the person's
  repositories wherever they are built, so the static route() helpers and the listener
  that closed every pushed screen on a change of person are gone: a change of person
  now disposes the whole router and starts again at home.
- The account dialog becomes the Profile tab, with Settings (sign out, delete the
  account, About) behind a gear. A waiting GitHub sign-in now keeps waiting while
  another tab shows; a change of person still cancels it.
- The new screens' strings come from Flutter's gen-l10n (English for now). The
  generated files are committed, left out of analysis and formatting, and written
  anew by `flutter pub get`, so CI's check of generated code also catches stale ones.

Widget tests now start the whole app (test/support/pump_app.dart) at the screen's path
and move through it as a person would, by tabs and taps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 09:59:44 +02:00
ce2cd42ffe
ci: check every push on sahaquiel and deploy a green main
Some checks failed
CI / check (push) Failing after 3m22s
CI / deploy (push) Has been skipped
Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every
push, and a green main deploys learn.halkyon.eu by itself.

The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`,
with jobs directly on the host: containers would take Docker access, which is root on the
production server, and Forgejo warns that host jobs have no isolation from the runner's
user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and
the Docker socket invisible, Docker's container networks unreachable, no capabilities,
two cores and 3 GB at most, lowest priority. It is registered to this repository only,
with the UUID and token of Forgejo's "Create new runner" (the old `register` is
deprecated); the token reaches it as a systemd credential. The configuration
(deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in
`systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run
its code on the production server.

The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys
pins to that script and to connections from sahaquiel itself. The script deploys the
commit CI checked while it is still main's tip, as a fast-forward, only if every new
commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists
them), after dumping the database (the last ten are kept), and fails unless the server
answers afterwards; a failure puts the checkout back. Tested against the local stack:
refusals of a malformed argument, a stale commit, a forged unsigned commit and a
non-fast-forward, a real deploy with its dump, a repeat, and a failed build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
ba55d6f725
test(app): studysets synced against a running server
The first tests that need serverpod start running (ADR 0001, rule 9: the client's and the server's sync engines can't share one process), tagged requires-server: tool/check.sh skips them, flutter test -t requires-server runs them. Every device is its own client and local database with the app's real gateway and repository, and syncs only when a test says so.

They state: two devices of one person end up with the same studysets; a deleted studyset leaves the other devices, and its payload is blank on the server, checked with SQL against the development database because the engine keeps deleted rows; a studyset written into another person's space never reaches them; claiming another person's space shows none of their studysets, since the server sends only the spaces the signed-in person has access to. As written here, the forgery test also expects the server to refuse and log the write, and the SQL checks connect over TCP; the first run against the server corrected both (see the commit "the sync tests pass against the running server").

They live in the app package: the server's Docker build resolves its dependencies with only the server package present, so a dev dependency on the client package would break it. They never initialize a Flutter test binding, which would replace real HTTP with a mock. Without a server they fail at sign-in with "connection refused".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 20:38:15 +02:00
518939ee84
chore(check): run the app's tests before every push
Now that the app has real tests, tool/check.sh runs them after the server tests, and AGENTS.md's checklist says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 20:38:13 +02:00
41e968c53e
chore(lint): LeanCode's strict rules and a pre-push check
Some checks failed
Analyze / analyze (push) Has been cancelled
Format / format (push) Has been cancelled
Tests / test (push) Has been cancelled
The workspace root's analysis_options.yaml includes leancode_lint 27 and enables its analyzer plugin (rules for blocs, Equatable and Flutter layout); the server and the app include that file, while the generated client keeps its own options. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include the root file, which was verified before settling on this layout. Most rules are warnings, casts, inference and raw types are strict, and the check treats infos as fatal too. lints and flutter_lints are no longer needed.

tool/check.sh checks formatting, runs dart analyze --fatal-infos and the server tests; .git/hooks/pre-push links to it, so every push has to pass it. AGENTS.md describes the checklist and how to install the hook in a fresh clone.

Fixes for the new rules: package imports, run() returns Future<void>, the server setup as one cascade, a primary constructor for the app widget, a const and a redundant argument. The workspace root keeps its conventional name `_` with a documented ignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-29 23:06:29 +02:00