M4-18: Desktop app shell¶
Status: ready | Depends on: M4-07, M4-11, M4-17 | Milestone: M4
Goal¶
After this ticket apps/client is a desktop app for macOS and Windows that a person can install from a local build, connect to an instance, log in with, get confirmed on, and log out of: the platform folders macos/ and windows/, the composition of the packages (device keys of M4-10 and M4-11 behind the signer ports of auth, secrets and the database key in secure storage, the encrypted local_store, the SyncEngine of M4-17), Riverpod providers that read only from the database and the engine (15 4), the router with the paths of 15 11 and deep links from outside, the column layout of D1 and D2 with draggable widths stored as layout.column_widths and the collapse widths of 01 14, and the onboarding screens E1 to E7 of 01 5.6 as cards in an empty window with the branding of the instance. Logout and revocation delete every local datum of the account (R-SY-14). The columns stay empty frames that M4-19 to M4-25 fill. Why: every desktop feature ticket needs a running app with login, a local database and a connected engine; and the onboarding screens carry the security rules of 08 (no content before login, no exception button on a bad certificate, codes read out character by character, R-PR-14).
Read first¶
docs/PLAN.md: section 3; section 10 (Goal, Exit criteria items 2 and 4, Test strategy, Risks; rows M4-18 to M4-25, M4-30, M4-31, M4-34).docs/spec/01-product.md: 4 "Information architecture", 5.1 D1 and D2 (columns, widths, collapse), 5.6 "Onboarding and sign-in" (rules for all screens, E1 to E7 with their texts and results), 6.6 and 6.7 ("Not found or no access"), 8 (accessibility rules for codes, focus, text scaling), 9, 11 (layout.column_widths,appearance.*), 12 "Branding per instance", 13 (R-PR-01, R-PR-14), 14 (ui.desktop_min_width_four_columns,ui.desktop_min_width_three_columns,a11y.live_announce_min_interval).docs/spec/15-client-architecture.md: 1, 2 (macOS 12, Windows 10 2004), 3 (apps), 3.1 (rule 4: platform code only indevice_keyandapps/client), 4 "State", 5, 6.3 (key in secure storage;4012deletes database, file cache, keys and tokens), 8 (login in the system browser, loopback redirect on desktop), 9 (secure storage per platform), 11 "Deep links and navigation", 15 (4007shows "Update required"), 18 (switches from the server), 20 (rows "Widget", "Golden", "Integration").docs/spec/08-identity.md: 2.2, 2.4, 2.5 (login methods and what E2 offers), 3.2 (enrollment code input rules), 5.1 to 5.4 (registration, approval with the verification code, pairing, no key change), 7.4 and 8 (sessions), 9 (app lock: only the screen here), 10 (lost device), 14.1 and 14.2 (flows).docs/spec/04-sync-protocol.md8.5 and 13 (what the client deletes and keeps);docs/spec/16-operations.md3.1 (setup wizard for E1);docs/spec/18-release.md(minimum client versions for E1).docs/api/openapi.yaml:getDiscovery(Discovery,AuthProviderInfo,PublicBranding,ClientVersionRequirement,setup_required), the login and device operations named indocs/briefs/M4-09-auth-dpop.md,getInstance(InstanceInfo,InstanceLimits),getMe,getBrandingLogo.docs/design/components.md(cards, buttons, inputs, banners) anddocs/design/README.md(previews of the onboarding screens if present).- ADRs
0027,0029,0044-working-name-and-branding-as-configuration.md(build branding),0056(no Flutter engine in extensions; not used on desktop). docs/DECISIONS.md: D-W-100, D-W-114, D-W-115, D-W-116 (this ticket depends on M4-11 for the device keys;auth.device_pending_ttlinAppConfig); section 10.- Briefs:
docs/briefs/M4-17-fake-server.md(SyncEngine,FakeServer,ScenarioClient, test-only plain HTTP),docs/briefs/M4-09-auth-dpop.md(LoginService,AuthSession, ports,AuthEvent),docs/briefs/M4-10-device-key-apple.mdanddocs/briefs/M4-11-device-key-windows.md(DeviceKeyClient, errors),docs/briefs/M4-12-local-store.md(openLocalStore,DatabaseKeySource,wipe),docs/briefs/M4-07-core-components.md(components, view models),docs/briefs/M4-04-design-system-theme.md(DsThemeScope,resolveDsContext,dsGoldenMatrix),docs/briefs/M4-06-l10n.md(ARB rules,problemText,resolveAppLocale),docs/briefs/M4-01-workspace-arch.md(platform-tests.json, CI jobs).
This ticket touches no API operation, no table of chapter 03, no action, no event and no configuration key.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n, never as literals in widgets. - Limits only through named keys:
ui.desktop_min_width_four_columns,ui.desktop_min_width_three_columns,a11y.live_announce_min_intervalandauth.device_pending_ttl(D-W-116) fromAppConfig; the pairing time fromexpires_atofbeginPairing; no literal duration or width in code. - Specification first: 01 5.6 and 15 11 do not say how a desktop build gets its URL scheme and app name before
branding.yamlexists (ADR 0044, distribution in M7); this ticket follows Design note 7 and states it in 15 11.
Product requirements implemented here:
- Before login only name, logo and accent from
getDiscovery(branding); no content of the instance (01 5.6). - E1: "Connect" calls
getDiscovery; results "No instance at this address", "Certificate invalid" (no continue, no exception button), "Instance not set up yet" onsetup_required, "App too old" belowmin_client_versions; the app remembers the address (01 5.6, R-PR-14). - E2 offers exactly the methods
getDiscoverynames (OIDC providers, proxy header, local login name with passkey where offered and password with one-time code iflocal_password_enabled, always "I have an enrollment code" and "Pair with another device"); without a method "No login is set up for this instance". - Codes in groups, mono font, copyable, read character by character by screen readers; input ignores case, spaces and hyphens; remaining times as text announced at most once per
a11y.live_announce_min_interval(01 5.6). - E4 shows the verification code of the waiting device and polls; confirmation leads to the main view, rejection or expiry to E7; "Cancel" logs out and deletes the keys (01 5.6, 08 5.2).
- E5 shows the pairing code with its remaining time and "New code"; after the approval on another device the new device is active (08 5.3).
- Logout, revocation (
4012,session.revoked,401 session_revoked) and a rejected device delete the local database, the file cache, the keys and the tokens, and show E7 with the reason and "The data of this device has been deleted" (R-SY-14, 01 5.6). - OIDC runs in the system browser with a loopback redirect on
127.0.0.1with a free port, never in a web view (15 8, RFC 8252). - The router has one path per target (15 11); a link to something invisible shows "Not found or no access" (01 6.7).
- Widgets read only from providers over the local database and the engine (R-CL-03); the app shows
branding.product_name, never a literal name.
Scope¶
Create or fill in apps/client/:
- Platform folders from
flutter create --platforms=macos,windows --org com.exampleinapps/client(only these two platforms), with the macOS entitlements for the keychain access group and outgoing network, the macOS URL type and the Windows protocol registration for the deep link scheme of Design note 7;platform-tests.jsonwithmacosandwindowsentries that runflutter test integration_test -d <platform>(Design note 9). lib/main.dartandintegration_test/support/test_app.dart(the test entry that allows plain HTTP for the fake server, M4-17 Design note 8; it lives in a test folder, so no release build contains it),lib/app/(app.dart,bootstrap.dart: composition root of the app, Design note 1),lib/app/providers/(Riverpod providers with code generation: engine, auth, store, instance, settings, connection state),lib/app/router.dart(Design note 4),lib/app/deep_links.dart,lib/app/layout/(desktop_shell.dart,column_layout.dart,column_widths.dart, Design note 5),lib/onboarding/(one file per screen E1 to E7 plusonboarding_controller.dart,code_text.dartfor grouped codes, Design note 6),lib/platform/(secure_store.dartonflutter_secure_storage,device_key_signer.dartadapters to theauthports,loopback_redirect.dart,browser_launcher.dartonurl_launcher,network_status.dart,app_lifecycle.dart,frame_ticker.dart),lib/app/branding.dart(build branding, Design note 7).branding.example.yamlatapps/client/(the example of ADR 0044 withexample.comvalues) andtool/branding_defines.dartthat turns it into--dart-define-from-fileinput (Design note 7).- Tests:
test/onboarding/(widget tests),test/goldens/onboarding/(golden files),test/app/(router, column widths, providers),integration_test/(Tests). - Fake server additions in
packages/sync_engine/lib/src/testing/through the handler registry and world controls of M4-17, only where a flow of this ticket needs one that M4-17 lacks. - ARB keys in
packages/l10n/lib/l10n/app_*.arbfor every text of E1 to E7, the column frames and "Not found or no access" (where M4-06 has not added them yet).
Change:
apps/client/pubspec.yaml(memberssync_engine,auth,local_store,domain,api_client,device_key,design_system,l10n,admin_uionly if used;flutter_riverpod,riverpod_annotation,go_router,flutter_secure_storage,url_launcher,connectivity_plusor the network status source Design note 1 chooses; dev:riverpod_generator,build_runner,integration_test,alchemist),apps/client/README.md(how to build and run on macOS and Windows, the test entry, branding defines).- Root
Makefilecodegen-check: the Riverpod generation ofapps/clientcompared like the other generated Dart code (M4-01). docs/spec/15-client-architecture.md11 (desktop deep link registration and the build branding of Design note 7), 9 (where the app keeps the database key and tokens on macOS and Windows).docs/DECISIONS.mdsection 10: rowsD-M4-18-<n>; rootCHANGELOG.mdunder[Unreleased].
Out of scope¶
- Sidebar content, post list, thread view, session column content (M4-19 to M4-21); search and quick switcher (M4-23); shortcuts beyond Enter and Esc on the onboarding cards (M4-24); settings, devices and sessions (M4-25), including confirming another device with its code (the active-device side of E4 and E5).
- The app lock logic (M5-08): this ticket builds the E6 screen with its states and goldens only.
- iOS and Android folders (M5), web (console, M6).
- Notification area, local notifications, MSIX packaging (M4-30, M7); auto-update and signing (M7);
branding.yamlprocessing for icons, bundle IDs and feeds (M7). - Client metrics and crash reports (M4-31).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-18-desktop-shell; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(client): ...,feat(sync-engine): ...,build(gates): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only viamktemp -d(DartDirectory.systemTemp.createTemp, pytesttmp_path), removed at the end. Stop only processes you started, by PID. - Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of
make checkdo themselves. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH(and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows
branding.product_namefrom the instance, never a literal. - No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
- If the core contract (chapters 00, 03, 04, 05) or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - Integration tests run on a desktop (
flutter test integration_test -d macosor-d windows); on a host without a desktop sessionmake apps-platformprints its SKIP line and theapps-macosandapps-windowsCI jobs are the evidence. The report names where they ran. - Tests never touch the login keychain or the credential store of the build host outside a test-only service name that the test deletes in teardown; the integration test entry uses an in-memory secret store and an in-memory key source unless a test is about the real store.
- The fake server of M4-17 is the only server in tests; no test calls a real instance.
Design notes¶
-
Composition root.
bootstrap.dartbuilds, per server address: theDeviceKeyClient(M4-10, M4-11) with an adapter per purpose that implementsDeviceKeySignerof M4-09 (scope = the server'sinstance_idfrom discovery;algES256; JWK and thumbprint fromDeviceKeyInfo); theSecretStoreonflutter_secure_storage(macOS: Data Protection keychain,synchronizable: false, accessibilityfirst_unlock_this_device; Windows: the package's default) with keys prefixed by the instance ID; theDatabaseKeySourcethat creates 32 random bytes withRandom.secure()at the first start and stores them in the secret store;openLocalStorewith a file under the application support folder (path_provider), one file per instance ID; theAuthSessionandLoginService; and theSyncEngineof M4-17 with the platform ports oflib/platform/. Riverpod overrides replace every port in tests (15 4). Network status: useconnectivity_plus(publisher fluttercommunity.dev, BSD-3-Clause) only ifmake licensesandmake osvstay green; otherwise derive "network changed" from failed connects andAppLifecycleresume, and record which. -
Sign-out and wipe. One
AccountWiper.wipe(reason)used by logout, E4 "Cancel", a rejected or expired device,4012,session.revokedand401 session_revoked: stop the engine,LocalStore.wipe(), delete the file cache folder of the instance, delete both device keys (DeviceKeyClient.delete(scope)), delete the secrets of the instance (tokens, database key, device ID), keep only the server address (01 5.6: the app remembers the address), then show E7 with the reason text (devices.revoke_reasonor the session reason mapped throughl10n, never an internal name). A test asserts that after a wipe no file of the instance remains and the secret store has no key of the instance. -
Onboarding flow.
OnboardingController(a RiverpodNotifier) drives E1 to E7 from theLoginServiceoutcomes of M4-09: E1 discovery result to E2; E2 method to the flow;pendingto E4 with polling (pollPendingDevice) and the remaining time computed from the device'screated_atplusauth.device_pending_ttlfromAppConfig(D-W-116: theDeviceschema names no expiry; the value is display only, the server decides the expiry and the poll reports it);signedInto the main view;signedOutto E7. E3: the code field, then the methods the instance offers for the redeemed code (passkey, password with one-time code including the QR step, link with a login service), redeemed codes make the device active at once. E5:beginPairingshows the code and the remaining time, "New code" starts a new pairing, the poll ends in the main view or, on expiry, back to E5 with the expiry text. E6: the screen with the unlock button, the failure state and "Log out" (wired to the wiper); no timer (M5-08). E1 certificate errors: aHandshakeExceptionorTlsExceptionfrom the discovery request shows "Certificate invalid" with no way to continue; there is no setting to trust a certificate. "App too old": compare the app version (from the build) with themin_client_versionsentry of the platform (macosorwindows) by semantic version. -
Router and deep links.
go_routerwith the paths of 15 11 (/s/:spaceId,/c/:channelId,/t/:threadId?m=,/running,/mentions,/search?q=,/admin/..., plus/onboarding/...for E1 to E7 and/settings/...); every content route requires a signed-in state and redirects to onboarding otherwise. A target that is not in the local database and that REST answers with404shows "Not found or no access" (01 6.7, the same text for "does not exist" and "no access"). Deep links from outside:<scheme>://t/<thread_id>?m=<message_id>(ands,c) arrive through the platform (macOS URL type through the app delegate, Windows protocol activation through the runner's command line forwarded over a method channel) and go to the router; a link that arrives before login is kept and opened after the login; a link to a thread that is not loaded waits until it is loaded locally or through REST, then jumps to the message (15 11). The route parser is a pure function with unit tests. -
Columns.
ColumnLayoutwith the columnssidebar,post_list,history,session(names of 01 11layout.column_widths): drag handles between columns, widths saved through the personal settinglayout.column_widths(an outbox write ofputMySettingthrough the executor of M4-15) and read back fromuser_settings; belowui.desktop_min_width_four_columnsthe session column collapses, belowui.desktop_min_width_three_columnsthe post list becomes a stack; "Reset" restores the defaults. Each column is an empty frame with a test key that later tickets fill. The connection state of the engine appears as the offline banner ofDsBanner(01 6.6) at the top of the content. -
Codes and remaining times.
CodeText(String code, {int groupSize = 4})(groups of four, as 01 5.6 and 24 4.3 show them) renders groups infont.family.mono, offers copy, and sets a semantics label that spells the code character by character (letters and digits separated so screen readers read each one, for example "4, 8, 1, 2, 0, 7"); code input fields accept paste and normalize like M4-09. Remaining times are a text ("Valid for 23 more minutes") fromexpires_at(pairing) or fromcreated_atplusauth.device_pending_ttl(waiting device) and an injected clock; aLiveAnnouncer(M4-07) announces changes at most once pera11y.live_announce_min_interval. -
Build branding. Until the distribution of M7 reads
branding.yaml, the desktop build takesapp_name,url_scheme, bundle identifier and default server from--dart-define-from-filegenerated bytool/branding_defines.dartfromapps/client/branding.example.yaml(neutral values underexample.com, a neutral working app name that is not the product name, schemechat-example); the macOSInfo.plistand the Windows runner read the same file through the Flutter build variables. The interface showsbranding.product_namefrom the instance after discovery and the build app name only where the system needs one (window title before discovery). Ruling for docs/DECISIONS.md and a sentence in 15 11. -
Goldens. E1 to E7 as cards (desktop form of 01 5.6) through
dsGoldenMatrixin light and dark, text scaling 1 and 2,deanden, densitycomfortable; E1 in its states (empty, connecting, no instance, certificate invalid, not set up, app too old), E2 with three discovery fixtures (OIDC only; local with passkey and password; nothing), E3, E4, E5, E6, E7 (revoked by another device; disabled person without the login button). -
Integration tests.
integration_test/runs the app fromintegration_test/support/test_app.dartagainstFakeServer(M4-17) on loopback and implementsScenarioClientof M4-17 over the app's engine providers, so the five scenarios of 04 16 run in the app process.platform-tests.jsonlists["flutter", "test", "integration_test", "-d", "macos"]and the Windows equivalent; the CI jobs of M4-01 run them. -
Rulings for docs/DECISIONS.md (
D-M4-18-<n>): composition per server address and the secret key names (note 1); one wiper for every sign-out cause (note 2); certificate errors without an exception path (note 3); route set and the deep link wait (note 4); column names and the setting (note 5); code semantics (note 6); build branding before M7 (note 7); network status source (note 1).
Tests (write them first)¶
Widget and golden tests (flutter test, fake LoginService, fake discovery, fake clock):
test/onboarding/e1_test.dart: each discovery result leads to its text; a TLS error offers no continue;setup_requiredshows the setup pointer; an app version belowmin_client_versionsshows "App too old"; the address is remembered.test/onboarding/e2_test.dart: the buttons equal the methods of the discovery fixture, nothing more; "No login is set up" without methods; Enter triggers the main action and Esc goes back.test/onboarding/codes_test.dart: a code is grouped in the mono font, copyable, and its semantics label spells it character by character; pasted input with spaces, hyphens and lower case is normalized.test/onboarding/e4_e5_test.dart: remaining time as text (E4 fromcreated_atplusauth.device_pending_ttl, E5 fromexpires_at), announced at most once per interval; "Cancel" calls the wiper.test/onboarding/e7_test.dart: reason texts per revoke reason; no login button for a disabled person.test/goldens/onboarding/*: Design note 8.test/app/router_test.dart: route parsing of every path of 15 11 and of external links; an unknown target shows "Not found or no access"; a link before login is opened after it.test/app/column_widths_test.dart: widths are written tolayout.column_widthsand restored; collapse at the two widths fromAppConfig.test/app/wiper_test.dart: afterwipeno file of the instance and no secret of the instance remains; the server address stays.
Integration tests (integration_test/, against FakeServer, run on macOS and Windows):
login_pending_confirm_test.dart: login with password and one-time code, the device is pending (E4 shows the code), the fake approves it from a second simulated device, the main view appears with the sidebar frame.pending_rejected_expired_test.dart: rejection (wrong code on the other device) and expiry lead to E7 and a wiped store.pairing_test.dart: E5 pairing with confirmation, with rejection and with expiry and "New code".logout_wipes_test.dart: logout deletes database, file cache, keys and tokens (R-SY-14);4012from the fake does the same.scenarios_test.dart: the five scenarios of M4-17 through the app's engine.
Commands that must pass, from the repository root:
cd apps/client && flutter analyze --fatal-infos && flutter test --coverage- on macOS and on Windows:
make apps-platform(runs the integration tests); elsewhere it prints its SKIP line make codegen-check,make arch,make lint,make fmt-check,make coverage,make licenses,make osvmake check
Acceptance criteria¶
- Integration test: login, waiting device, confirmation, and logout deletes local data (R-SY-14), green on macOS and Windows in the
apps-macosandapps-windowsjobs. - Widget and golden tests of E1 to E7 in light and dark with text scaling 1 and 2; E1 with
setup_required, an invalid certificate and the minimum version; E2 shows only the methods fromgetDiscovery; codes are read out character by character. - E4 and E5 with confirmation, rejection and expiry against the fake server.
- Router with the paths of 15 11, deep links from outside on both platforms, "Not found or no access" for invisible targets.
- Column layout with stored widths and the collapse widths of 01 14; widgets read only from providers.
- 15 9 and 11 updated; rulings recorded.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why and cite 01, 08 or 15.
apps/client/README.md(English) as in Scope.- 15 updated; rulings of Design note 10 in
docs/DECISIONS.mdsection 10. - Root
CHANGELOG.mdentry under 'Unreleased'. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- Where the integration tests ran (macOS and Windows versions, CI or a named class of machine), the run time of the integration suite, the network status source chosen, and how a deep link was checked on each platform.
- What is not proven (for example a platform test that ran only in CI, or a check done by hand).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.