Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
216 lines
13 KiB
Markdown
216 lines
13 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` keeps a person
|
|
signed in, signing in anonymously at a first start and asking how to start
|
|
once signed out (`StartScreen`); `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`): whose account on which instance,
|
|
switching to the account on another, signing out (which of an anonymous
|
|
account deletes it, so asks first) 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 anonymously and with GitHub (the system
|
|
browser and a loopback address on Linux and Windows, `LoopbackWebAuth`),
|
|
through the instance's `halkyon_base`, and puts a session granted elsewhere
|
|
in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
|
|
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.
|
|
Where the instance offers passwords, GitHub is added to nobody (ADR 0017):
|
|
`gitHubOffer` says so from its basics, `GitHubSignInStateReady.linking`
|
|
hands it to the screens, which then offer no Add, and the start screen
|
|
offers an account to create in place of the app's sentence on adding
|
|
GitHub; `gitHubProblemText` says why a GitHub sign-in was refused.
|
|
- **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. Anonymous people get no
|
|
keys until something is encrypted (step 4). Every build of an app needs a C
|
|
compiler and make: sodium's build hook compiles libsodium.
|
|
- **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`, `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`).
|