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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 15:35:40 +01:00
commit e61d3abb4f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
41 changed files with 963 additions and 107 deletions

View file

@ -30,7 +30,9 @@ deploy/.env
apps/learn/halkyon_learn_flutter/ apps/learn/halkyon_learn_flutter/
apps/learn/halkyon_learn_client/ apps/learn/halkyon_learn_client/
apps/learn/tool/ apps/learn/tool/
apps/todo/ apps/todo/halkyon_todo_flutter/
apps/todo/halkyon_todo_client/
apps/todo/halkyon_todo_server/.serverpod/
apps/train/halkyon_train_flutter/ apps/train/halkyon_train_flutter/
apps/train/halkyon_train_client/ apps/train/halkyon_train_client/
apps/train/halkyon_train_server/.serverpod/ apps/train/halkyon_train_server/.serverpod/

View file

@ -6,7 +6,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
## Layout ## Layout
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`; the apps' icons are the root's `tool/app_icons.py`, ADR 0031). Learn is the app that runs; Todo is still Serverpod's scaffold, and Train a shell like it (ADR 0030); Meditate is to come. - **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`; the apps' icons are the root's `tool/app_icons.py`, ADR 0031). Learn is the app that runs; Train and Todo run too (ADR 0030, ADR 0033), Todo not released yet; Meditate is to come.
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit: - **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German). - `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how. - `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
@ -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 are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`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 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).
- **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

@ -18,7 +18,6 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "app.halkyon.todo" applicationId = "app.halkyon.todo"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
@ -32,13 +31,46 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
// The app's own key signs release builds when tool/release.dart hands it over
// through the environment, as Learn's and Train's do: the keystore's path and the
// key's alias, and the password asked for at each release, never written down. Other
// release builds are signed with the debug key, so `flutter run --release` works
// anywhere; the release tool releases no APK signed otherwise.
val releaseKeystore = System.getenv("HALKYON_ANDROID_KEYSTORE")
signingConfigs {
if (releaseKeystore != null) {
create("release") {
storeFile = file(releaseKeystore)
keyAlias = System.getenv("HALKYON_ANDROID_KEY_ALIAS")
// A PKCS #12 keystore has one password, for the store and its key.
storePassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
keyPassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig =
// Signing with the debug keys for now, so `flutter run --release` works. signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("debug")
} }
} }
// The native libraries go into the APK compressed, as Learn's: one APK serves
// every phone, holding them for three architectures, and Android unpacks the
// phone's own as it installs the app.
packaging {
jniLibs {
useLegacyPackaging = true
}
}
// Leaves out the list of dependencies Gradle would otherwise add to the APK,
// encrypted for Google Play alone; F-Droid's and others' scanners flag it.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
} }
kotlin { kotlin {

View file

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The app talks to its instance: release builds need this too (Flutter's <!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). --> template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@ -8,10 +9,19 @@
them off; without, Android may bring a reminder a few minutes late). --> them off; without, Android may bring a reminder a few minutes late). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- No backup and no transfer to another phone (ADR 0033, step 5, as
Train's ADR 0030, D53): the reminders scheduled, with their tasks'
titles, are kept unsealed. Through Android 11 allowBackup and
fullBackupContent stop both, from 12 on the extraction rules stop the
transfer. -->
<application <application
android:label="Halkyon Todo" android:label="Halkyon Todo"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
tools:targetApi="s">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Nothing of Todo's goes to a backup or to another phone, from Android 12
on (ADR 0033, step 5, as Train's ADR 0030, D53): allowBackup="false"
stops the backup, and only these rules stop the transfer. What Todo keeps
unsealed (the reminders it has scheduled, with their tasks' titles) stays
on this phone; what is sealed comes back from the instance on the next. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="."/>
<exclude domain="file" path="."/>
<exclude domain="database" path="."/>
<exclude domain="sharedpref" path="."/>
<exclude domain="external" path="."/>
<exclude domain="device_root" path="."/>
<exclude domain="device_file" path="."/>
<exclude domain="device_database" path="."/>
<exclude domain="device_sharedpref" path="."/>
</cloud-backup>
<device-transfer>
<exclude domain="root" path="."/>
<exclude domain="file" path="."/>
<exclude domain="database" path="."/>
<exclude domain="sharedpref" path="."/>
<exclude domain="external" path="."/>
<exclude domain="device_root" path="."/>
<exclude domain="device_file" path="."/>
<exclude domain="device_database" path="."/>
<exclude domain="device_sharedpref" path="."/>
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1 @@
- Die erste Version von Halkyon Todo: Aufgaben im Eingang und in Projekten mit Abschnitten, Heute und Demnächst, wiederkehrende Aufgaben und Erinnerungen, jedes Projekt als Liste, Board oder Kalender, alles versiegelt.

View file

@ -0,0 +1 @@
- The first release of Halkyon Todo: tasks in an Inbox and in projects with sections, Today and Upcoming, repeating tasks and reminders, each project as a list, a board or a calendar, everything sealed.

View file

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/app/licences.dart'; import 'package:halkyon_todo_flutter/src/app/licences.dart';
import 'package:halkyon_todo_flutter/src/app/todo_app.dart'; import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
@ -28,12 +29,23 @@ Future<void> main() async {
); );
await instances.load(usedBefore: device.storage.usedBefore()); await instances.load(usedBefore: device.storage.usedBefore());
runApp( runApp(
TodoApp( // What's new in each release (Train's ADR 0030, D61), from
choice: instances, // assets/whats_new/, shown once after an update.
// Only an instance of Halkyon Todo: one of another Halkyon app's WhatsNewScope(
// answers too, but holds nothing of this app's. whatsNew: WhatsNew(
checker: ServerpodInstanceChecker(app: 'todo'), AssetWhatsNewSource(rootBundle),
open: (instance) => TodoDependencies.create(instance, device), FileWhatsNewStore(File(p.join(device.support, 'whats_new.json'))),
version: device.appVersion,
),
// An instance chosen before: the app ran on this device already.
usedBefore: instances.current != null,
child: TodoApp(
choice: instances,
// Only an instance of Halkyon Todo: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'todo'),
open: (instance) => TodoDependencies.create(instance, device),
),
), ),
); );
} }

View file

@ -80,6 +80,10 @@ flutter:
# The instance the build names, unless --dart-define=SERVER_URL=… does # The instance the build names, unless --dart-define=SERVER_URL=… does
# (serverpod_flutter's getServerUrl). # (serverpod_flutter's getServerUrl).
- assets/config.json - assets/config.json
# What each release changed, one file a release and language
# (<version>.<language>.txt), drafted by tool/release.dart and edited
# (Train's ADR 0030, D61): halkyon_app's What's new shows them.
- assets/whats_new/
# The app's icon, Lucide's list-todo (ADR 0031), and Lucide's licence for the # The app's icon, Lucide's list-todo (ADR 0031), and Lucide's licence for the
# licence list (the repository's tool/app_icons.py writes both). # licence list (the repository's tool/app_icons.py writes both).
- assets/icon/halkyon_todo_icon.png - assets/icon/halkyon_todo_icon.png

View file

@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Nothing of Todo's leaves the phone by Android's backup or a transfer to
/// another phone (ADR 0033, step 5, as Train's ADR 0030, D53): the reminders
/// scheduled are kept unsealed, with their tasks' titles, and what is sealed
/// comes back from the instance anyway. Read as text, the files are small and
/// Android's own.
void main() {
const folder = 'android/app/src/main';
test('backup is off, through Android 11 as from 12 on', () {
final manifest = File('$folder/AndroidManifest.xml').readAsStringSync();
final application = RegExp(
'<application([^>]*)>',
).firstMatch(manifest)![1]!;
expect(application, contains('android:allowBackup="false"'));
expect(application, contains('android:fullBackupContent="false"'));
expect(
application,
contains('android:dataExtractionRules="@xml/data_extraction_rules"'),
);
});
test("from Android 12 on, neither a backup nor a transfer takes any of the app's "
'folders', () {
// allowBackup="false" leaves a transfer to another phone on from Android
// 12 on: the rules have to exclude it too, folder by folder.
final rules = File(
'$folder/res/xml/data_extraction_rules.xml',
).readAsStringSync();
for (final kind in ['cloud-backup', 'device-transfer']) {
final section = RegExp(
'<$kind>(.*?)</$kind>',
dotAll: true,
).firstMatch(rules)?[1];
expect(section, isNotNull, reason: '$kind is missing');
expect(section, isNot(contains('<include')), reason: kind);
final excluded = {
for (final exclude in RegExp(
r'<exclude domain="(\w+)" path="\."/>',
).allMatches(section!))
exclude[1],
};
expect(excluded, {
'root',
'file',
'database',
'sharedpref',
'external',
'device_root',
'device_file',
'device_database',
'device_sharedpref',
}, reason: kind);
}
});
}

View file

@ -0,0 +1,60 @@
# Todo's server image (ADR 0033, step 5). Built from the repository root (the build
# context), because the workspace's root pubspec and lockfile live there:
#
# docker build -f apps/todo/halkyon_todo_server/Dockerfile .
#
# Train's server image (apps/train/halkyon_train_server/Dockerfile) for Todo, as
# Learn's: the same Dart, the same Ubuntu runtime and start command; no git, no
# repositories folder, no data of its own.
# ── Build stage: compile the server to a native executable ────────────────────────
FROM dart:3.13.4 AS build
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
COPY tool/workspace_only.sh tool/
COPY apps/todo/halkyon_todo_server apps/todo/halkyon_todo_server
COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client
COPY packages/halkyon_core packages/halkyon_core
COPY packages/halkyon_llm packages/halkyon_llm
COPY packages/halkyon_oidc_server packages/halkyon_oidc_server
COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# The workspace root with only the server and the modules it builds on, as Learn's
# image has it (the reasons there).
RUN sh tool/workspace_only.sh apps/todo/halkyon_todo_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_llm packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN dart pub get
WORKDIR /app/apps/todo/halkyon_todo_server
RUN dart build cli --target bin/main.dart --output build
RUN mv build/bundle/bin/main build/bundle/bin/server
RUN mkdir -p config migrations
# ── Runtime stage ─────────────────────────────────────────────────────────────────
# Ubuntu 24.04 as Learn's; CA certificates for outgoing TLS (the operator's
# assistants, which the module forwards, ADR 0021).
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV runmode=production
ENV serverid=default
ENV logging=normal
ENV role=monolith
COPY --from=build /app/apps/todo/halkyon_todo_server/build/bundle/ ./
COPY --from=build /app/apps/todo/halkyon_todo_server/config/ config/
COPY --from=build /app/apps/todo/halkyon_todo_server/migrations/ migrations/
COPY --from=build /app/apps/todo/halkyon_todo_server/lib/src/generated/protocol.yaml lib/src/generated/protocol.yaml
# No root rights needed: it listens on 8080 and 8082 and writes nothing.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
USER app
# The API server, and the web server the register tells of accounts gone (ADR
# 0016), reached on the instance's own network alone.
EXPOSE 8080 8082
ENTRYPOINT ["/bin/sh", "-c", "exec ./bin/server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role \"$@\"", "--"]
CMD []

View file

@ -1,27 +1,20 @@
# This is the configuration file for your production environment. # Todo's server on learn.halkyon.eu's server, at todo.halkyon.eu (ADR 0033, step 5),
# Typically, you will want to route the traffic through a load balancer # behind the instance's Caddy (deploy/compose.todo.yaml): its API and its web server
# which adds SSL security through https. If you use Serverpod's standard # on the one domain, as Learn's and Train's; its database in the instance's PostgreSQL.
# Terraform scripts to deploy your server, all you need to change in
# this file is the examplepod.com domain name.
# Configuration for the main API server. # Configuration for the main API server.
apiServer: apiServer:
port: 8080 port: 8080
publicHost: api.examplepod.com publicHost: todo.halkyon.eu
publicPort: 443 publicPort: 443
publicScheme: https publicScheme: https
# Configuration for the Insights server. # No insightsServer, as Learn's: without the block Serverpod doesn't start Insights.
insightsServer:
port: 8081
publicHost: insights.examplepod.com
publicPort: 443
publicScheme: https
# Configuration for the web server. # Configuration for the web server.
webServer: webServer:
port: 8082 port: 8082
publicHost: app.examplepod.com publicHost: todo.halkyon.eu
publicPort: 443 publicPort: 443
publicScheme: https publicScheme: https
@ -30,11 +23,12 @@ webServer:
# If you are connecting on a public IP (e.g. on AWS or Google Cloud Run), you # If you are connecting on a public IP (e.g. on AWS or Google Cloud Run), you
# connect on the public IP of the database e.g. database.examplepod.com. # connect on the public IP of the database e.g. database.examplepod.com.
database: database:
host: database.private-production.examplepod.com host: postgres
port: 5432 port: 5432
name: serverpod name: halkyon_todo
user: postgres user: halkyon_todo
requireSsl: true # On the instance's own network, never leaving the server.
requireSsl: false
#isUnixSocket: true # defaults to false #isUnixSocket: true # defaults to false
#maxConnectionCount: 10 # Defaults to 10, a negative or null value removes the limit #maxConnectionCount: 10 # Defaults to 10, a negative or null value removes the limit

44
apps/todo/release.dart Normal file
View file

@ -0,0 +1,44 @@
// How Halkyon Todo is released (`dart run tool/release.dart todo`): the
// release tool's configuration of it, registered in tool/src/release/apps.dart.
import '../../tool/src/release/app_release.dart';
/// Halkyon Todo's releases (ADR 0033, step 5): the APK and the snap, both
/// connecting to todo.halkyon.eu, published on code.halkyon.app, the APK in
/// the project's F-Droid repository beside Learn and Train.
const todoRelease = AppRelease(
id: 'todo',
name: 'Halkyon Todo',
applicationId: 'app.halkyon.todo',
directory: 'apps/todo',
flutterPackage: 'apps/todo/halkyon_todo_flutter',
artifactName: 'halkyon-todo',
serverUrl: 'https://todo.halkyon.eu/',
forgejo: 'https://code.halkyon.app',
repository: 'project-halkyon/halkyon',
// A PKCS #12 keystore of its own outside the repository (also in the
// maintainer's password manager), as Learn's and Train's: it is the app's
// identity, and lost, installed apps can't be updated; one key for several
// apps would let either's build pass for the other's update.
android: AndroidSigning(
certificateFile: 'tool/release/todo-android-certificate.sha256',
keystoreVariable: 'HALKYON_TODO_ANDROID_KEYSTORE',
defaultKeystore: '.local/share/halkyon-todo/android-release.p12',
aliasVariable: 'HALKYON_TODO_ANDROID_KEY_ALIAS',
defaultAlias: 'halkyon-todo',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo',
),
// Built in a clone of its own (`SnapRelease.projectPath`), as the root's
// snap/ is Learn's. To the Store's edge channel, as Learn's, once the
// name is registered (`snapcraft register halkyon-todo`).
snap: SnapRelease(
name: 'halkyon-todo',
part: 'halkyon-todo',
directory: 'apps/todo/snap',
storeChannel: 'edge',
),
);

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Halkyon Todo
Comment=Tasks and projects, sealed and synced between your devices
Exec=halkyon-todo
Icon=${SNAP}/meta/gui/halkyon-todo.png
Terminal=false
Categories=Office;ProjectManagement;
StartupWMClass=app.halkyon.todo

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,145 @@
# Halkyon Todo's Linux app as a Snap (ADR 0033, step 5), built in LXD by the release
# tool (`dart run tool/release.dart todo`): 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 todo.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-todo
title: Halkyon Todo
base: core24
adopt-info: halkyon-todo # the version, from the app's pubspec.yaml
summary: Tasks and projects, sealed and synced between your devices
description: |
Halkyon Todo keeps your tasks: an Inbox for what comes up, projects with
sections, sub-tasks and labels, Today with what is overdue, and Upcoming as
an agenda that opens into the month. Tasks repeat (every day, every other
Tuesday, the first Monday of each month), have reminders, deadlines and
priorities, and are typed the quick way: "Call Anna tomorrow 9am #Home p1".
Each project shows as a list, a board or a calendar, grouped, sorted and
filtered as you set it, the same on every device. Tasks are dragged between
sections, days and hours.
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 todo.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/todo
# 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-todo.png
grade: stable
confinement: strict
platforms:
amd64:
apps:
halkyon-todo:
command: bin/halkyon_todo_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,
# the reminders' notifications through the desktop's own; files 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-todo:
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/todo/halkyon_todo_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://todo.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). Todo stages no package today; the part keeps a
# later one from doing so unnoticed.
cleanup:
after: [halkyon-todo, 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

@ -28,7 +28,8 @@ DOMAIN=http://localhost
# data too, and told again hourly until each answers. Each of them sets, in its own # data too, and told again hourly until each answers. Each of them sets, in its own
# environment, the same INSTANCE_SECRET and ACCOUNT_REGISTER_URL, this server's web # environment, the same INSTANCE_SECRET and ACCOUNT_REGISTER_URL, this server's web
# server as it reaches it (`http://server:8082`). Learn never takes ACCOUNT_REGISTER_URL: # server as it reaches it (`http://server:8082`). Learn never takes ACCOUNT_REGISTER_URL:
# it doesn't start with one, as it holds the register. # it doesn't start with one, as it holds the register. Train's and Todo's compose files
# name their own servers (INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO): this is for others.
#INSTANCE_MEMBERS= #INSTANCE_MEMBERS=
# The instance's assistants, which an operator grants to people (Halkyon Learn's # The instance's assistants, which an operator grants to people (Halkyon Learn's
@ -38,7 +39,8 @@ DOMAIN=http://localhost
# Sidecars switched on beside the instance (sidecars/README.md), each by its compose file # Sidecars switched on beside the instance (sidecars/README.md), each by its compose file
# after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to # after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to
# learn from; compose.languagetool.yaml, spelling and grammar checked; compose.train.yaml, # learn from; compose.languagetool.yaml, spelling and grammar checked; compose.train.yaml,
# Halkyon Train's server with its BRouter. Unset: the instance alone. # Halkyon Train's server with its BRouter; compose.todo.yaml, Halkyon Todo's server.
# Unset: the instance alone.
#COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml #COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml
# With the Jellyfin sidecar: the domain it answers at, which the instance then suggests # With the Jellyfin sidecar: the domain it answers at, which the instance then suggests
@ -50,8 +52,7 @@ DOMAIN=http://localhost
# pepper (never change it once people sign in), INSTANCE_SECRET above set, and the # pepper (never change it once people sign in), INSTANCE_SECRET above set, and the
# region of BRouter's segments, `west,south,east,north` in whole degrees, multiples of # region of BRouter's segments, `west,south,east,north` in whole degrees, multiples of
# 5 (empty: Europe's, -25,35,40,70, about 3.3 GB). Generate each secret: # 5 (empty: Europe's, -25,35,40,70, about 3.3 GB). Generate each secret:
# openssl rand -hex 32. Its server is told of accounts gone without INSTANCE_MEMBERS; # openssl rand -hex 32. Its compose file names its server to Learn's register.
# setting it, name Train's too (`http://train-server:8082/`).
#TRAIN_DOMAIN=train.example.org #TRAIN_DOMAIN=train.example.org
#TRAIN_POSTGRES_PASSWORD= #TRAIN_POSTGRES_PASSWORD=
#TRAIN_SESSION_KEY_HASH_PEPPER= #TRAIN_SESSION_KEY_HASH_PEPPER=
@ -84,6 +85,14 @@ DOMAIN=http://localhost
#PHOTON_HEAP= #PHOTON_HEAP=
#PHOTON_MEMORY= #PHOTON_MEMORY=
# With Halkyon Todo beside the instance (compose.todo.yaml, sidecars/README.md, Todo):
# the domain its apps reach it at, its database's password and its own session pepper
# (never change it once people sign in), INSTANCE_SECRET above set. Generate each
# secret: openssl rand -hex 32. Its compose file names its server to Learn's register.
#TODO_DOMAIN=todo.example.org
#TODO_POSTGRES_PASSWORD=
#TODO_SESSION_KEY_HASH_PEPPER=
# Postgres password, shared by the postgres and server services. # Postgres password, shared by the postgres and server services.
# Generate: openssl rand -base64 32 # Generate: openssl rand -base64 32
POSTGRES_PASSWORD= POSTGRES_PASSWORD=

