The forwarding page's table gains train-loopback (port 43825) and train-scheme (app.halkyon.train://). Train's app had kept Todo's loopback port 43824 from its shell; it now has its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
# halkyon_app
|
|
|
|
What every Halkyon app (Learn, Todo, Train) is built around, on an instance's
|
|
`halkyon_base` module: the instances a device keeps an account on, signing in,
|
|
each person's data opened and closed, and syncing it. Taken from Halkyon Learn,
|
|
which it was written for, so it behaves as Learn's own did; the texts are in
|
|
British English, American English and German, and where they name the app,
|
|
they name yours.
|
|
|
|
Written as the apps are: Dart 3.13's primary constructors, Material from
|
|
`package:material_ui`, Lucide icons, blocs (`flutter_bloc`), LeanCode's lints.
|
|
|
|
## What it holds
|
|
|
|
- **Instances** (`lib/src/instance/`): `Instance` (an address as people type
|
|
it; `isSecure`: over HTTPS, or over plain HTTP on this machine alone),
|
|
`InstanceChoice` (those the device keeps an account on, one in use;
|
|
`FileInstanceStore`; `allows` an instance reached securely, or in a
|
|
development build the build's own over HTTP, and `leave`s one it no longer
|
|
allows, which `InstanceGate` never opens: it says why and offers another or
|
|
removing it), `InstanceStorage` (each instance's people's folders and
|
|
session key), `InstanceChecker` (does an address answer, where does it keep
|
|
people's data; `ServerpodInstanceChecker` asks its `instance.basics()`),
|
|
`InstanceScope`. The screens: `FirstStartScreen` (the build's instance and
|
|
another, as tiles, `InstanceTile`), `InstanceStep` (an address, checked),
|
|
`showAccountSwitcher` and `AddAccountScreen`, and where an instance keeps
|
|
people's data, as a flag and words (`InstanceLocationBuilder`,
|
|
`InstanceFlag`, `instanceLocationWords`): `EU`, a country or a subdivision
|
|
(ISO 3166-2, `US-CA`) as its operator says, "Germany (EU)" for a member of
|
|
the European Union (`europeanUnionMembers`) and "California, United States"
|
|
for a subdivision, with the most specific flag there is (a few subdivisions
|
|
have their own, such as Scotland; else the country's). Countries and
|
|
subdivisions are named by Unicode CLDR's names in English and German, which
|
|
come with the package (`AssetCountryNames`, `AssetSubdivisionNames`; the
|
|
package's `tool/country_names.py` and `tool/subdivision_names.py` write
|
|
them, from one release); tests give names of their own (`CountryNames`,
|
|
`SubdivisionNames`).
|
|
- **Gates** (`lib/src/app/`): `InstanceGate` opens the app's dependencies on
|
|
the instance in use (`AppDependencies`, handed down by `AppScope`), anew on
|
|
a switch, the previous ones closed first; `SessionGate` shows the app of
|
|
whoever is signed in and asks how to start while nobody is
|
|
(`StartScreen`: SimpleLogin, a password, another instance; an instance out of reach is said so, with Try again,
|
|
and one offering no way to create an account too; where new accounts wait
|
|
for the operator's approval, or none are made, it says so first), at a
|
|
first start as after signing out; while an account signed in or up to
|
|
here waits for the operator (Halkyon Learn's ADR 0021, D31), `WaitScreen`
|
|
shows its handle and the four words the operator reads out for the person
|
|
to match, asks every 30 seconds and as the app comes back, deletes a new
|
|
account after asking, and once approved leads to signing in once more
|
|
(the wait kept in the keyring per instance, `SecureAccountWaitStore`, so a
|
|
restart shows it again); an anonymous account's session, left from before
|
|
there were none, is deleted as the app starts (ADR 0020, D25), falling
|
|
back to signing out on the device; `PersonScope` opens each person's own
|
|
dependencies and closes them when the person goes. `resolveAppLocale`
|
|
picks the app's language as every Halkyon app does: the first preferred
|
|
language it speaks, never by country alone, British English otherwise.
|
|
- **The instance's operators** (`lib/src/operator/`, Halkyon Learn's ADR
|
|
0021, D21 to D24 and D34): `InstanceOperatorRepository` on
|
|
`ServerpodInstanceOperatorGateway` (the module's `instanceOperator`
|
|
endpoint, refusals as `OperatorFailed`), `OperatingCubit` (how many
|
|
accounts wait, null for anyone who doesn't operate the instance; asked as
|
|
the person's app opens, after each change, and every 5 minutes only while
|
|
they operate it), `InstanceOperatorCard` or `InstanceOperatorTiles` for an
|
|
app's settings and `WaitingAccountsBadge` for its Settings cog, both
|
|
nothing at all for anyone else, and `InstanceOperatorScreen` (D62): an
|
|
overview of how many wait, operate and have accounts, and of the
|
|
assistants set up, each row opening a screen of its own, every list read
|
|
50 at a time as it is scrolled (`OperatorListCubit`, `OperatorPage`): the
|
|
accounts that wait, approved once their person confirmed the words read
|
|
out, or refused; who operates, named by a handle picked among those
|
|
suggested and taken back (never the last); accounts found by the start of
|
|
their handle, suspended, named or granted assistants. A handle is only
|
|
ever picked among the accounts there are (`AccountSuggestionField`, D61).
|
|
Only the register's service answers: on a member, nothing shows.
|
|
- **Assistants** (`lib/src/assistant/`, Halkyon Learn's ADR 0021, step 2):
|
|
`AssistantRepository` on `ServerpodAssistantGateway` (the module's
|
|
`assistant` endpoint; refusals as `AssistantFailed`) asks an
|
|
`AssistantChoice` in halkyon_llm's terms: a `GrantedAssistant` through the
|
|
instance, which counts it, at an effort among those the operator allows
|
|
and with that effort's ceiling on output (`efforts`, `outputCeilingAt`,
|
|
D63), or a `PersonalAssistant` with the person's own key directly
|
|
(`LlmClient`), without an effort and at 4,096 output tokens, the instance
|
|
seeing neither key nor question;
|
|
`assistant_messages.dart` turns a conversation into the endpoint's models
|
|
and back, part by part. `AssistantsCard` is an app's settings' card: what
|
|
is granted, under each provider's name in its colour (`ProviderLabel`,
|
|
`providerColorIn`, D60), what is left of the person's budget for them
|
|
together (D58), each agreed to once (the consent sheet), the person's use
|
|
by UTC month, oldest first (D57), the instance's privacy page, and the
|
|
person's own assistants where the operator allows them, kept where the
|
|
app says (`OwnAssistantStore`: Learn's sealed settings). For the operator,
|
|
`AssistantOperatorRepository` and `AssistantGrantsScreen`, opened from
|
|
`InstanceOperatorScreen`: the assistants set up, by provider, with their
|
|
efforts and whether they are priced; the people granted some, each with
|
|
one budget in money and tokens, a day and a month (D58, D59), found by
|
|
their handle's start; one sheet granting several assistants, a
|
|
provider's at once, to several people at once, or changing one person's
|
|
(`showAssistantGrantSheet`); and everyone's use by month.
|
|
`package:halkyon_app/assistant_models.dart` holds the models alone, in
|
|
pure Dart, for an app's own models. For the app's warnings before a
|
|
private studyset's content goes (ADR 0021, D9): `providerTermsOf` names a
|
|
provider's terms by its host (checked for Anthropic, OpenAI, Google and
|
|
OpenRouter, null for any other), and `OwnAssistant.onThisMachine` says
|
|
whether a person's own assistant runs on this machine.
|
|
- **About** (`showAppAbout`): the app's name (its frame's), its version,
|
|
icon and legalese where it gives them, where the source of what the
|
|
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
|
|
both licences require offering it), the app's own parts (its credits), and
|
|
the licence list.
|
|
- **The account** (`AccountTiles`): on which instance the account is signed
|
|
in, switching to the account on another, signing out and deleting it
|
|
(after asking), for an
|
|
app's settings, in the app's words where only it can say them. While one
|
|
runs, the person's app stays and its tile shows a spinner; one that fails
|
|
keeps them signed in and says why once, in a snack bar (in a sheet, give
|
|
it `hasSnackBars`). An instance out of reach signs out on the device all
|
|
the same (Serverpod's `signOutDevice`), so only the device's own storage
|
|
fails a sign-out.
|
|
- **The session** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
|
|
`ServerpodAuthGateway` follows the session the app's Serverpod client
|
|
keeps, signs out, deletes the account through the instance's
|
|
`halkyon_base`, and puts a session granted elsewhere in place
|
|
(`useSession`: a password's or SimpleLogin's sign-in; GitHub signs nobody
|
|
in since ADR 0020, D36); `SessionBloc` and `SignInWaysCubit`;
|
|
`SignInAddresses`, `PersonFolders`.
|
|
- **Content sealed with data keys** (`lib/src/privacy/`, ADR 0019):
|
|
`ContentCipher` (`SodiumContentCipher`, on the isolate it is used on, as an
|
|
app seals and opens rows one by one; `FakeContentCipher` in tests) makes a
|
|
store's data keys, sealed to the account and signed by it, opens them, and
|
|
seals and opens a row's content for the row it is stored in (its table, id
|
|
and column), the payload holding the signature before the ciphertext.
|
|
- **A password and TOTP** (`lib/src/password/`, ADR 0017): `PasswordRepository`
|
|
on a `PasswordGateway` (`ServerpodPasswordGateway`), an `AccountCrypto`
|
|
(`SodiumAccountCrypto`: halkyon_keys and SRP's client half, each step on an
|
|
isolate of its own, whose results are copied before they cross back:
|
|
sodium's are views of memory freed with the isolate that made them) and an
|
|
`AccountKeyStore` (`SecureAccountKeyStore`: the account's unlocked keys in
|
|
the system's keyring, per instance and person, gone once another person
|
|
signs in or nobody; on iOS and macOS `unlocked_this_device`, so readable
|
|
only while the device is unlocked, never in a backup restored elsewhere nor
|
|
in iCloud Keychain, each write deleting the entry before it, and forgetting
|
|
finding too the entries earlier builds kept with another accessibility, a
|
|
keyring that can't be read now stopping nothing). Signing in (SRP's M2 checked and the keys opened before
|
|
anything is trusted), signing up (the recovery phrase shown once and three of
|
|
its words typed back before anything reaches the instance), a setup never
|
|
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery
|
|
codes, changing the password, a new phrase, and getting back in. The
|
|
screens: the start screen's Sign in and Create an account,
|
|
`showPasswordSignInSheet`, `showAccountSetupSheet`,
|
|
`showAccountRecoverySheet`, and `AccountSecuritySection` for an app's
|
|
account part. Every change of how the account is secured asks for the
|
|
password with the code, one answer per sheet (`OwnerAnswer`, the owner's
|
|
proof: a session alone gets no code checked), each answer bound to the
|
|
instance's base URL and to the change it approves (its purpose and the
|
|
digest of what it sets, `proof_digests.dart`), so nobody between the device
|
|
and the instance swaps anything in; deleting by a code by mail
|
|
follows `secondFactorWorks`, as the instance tells it, and asks for the
|
|
address only where the device doesn't know it. Secrets shown stay the
|
|
person's: nothing secret is copied (no app can mark a copy as sensitive
|
|
through Flutter, so clipboard histories and cloud clipboards would keep
|
|
it): the recovery phrase and codes are saved or written down, an
|
|
authenticator's key scanned, opened or typed; all of them show in a
|
|
`SecretScreen` (the phrase typed back too, its fields offering Paste and
|
|
Select all alone, their copy keys doing nothing), kept out of screenshots,
|
|
screen recordings and recents where the app's window answers
|
|
`app.halkyon/screen_guard`: Android's `FLAG_SECURE` (as Learn's and Todo's
|
|
`ScreenGuard.kt` in their `MainActivity`), macOS's `sharingType = .none`
|
|
(`MainFlutterWindow.swift`; screenshots, but not ScreenCaptureKit's
|
|
captures on macOS 15 and later) and Windows' `WDA_EXCLUDEFROMCAPTURE`
|
|
from Windows 10 2004 on, `WDA_MONITOR` (black in captures) before, by the
|
|
version `RtlGetVersion` tells (`flutter_window.cpp`); neither run yet;
|
|
covered on iOS in the app switcher's picture while the app isn't in front,
|
|
screenshots there not held back; nothing on Linux; keyboards learn nothing typed into a password, a code or
|
|
the phrase; no state's text (which a debug build's bloc observer logs) holds
|
|
a secret, a code or the address; only an `otpauth:` URI is shown as a QR
|
|
code or opened; and anything a step didn't expect (bytes of the wrong
|
|
length from an instance, libsodium's or the keyring's own errors) fails it
|
|
rather than leaving its sheet busy. `register_interop_test.dart` holds the
|
|
real cryptography to the register's own checks. Every build of an app
|
|
needs a C compiler and make: sodium's build hook compiles libsodium.
|
|
- **Signing in with SimpleLogin** (`lib/src/simplelogin/`, ADR 0020):
|
|
`SimpleLoginRepository` on a `SimpleLoginGateway` (`ServerpodSimpleLoginGateway`,
|
|
halkyon_base's `simpleLogin` endpoint) and halkyon_oidc_flutter's
|
|
`OidcBrowser`: SimpleLogin's page in the system's browser sheet, back by
|
|
the app's scheme, or on Linux and Windows in the browser, back to the
|
|
app's port on the loopback, as the instance's forwarding page names them
|
|
(`callbackScheme`, `loopbackPort`: Learn's 43823, Todo's 43824, Train's 43825). Someone
|
|
new creates an account (a handle, the one the instance suggests being
|
|
free; the password twice, which makes the account's keys on the device;
|
|
the recovery phrase shown once and three of its words typed back; nothing
|
|
reaches the instance before) or links theirs again in place of a lost
|
|
SimpleLogin account with its handle and recovery phrase, which open the
|
|
keys on the device before any proof is sent; an account opens its keys
|
|
with its password; an account of before gets its keys, and its handle
|
|
where it has none. With `link`, SimpleLogin joins the account of the
|
|
device's person instead. The
|
|
screens: the start screen's Continue with SimpleLogin, first where the
|
|
instance offers it, and `showSimpleLoginSheet` (`SimpleLoginCubit`),
|
|
which waits for the browser with Cancel and stops waiting on the loopback
|
|
once it closes. Adding it to an account (ADR 0020, D21): where the
|
|
person stands (`SimpleLoginRepository.standing`, read anew after every
|
|
change, `SimpleLoginStandingCubit`: nothing to show, linked, or
|
|
missing), shown by `SimpleLoginSection` (each app places it above
|
|
`AccountSecuritySection`: a heading and Add SimpleLogin; once added a row
|
|
among the ways in), which reads it once where a
|
|
`SimpleLoginStandingScope` is above it.
|
|
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
|
|
where the person chooses (a recovery phrase, recovery codes, an app's
|
|
exports): on Linux and macOS the own user's alone (`writePrivately`). On
|
|
Linux a new file beside it, made 0600 with `O_EXCL`, written through its
|
|
descriptor, renamed over the one chosen, never written into, and the
|
|
folder flushed. On macOS, whose sandbox lets an app reach the file chosen
|
|
and nothing beside it, the file itself, opened without following a link
|
|
or waiting on a pipe, refused unless it is a file of the own user's, made
|
|
0600 and emptied before anything is written; whoever held it open reads
|
|
what is saved. Each app's macOS entitlements grant reading and writing
|
|
the files the person chooses (`com.apple.security.files.user-selected.read-write`),
|
|
without which nothing could be saved there, Learn's exports neither. On
|
|
both, a symbolic link or another user's file is refused with nothing
|
|
written. On Windows written in place with its folder's rights, on iOS
|
|
without the copy file_picker leaves in the temporary folder
|
|
(`forgetPickerCopy`). None of it has run on a Mac yet.
|
|
- **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after
|
|
local changes, on resume and every few minutes, one sync at a time, counting
|
|
the changes not on the server yet, `PendingChangesStore`), `AppCompatibility`
|
|
and `compatibleSync` (the instance's minimum app build, the engine's table
|
|
check), `SyncStatusBloc`, `syncAndWait`, and the widgets: `SyncStatusCard`,
|
|
`AppMismatchBanner`, `SyncTroubleBadge`.
|
|
- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance
|
|
about itself, and to sign in as another person than the app's client holds.
|
|
- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary
|
|
(`FakeAuthGateway`, with waits, `fakeWaitOf`; `FakePasswordGateway`,
|
|
`FakeAccountCrypto`, `FakeInstanceChecker`, `FakeInstanceOperatorGateway`,
|
|
the stores in memory), a frame
|
|
(`testFrame`), `testApp`, and `TestAppDependencies`.
|
|
|
|
## Using it in an app
|
|
|
|
1. **The server** includes `halkyon_base` with the nickname `base` (its
|
|
README), so the app's generated client has `client.modules.base`.
|
|
2. **Depend on it** by path, within the repository's workspace, as on
|
|
`halkyon_ui`:
|
|
|
|
```yaml
|
|
dependencies:
|
|
halkyon_app:
|
|
path: ../../../packages/halkyon_app
|
|
```
|
|
|
|
3. **Texts**: add `HalkyonAppLocalizations.delegate` to every `MaterialApp`'s
|
|
delegates, beside `HalkyonUiLocalizations.delegate` and material_ui's
|
|
`GlobalMaterialLocalizations.delegates`.
|
|
4. **The frame** (`AppFrame`): the app's name and wordmark word, its icon, an
|
|
example address, its theme, delegates, languages (`resolveAppLocale` as
|
|
its resolution) and launch screen, and the sentence only it can say: what
|
|
the first start's instance keeps of the person's.
|
|
5. **Android**: the app's `MainActivity` answers `app.halkyon/screen_guard`
|
|
(copy Learn's or Todo's `ScreenGuard.kt` and call `ScreenGuard.attach` in
|
|
`configureFlutterEngine`), or secrets show in screenshots and recents.
|
|
6. **The dependencies**: the app's composition root implements
|
|
`AppDependencies<P>` (`serverName`, `auth`, `passwords`, `simpleLogin`,
|
|
`instanceOperators`, `fileSaver`, `sourceCodeUrl`, `openPerson`,
|
|
`dispose`), its person's dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
|
|
`ServerpodPasswordGateway(client, base: client.modules.base)`,
|
|
`SodiumAccountCrypto()`, `SecureAccountKeyStore(storage.keysOf(instance))`,
|
|
the auth gateway and the app's `SignInAddresses`. Its `auth` is an
|
|
`AuthRepository` on `ServerpodAuthGateway(client, base:
|
|
client.modules.base, waits:
|
|
SecureAccountWaitStore(storage.waitOf(instance)))`, whose `initialize()`
|
|
it awaits before anything asks who is signed in. Its `instanceOperators`
|
|
is an `InstanceOperatorRepository` on
|
|
`ServerpodInstanceOperatorGateway(client.modules.base)`; the person's app
|
|
provides an `OperatingCubit` on it, for the card and the badge.
|
|
7. **The tree**:
|
|
|
|
```dart
|
|
InstanceGate(
|
|
frame: frame,
|
|
choice: choice,
|
|
checker: ServerpodInstanceChecker(app: 'todo'),
|
|
readLook: …, // the device's theme mode and language
|
|
open: Dependencies.create,
|
|
app: SessionGate(
|
|
look: (context) => …,
|
|
app: (person) => PersonApp(person: person), // with PersonScope inside
|
|
),
|
|
)
|
|
```
|
|
|
|
A person's widgets find their dependencies with
|
|
`PersonScope.of<PersonDependencies>(context)` and the app's with
|
|
`AppScope.of<Dependencies>(context)`.
|
|
8. **Licences**: call `registerHalkyonAppLicenses()` once at startup (the
|
|
flags' and Unicode CLDR's notices), beside halkyon_ui's
|
|
`registerFontLicenses()`.
|
|
|
|
## Licence
|
|
|
|
GNU Affero General Public License 3.0 or later (`LICENSE`). The flags are
|
|
lipis' flag-icons, drawn by the `flag` package, under the MIT licence
|
|
(`assets/flags/LICENSE-flag-icons.txt`). The subdivisions' names are Unicode
|
|
CLDR's (release 48.2), under the Unicode License v3
|
|
(`assets/subdivisions/LICENSE-CLDR.txt`, `assets/subdivisions/ATTRIBUTION.md`).
|