halkyon/packages/halkyon_app/README.md

250 lines
15 KiB
Markdown
Raw Normal View History

# 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, GitHub for those who have it,
another instance; an instance out of reach is said so, with Try again,
and one offering no way to create an account too), at a first start as
after signing out; 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.
- **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.
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` signs in with GitHub those who have it, as it is
added to nobody any more (ADR 0020, D25; the system browser and a loopback
address on Linux and Windows, halkyon_oidc_flutter's `LoopbackWebAuth`),
through the instance's `halkyon_base`, and puts a session granted elsewhere
in place (`useSession`); `SessionBloc` and `SignInWaysCubit`;
`SignInAddresses`, `PersonFolders`. The start screen says why a GitHub
sign-in was refused, and for a GitHub account nobody here has, the way to
create an account in its place.
- **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.
feat(app): signing in with SimpleLogin, its sheet and the start screen's offer (ADR 0020) Where the instance offers SimpleLogin, the start screen offers it first, saying that the instance keeps only the SimpleLogin ID, and opens a sheet that waits for SimpleLogin's page in the browser (Cancel ends the wait), then: for someone new, the choice between creating an account (the suggested handle or another, checked with the instance, the password twice, the recovery phrase shown once and three of its words typed back, nothing reaching the instance before) and linking theirs again with its handle and recovery phrase; for an account, its password opening its keys; for an account of before, the keys made with the handle it lacks. SimpleLoginCubit holds the steps, each refusal shown where it is mended (a handle's on the handle, a sign-in to begin again on the browser's), and closing the sheet stops waiting on the loopback. The suggested handle is never checked: the instance suggests none that anybody has, and every answer about a handle counts against its limit (D5). The fake's handle rule is now the module's. Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter, now a direct dependency of both, with their names on the loopback's page), their schemes and their loopback ports (43823, 43824), as the instance's forwarding page has them. Their tests offer no SimpleLogin; halkyon_app's test the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 01:21:29 +02:00
- **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). 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
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all The short reading's H1: open(2) takes its mode among variable arguments, which Apple's Arm passes on the stack, so bound with three fixed ones it read the mode from whatever the stack held: a phrase or recovery codes saved on an Apple silicon Mac could be readable by everyone. It is bound as variadic now (VarArgs), and every file written is made 0600 through its descriptor (fchmod), checked. M1: macOS's sandbox lets an app reach the file the person chose and nothing beside it, so the file made beside it was refused and nothing could be saved; Learn's sandbox allowed reading chosen files alone, so its exports never saved on macOS either. There, writePrivately now writes the chosen file itself (decided by the developer on 21 Oct): opened without following a link or waiting on a pipe and without truncating, refused with nothing written unless fstat finds a file of the own user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing the files the person chooses. Linux keeps the new file renamed over it. The reading's nits: the buffer is wiped whatever happens, an interrupted call is made again (EINTR), the file made beside a chosen one is named apart from it so a name as long as a folder takes still saves, the folder is flushed after the rename, and Linux reads a file's type and owner with statx, alike on every architecture, where __lxstat's version differed (riscv64's was wrong). The test of another user's file is skipped as root. The in-place path is tested on Linux; none of it has run on a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 17:57:44 +02:00
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`, `FakePasswordGateway`, `FakeAccountCrypto`,
`FakeInstanceChecker`, 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 three sentences only it can
say: what the first start's instance keeps of the person's, what a switch
to another account leaves behind, and where GitHub is added later (shown
only where the instance offers no passwords).
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`, `signedOut`, `passwords`,
`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, callbackScheme: …, appName: …)`; the scheme is the
app's own (`app.halkyon.learn`), registered with each platform and in the
instance's GitHub OAuth app, whose loopback address on Linux and Windows is
`http://127.0.0.1/github-callback` (port 43823 unless given).
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`).