View file

@ -4,8 +4,9 @@
# how it was set up and how to restore. # how it was set up and how to restore.
# #
# Everything people keep is in the database, their files too (Serverpod's database # Everything people keep is in the database, their files too (Serverpod's database
# cloud storage), and in Train's where .env switches Train on (compose.train.yaml, # cloud storage), and in Train's and Todo's where .env switches each on (compose.train.yaml,
# Halkyon Train's ADR 0030, D46), but for the releases of published studysets, which are git # Halkyon Train's ADR 0030, D46; compose.todo.yaml, Halkyon Todo's ADR 0033), but for the
# releases of published studysets, which are git
# repositories in the server's volume: a dump and a bundle of each repository cover # repositories in the server's volume: a dump and a bundle of each repository cover
# the instance; the secrets in deploy/.env are kept in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box # the instance; the secrets in deploy/.env are kept in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box
# and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03). # and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03).
@ -36,11 +37,15 @@ cd "$(dirname "$0")"
# the last hour and stores it only once; restic compresses what it stores. # the last hour and stores it only once; restic compresses what it stores.
mkdir -p -m 700 "$HOME/backups" mkdir -p -m 700 "$HOME/backups"
dump="$HOME/backups/latest.dump" dump="$HOME/backups/latest.dump"
train_dump="$HOME/backups/latest-train.dump" # The apps' servers .env switches on beside Learn's, each with its database
train= # (halkyon_<app>) dumped to latest-<app>.dump.
if docker compose config --services | grep -qx train-server; then services=$(docker compose config --services)
train=yes apps=
fi for app in train todo; do
if printf '%s\n' "$services" | grep -qx "$app-server"; then
apps="$apps $app"
fi
done
( (
umask 077 umask 077
# Never during a deploy (redeploy.sh holds this lock): its migrations would wait # Never during a deploy (redeploy.sh holds this lock): its migrations would wait
@ -48,14 +53,17 @@ fi
exec 9>"$HOME/.redeploy.lock" exec 9>"$HOME/.redeploy.lock"
flock 9 flock 9
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_learn >"$dump.partial" docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_learn >"$dump.partial"
if [ -n "$train" ]; then for app in $apps; do
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_train >"$train_dump.partial" docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 "halkyon_$app" \
fi >"$HOME/backups/latest-$app.dump.partial"
done
) )
mv "$dump.partial" "$dump" mv "$dump.partial" "$dump"
if [ -n "$train" ]; then set -- "$dump"
mv "$train_dump.partial" "$train_dump" for app in $apps; do
fi mv "$HOME/backups/latest-$app.dump.partial" "$HOME/backups/latest-$app.dump"
set -- "$@" "$HOME/backups/latest-$app.dump"
done
# Each repository as a git bundle, after the dump: a release the dump lacks is never # Each repository as a git bundle, after the dump: a release the dump lacks is never
# numbered again (the server numbers past the highest tag), while one the bundles # numbered again (the server numbers past the highest tag), while one the bundles
@ -79,13 +87,9 @@ releases="$HOME/backups/repositories.tar"
) )
mv "$releases.partial" "$releases" mv "$releases.partial" "$releases"
if [ -n "$train" ]; then restic backup --quiet "$@" "$releases"
restic backup --quiet "$dump" "$train_dump" "$releases"
else
restic backup --quiet "$dump" "$releases"
fi
# By host alone: restic groups by the paths backed up too, so a path added (Train's # By host alone: restic groups by the paths backed up too, so a path added (Train's
# dump) would leave the snapshots before it a group of their own, never forgotten, and # dump, Todo's) would leave the snapshots before it a group of their own, never forgotten, and
# a deleted account in them for good. # a deleted account in them for good.
restic forget --quiet --group-by host --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3 restic forget --quiet --group-by host --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3

View file

@ -211,6 +211,11 @@ docker compose exec -T postgres \
whose role is the cluster's and so survives the restore (on a new server, make it first: whose role is the cluster's and so survives the restore (on a new server, make it first:
`sidecars/README.md`, *Train*, step 2). `sidecars/README.md`, *Train*, step 2).
Todo's database, where `.env` switches Todo on (`compose.todo.yaml`, Halkyon Todo's
ADR 0033), is `latest-todo.dump` in the same snapshots, restored as Train's, with
`todo` for `train` (its role made first on a new server: `sidecars/README.md`, *Todo*,
step 1).
To check a backup without touching the instance, restore it into a database of its own To check a backup without touching the instance, restore it into a database of its own
next to the real one, look, and drop it: next to the real one, look, and drop it:

Some files were not shown because too many files have changed in this diff Show more