build(train): Train on the desktop as a snap, to the Store's edge channel with each release (ADR 0030, D62)

Built as Todo's in a clone of its own, from apps/train/snap/, for train.halkyon.eu; its
listing says the desktop shows, plans and keeps the statistics, and records nothing.
The snap tests now hold every snap built in a clone of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 11:48:19 +01:00
commit 551a29445f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 231 additions and 53 deletions

View file

@ -15,7 +15,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`. - `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`. - `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.
- `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first. - `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first.
- **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files join Learn's (Train's `compose.train.yaml`, Todo's `compose.todo.yaml`, each switched on in `.env`). **`snap/`**: Learn's snap, built from the root, which its build needs whole; Todo's is `apps/todo/snap/`, which the release tool builds in a clone of the repository with it in place of `snap/` (Todo's `AGENTS.md`, *Releases*). **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below). - **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files join Learn's (Train's `compose.train.yaml`, Todo's `compose.todo.yaml`, each switched on in `.env`). **`snap/`**: Learn's snap, built from the root, which its build needs whole; Todo's and Train's are `apps/todo/snap/` and `apps/train/snap/`, which the release tool builds each in a clone of the repository with it in place of `snap/` (Todo's `AGENTS.md`, *Releases*). **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below).
- **One workspace, one lock**: the root `pubspec.yaml` lists every package, `pubspec.lock` holds one version of each dependency for all of them, and its `dependency_overrides` hold for every app (the offline sync fork, Learn's `AGENTS.md`; `cli_util`, below). **Serverpod's version moves as one**: every package pins Serverpod's packages to the same version (4.0.4), the module's, Todo's and Train's too, together with the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`) and the sync packages Learn's server depends on. - **One workspace, one lock**: the root `pubspec.yaml` lists every package, `pubspec.lock` holds one version of each dependency for all of them, and its `dependency_overrides` hold for every app (the offline sync fork, Learn's `AGENTS.md`; `cli_util`, below). **Serverpod's version moves as one**: every package pins Serverpod's packages to the same version (4.0.4), the module's, Todo's and Train's too, together with the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`) and the sync packages Learn's server depends on.
## How work is done here ## How work is done here

File diff suppressed because one or more lines are too long

View file

@ -32,4 +32,13 @@ const trainRelease = AppRelease(
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256', indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo', repositoryUrl: 'https://halkyon.app/fdroid/repo',
), ),
// The desktop's Train (ADR 0030, D62), built in a clone of its own as
// Todo's (`SnapRelease.projectPath`), the root's snap/ being Learn's; to the
// Store's edge channel, its name registered on 3 Oct 2026.
snap: SnapRelease(
name: 'halkyon-train',
part: 'halkyon-train',
directory: 'apps/train/snap',
storeChannel: 'edge',
),
); );

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Halkyon Train
Comment=Your workouts, routes and training plans, sealed and synced
Exec=halkyon-train
Icon=${SNAP}/meta/gui/halkyon-train.png
Terminal=false
Categories=Utility;
StartupWMClass=app.halkyon.train

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,144 @@
# Halkyon Train's Linux app as a Snap (decided 3 Nov 2026, ADR 0030, D62), built in LXD
# by the release tool (`dart run tool/release.dart train`) as Todo's is: snapcraft
# builds one recipe a folder, from its snap/snapcraft.yaml, and the root's snap/ is
# Learn's, so the tool builds this one in a clone of the repository at the release's
# tag with this folder in place of the clone's snap/ (`SnapRelease.projectPath`), but
# for snap/local/, which the recipes share. By hand, the same: in such a clone,
# `snapcraft pack`.
#
# It builds what is committed (the source is the clone's HEAD, the tag), never a
# working tree with its build caches, and always for train.halkyon.eu. Flutter is
# pinned like the local and CI one (FLUTTER_VERSION in .forgejo/workflows/ci.yaml),
# spelled out as in Learn's recipe, which says why.
name: halkyon-train
title: Halkyon Train
base: core24
adopt-info: halkyon-train # the version, from the app's pubspec.yaml
summary: Your workouts, routes and training plans, sealed and synced
description: |
Halkyon Train on the desktop: your runs, walks, hikes, rides and rows with
their maps, splits, heart rate and power, the routes you plan on the map,
turn by turn, your strength plans with the equipment at each place and the
plates to load, and the statistics of your weeks and months.
Recording, the Polar Loop and Health Connect are the phone's (Halkyon Train
for Android); what the phone records shows here, synced.
Everything is sealed on your device with your account's keys before it
reaches the instance, which can't read it. You sign in with SimpleLogin, by
Proton, or with an email address, a password and an authenticator app. It
connects to train.halkyon.eu, the project's instance, or to any other:
everything an instance needs is in the repository, to run your own. The app
is AGPL-3.0 and the server SSPL-1.0, so all of its code is public.
Questions and reports: support@halkyon.app
license: AGPL-3.0-or-later
source-code: https://code.halkyon.app/project-halkyon/halkyon
# The project's, not an instance's: the app works with any instance.
website: https://halkyon.app/train
# No contact or issues, as Learn's (its recipe says why): the address is in the
# description.
# The app's icon with rounded corners, made by the repository's tool/app_icons.py; also
# the desktop file's.
icon: snap/gui/halkyon-train.png
grade: stable
confinement: strict
platforms:
amd64:
apps:
halkyon-train:
command: bin/halkyon_train_flutter
# How the session is kept: see snap/local/secret-backend (Learn's, kept in the
# clone).
command-chain: [bin/secret-backend]
# GTK, WebKitGTK (the sign-in's page) and libsecret come from the GNOME runtime;
# files (GPX, TCX and FIT in and out) and the browser go through the desktop's
# portals.
extensions: [gnome]
plugs:
- network
# SimpleLogin's sign-in comes back on a loopback address the app listens on.
- network-bind
# Never connected by itself; whoever connects it by hand gets the keyring.
- password-manager-service
# As Learn's: the sync's connection ended at once when the network goes;
# not connected until the Store grants it.
- network-manager-observe
parts:
flutter:
plugin: nil
source: https://github.com/flutter/flutter.git
source-tag: '3.47.8'
source-depth: 1
build-packages:
- clang
- cmake
- curl
- git
- libgtk-3-dev
- lld
- llvm-18
- ninja-build
- pkg-config
- unzip
- xz-utils
- zip
override-build: |
mkdir -p "$CRAFT_PART_INSTALL/usr/libexec" "$CRAFT_PART_INSTALL/usr/bin"
cp -r "$CRAFT_PART_SRC" "$CRAFT_PART_INSTALL/usr/libexec/flutter"
ln -s ../libexec/flutter/bin/flutter "$CRAFT_PART_INSTALL/usr/bin/flutter"
ln -s ../libexec/flutter/bin/dart "$CRAFT_PART_INSTALL/usr/bin/dart"
"$CRAFT_PART_INSTALL/usr/bin/flutter" --disable-analytics
"$CRAFT_PART_INSTALL/usr/bin/flutter" precache --linux
# Needed to build, not in the Snap.
override-prime: ''
halkyon-train:
after: [flutter]
plugin: nil
source: .
source-type: git
# sodium's build hook compiles libsodium with its configure script and make
# (ADR 0017), with the Flutter part's clang.
build-packages:
- make
override-build: |
set -eu
export PATH="$CRAFT_STAGE/usr/bin:$PATH"
cd apps/train/halkyon_train_flutter
craftctl set version="$(sed -n 's/^version: *\([^+]*\).*/\1/p' pubspec.yaml)"
flutter pub get --enforce-lockfile
flutter build linux --release \
--dart-define=SERVER_URL=https://train.halkyon.eu/
mkdir -p "$CRAFT_PART_INSTALL/bin"
cp -r build/linux/x64/release/bundle/. "$CRAFT_PART_INSTALL/bin/"
secret-backend:
plugin: dump
source: snap/local
organize:
secret-backend: bin/secret-backend
# As Learn's: every file the GNOME runtime or the base has at the same path
# goes, so that nothing staged hides the runtime's own (Learn's recipe says
# what that broke there). Train stages no package today; the part keeps a
# later one from doing so unnoticed.
cleanup:
after: [halkyon-train, secret-backend]
plugin: nil
build-snaps: [core24, gnome-46-2404]
override-prime: |
set -eu
for snap in core24 gnome-46-2404; do
cd "/snap/$snap/current/usr"
find . \( -type f -o -type l \) -print0 |
while IFS= read -r -d '' path; do
case "$path" in */blas/* | */lapack/*) continue ;; esac
target="$CRAFT_PRIME/usr/$path"
if [ -L "$target" ] || [ -f "$target" ]; then
rm -f "$target"
fi
done
done

View file

@ -1571,6 +1571,14 @@ Strava. What was found, 27 Oct:
folder), newest first; over the newest alone. Nothing on a fresh install; a device folder), newest first; over the newest alone. Nothing on a fresh install; a device
used before (an instance chosen) is shown the running version's on its first start used before (an instance chosen) is shown the running version's on its first start
with this. In the app's language, else English. with this. In the app's language, else English.
- **D62 Train on the desktop as a snap** (the developer's, 3 Nov 2026, on the agent's
recommendation): to the Snap Store's edge channel with each release, as Learn's and
Todo's, built as Todo's in a clone of its own (`apps/train/snap/`, Todo's ADR 0033,
step 5), for train.halkyon.eu. D58 had released the APK alone, before the release
tool could build a snap from an app's own folder. The desktop records nothing and
connects to no device (*Consequences*); it shows the workouts with their maps, plans
routes and strength sessions, and keeps the statistics, with what the phones
recorded coming through the server. Its listing says so.
## Consequences ## Consequences

View file

@ -92,7 +92,6 @@ void main() {
reason: path, reason: path,
); );
} }
expect(trainRelease.snap, isNull);
expect( expect(
trainRelease.android.certificateFile, trainRelease.android.certificateFile,
isNot(learnRelease.android.certificateFile), isNot(learnRelease.android.certificateFile),
@ -153,68 +152,76 @@ void main() {
); );
}); });
group("Todo's snap, built in a clone of its own with its folder in place of " test(
"the root's snap/", () { "Learn's snap is the root's; Todo's and Train's are built in clones of "
final snap = todoRelease.snap!; 'their own, outside the repository, and packed without --output, as '
final recipe = File('${snap.directory}/snapcraft.yaml').readAsStringSync(); "snapcraft takes the clone's own path for a file outside the project",
() {
test("is not the root's, and is built outside the repository", () {
expect(learnRelease.snap!.atRoot, isTrue); expect(learnRelease.snap!.atRoot, isTrue);
expect(snap.atRoot, isFalse);
expect(
snap.projectPath('/home/someone'),
'/home/someone/.cache/halkyon-release/snap/halkyon-todo',
);
});
test('is packed without --output, into its clone, as snapcraft takes the '
"clone's own path for a file outside the project; Learn's into the "
"release's folder", () {
expect(snap.packOutput(root: '/repo', out: 'build/x'), isNull);
expect( expect(
learnRelease.snap!.packOutput(root: '/repo', out: 'build/x'), learnRelease.snap!.packOutput(root: '/repo', out: 'build/x'),
'/repo/build/x', '/repo/build/x',
); );
}); for (final (app, name) in [
(todoRelease, 'halkyon-todo'),
(trainRelease, 'halkyon-train'),
]) {
final snap = app.snap!;
expect(snap.atRoot, isFalse, reason: app.id);
expect(
snap.projectPath('/home/someone'),
'/home/someone/.cache/halkyon-release/snap/$name',
);
expect(snap.packOutput(root: '/repo', out: 'build/x'), isNull);
expect(snap.storeChannel, 'edge', reason: app.id);
}
},
);
test('names the snap and the part the release tool takes', () { for (final app in [todoRelease, trainRelease]) {
expect(recipe, contains('name: ${snap.name}\n')); group("${app.name}'s snap, built in a clone of its own with its folder in "
expect(recipe, contains('\n ${snap.part}:\n after: [flutter]\n')); "place of the root's snap/", () {
expect(recipe, contains('adopt-info: ${snap.part} ')); final snap = app.snap!;
}); final recipe = File('${snap.directory}/snapcraft.yaml')
.readAsStringSync();
test('builds Todo for its instance', () { test('names the snap and the part the release tool takes', () {
expect(recipe, contains('cd ${todoRelease.flutterPackage}\n')); expect(recipe, contains('name: ${snap.name}\n'));
expect( expect(recipe, contains('\n ${snap.part}:\n after: [flutter]\n'));
recipe, expect(recipe, contains('adopt-info: ${snap.part} '));
contains('--dart-define=SERVER_URL=${todoRelease.serverUrl}\n'), });
);
});
test('has the files it names, as they will stand in the clone', () { test('builds the app for its instance', () {
// In the clone, the folder is snap/, the root's snap/local/ beside it. expect(recipe, contains('cd ${app.flutterPackage}\n'));
for (final match in RegExp( expect(recipe, contains('--dart-define=SERVER_URL=${app.serverUrl}\n'));
r'(?<![\w/$])snap/([\w./-]+)', });
).allMatches(recipe)) {
final inClone = match[1]!; test('has the files it names, as they will stand in the clone', () {
final path = inClone.startsWith('local') // In the clone, the folder is snap/, the root's snap/local/ beside
? 'snap/$inClone' // it.
: '${snap.directory}/$inClone'; for (final match in RegExp(
if (inClone == 'snapcraft.yaml') { r'(?<![\w/$])snap/([\w./-]+)',
continue; ).allMatches(recipe)) {
final inClone = match[1]!;
final path = inClone.startsWith('local')
? 'snap/$inClone'
: '${snap.directory}/$inClone';
if (inClone == 'snapcraft.yaml') {
continue;
}
expect(
FileSystemEntity.typeSync(path),
isNot(FileSystemEntityType.notFound),
reason: match[0],
);
} }
expect( expect(
FileSystemEntity.typeSync(path), File('${snap.directory}/gui/${snap.name}.desktop').readAsStringSync(),
isNot(FileSystemEntityType.notFound), contains('Exec=${snap.name}\n'),
reason: match[0],
); );
} });
expect(
File('${snap.directory}/gui/${snap.name}.desktop').readAsStringSync(),
contains('Exec=${snap.name}\n'),
);
}); });
}); }
test("every snap's recipe builds with the Flutter CI pins", () { test("every snap's recipe builds with the Flutter CI pins", () {
final pinned = RegExp( final pinned = RegExp(

View file

@ -97,6 +97,7 @@ APPS = {
"halkyon_train_icon.png", "halkyon_train_icon.png",
"dumbbell", "dumbbell",
(COBALT, VIOLET, STRAWBERRY, CARROT), (COBALT, VIOLET, STRAWBERRY, CARROT),
snap="apps/train/snap/gui/halkyon-train.png",
), ),
} }