Skip to content

M4-18 Desktop app shell

No Date Ruling Reason
D-M4-18-0 2026-10-17 Base int/m1-wave36 at 8493d3f, branch feat/M4-18-desktop-shell in its own worktree; the branch is not merged locally and not pushed, the PR lane lands it. The brief relies on no core rule or ruling that main has and the base lacks (D-W-100, D-W-114, D-W-116 and auth.device_pending_ttl in AppConfig are on the base), so nothing is ported. The brief's 15-client-architecture.md is 15-client-architektur.md on the base; the lines this ticket adds there are English. Differences to the brief's names, following the merged code: the local store is LocalDatabase and its wipe LocalDatabase.wipe; openLocalStore takes file and keys; the device key client is DeviceKeyClient with ensure, sign and delete(scope), whose StepUpPrompt (texts) is a different type from the StepUpPrompt port of auth (a question); LoginService has no discovery call, so E1 calls getDiscovery through the generated client; the fake server has no handlers for getSpace, getChannel and getThread, which no flow of this ticket needs against it (the target lookup is tested with a stub client). Instructions of the launcher, which override the brief's landing step; merged names win over the brief (D-W-100, D-W-114).
D-M4-18-1 2026-10-17 bootstrap.dart composes per server address (InstanceServices.open): the device keys with the instance_id of discovery as scope, InstanceSecrets with every key prefixed instance/<instance_id>/, the database key (32 bytes of Random.secure(), stored as database_key), the encrypted database <application support>/instances/<instance_id>/local.db, the AuthSession with browser, loopback receiver and (where present) passkey port, and the SyncEngine (keepConnectedInBackground). AppPorts holds every port and appPortsProvider hands it out, so tests replace all of them. On macOS only a release build uses the Data Protection keychain with the keychain access group; a debug or profile build is not signed by a team, has no access group and keeps the items in the login keychain. One instance never sees the secrets or keys of another, and a wipe can find everything by the instance ID; an access group without a team signature makes the app fail to launch.
D-M4-18-2 2026-10-17 One AccountWiper per instance for every end of an account: logout, E4 "Cancel", a rejected or expired waiting device, 4012, session.revoked, 401 session_revoked, and also a refresh token the server no longer accepts (E7 "session expired"). It stops the engine, wipes the database, deletes the instance folder (database files and file cache), both device keys and every instance secret, runs every step even when one fails and keeps only the server address. The controller reacts to the SignedOut events of the session and to the engine's SignedOutNotice through one guarded path. 01 5.6 shows "The data of this device has been deleted" on every E7; one path cannot forget a step for one cause.
D-M4-18-3 2026-10-17 E1 classifies discovery: a HandshakeException, a TlsException or Dio's badCertificate is "Certificate invalid" with no way to continue and no setting to trust a certificate; setup_required (flag or problem) is "Instance not set up yet"; a min_client_versions entry of the platform above the build's version (numeric major.minor.patch) is "App too old"; everything else is "No instance at this address". Only the integration test entry accepts http. The address is remembered after a found instance and asked again at the next start. R-PR-14: an exception button would let an attacker in the middle defeat DPoP and the device binding.
D-M4-18-4 2026-10-17 The router has the paths of 15 11 plus /onboarding/<step> and /settings/...; parseLocation is a pure function that checks IDs with the ID types of domain. The redirect follows the onboarding controller: signed out, a content path goes to the current screen and is kept, and after the login it opens. A target that is not in the local database and that REST answers with 404 or 403 shows "Not found or no access"; without an answer it waits. Deep links: macOS registers the URL type in Info.plist and the app delegate forwards links; the Windows runner registers the scheme under HKEY_CURRENT_USER\Software\Classes at every start (no administrator rights; MSIX of M7 replaces it), a second start with a link sends it to the running window by WM_COPYDATA and exits; both forward over the method channel bothy/deep_links (takePending for a link that started the app, open for later ones). 15 11; a per-user registration works for a local build without an installer.
D-M4-18-5 2026-10-17 Column names sidebar, post_list, history, session; the sized columns default to the layout tokens DsLayout.sidebarWidth, listWidth, sessionWidth, never below DsLayout.columnMin, and the history keeps at least columnMin. One drag is one putMySetting of layout.column_widths through the outbox (only the widths that differ from the defaults); the dragged widths stay until the stored setting comes back from user_settings. "Reset" is deleteMySetting (the setting's default is empty). Below ui.desktop_min_width_four_columns the session column is not shown, below ui.desktop_min_width_three_columns the post list and the history share one place. Until M4-25 the sidebar frame carries the "Log out" button. 01 5.1 and 11; a column that jumps back while the write travels would look broken.
D-M4-18-6 2026-10-17 CodeText shows a code in groups (four by default; the verification code of E4 in groups of three as the wireframe of 01 5.6 shows it) in the mono font of the tokens, offers copy without spaces, and its semantics label spells the code with commas between the characters while the visible groups are excluded from semantics. Remaining times are text ("Valid for 23 more minutes") from expires_at (E5) or created_at plus auth.device_pending_ttl (E4) and an injected clock, announced through the LiveAnnouncer of M4-07 at most once per a11y.live_announce_min_interval. 01 5.6 and 8: a screen reader must not read "481" as a number.
D-M4-18-7 2026-10-17 Build branding before M7: apps/client/branding.example.yaml (app_name ExampleChat, url_scheme chat-example, bundle_id com.example.chat, default_server https://chat.example.com) and tool/branding_defines.dart, which writes build/branding/dart_defines.json for --dart-define-from-file and the checked-in macos/Runner/Configs/Branding.xcconfig and windows/runner/branding.h; --check (and a test) holds the checked-in files equal to the example. The interface shows branding.product_name of the instance after discovery; the build's app name only names the window before it. Design note 7; one source for the Dart side and both runners.
D-M4-18-8 2026-10-17 Network status without connectivity_plus: LifecycleNetworkStatus reports online and emits "network may be back" on every return to the foreground; a lost network shows through the gateway's failed connects and heartbeat (04 6.5). No new dependency in the license and OSV gates; the engine already retries with backoff.
D-M4-18-9 2026-10-17 The desktop composition passes no PasskeyAuthenticator, because no package of the workspace bridges WebAuthn on macOS or Windows yet; E2 and E3 offer passkeys only when the ports have one (the tests and goldens inject one). E3 offers "Use this device" (redeemEnrollmentCode), "Create passkey" and "Link with" each login service; it offers no "Set up password and one-time code", because the API has no operation that sets a password and a TOTP secret while a code is redeemed (spec gap, reported). Fixed requirement 13 ("where offered"); P-11: no deviation from the contract.
D-M4-18-10 2026-10-17 When the poll of a waiting device ends because the server ended its session first (401 session_revoked before getDevice could report the revoke reason), E7 shows "not confirmed in time" once the app's own deadline (created_at plus auth.device_pending_ttl) has passed and "the confirmation was rejected" before it; a revoke reason that arrives is shown as it is. The device cannot read its revoke reason without a session; the deadline is the only fact it has.
D-M4-18-11 2026-10-17 E6 is built as a screen with its states and goldens and has its route /onboarding/locked; the onboarding controller never enters it, and "Unlock" does nothing until M5-08 builds the lock. Brief Out of scope.
D-M4-18-12 2026-10-17 The new pub packages of apps/client and their transitive packages get one PackageOverrides entry each in the root osv-scanner.toml (name and exact version), checked against the LICENSE file of that version: MIT (Riverpod, state_notifier, uuid, freezed_annotation, analyzer_buffer), Apache-2.0 (mockito) and BSD-3-Clause (the flutter.dev and Dart team packages, flutter_secure_storage, ffi_leak_tracker, win32). quality/licenses/allowed.txt is unchanged. deps.dev has no license data for these pub packages, so OSV-Scanner reports them as UNKNOWN (docs/quality/licenses.md).
D-M4-18-13 2026-10-17 The app remembers the public description of the last instance discovery found (app/instance: address, instance_id, product name, accent, login methods; no logo, no secret). A start or "Connect" whose discovery gets no answer at all (DiscoveryUnreachable: no network, host down) opens the remembered instance from its local data when its address matches and a refresh token is stored, and shows the main view (or E4 for a waiting device); otherwise E1 shows "No instance at this address". A found instance with a stored session resumes on every connect, not only at the start. 01 3.6 (offline reading) and 6.6 (the offline banner): a signed-in person must reach the local data without network.
D-M4-18-14 2026-10-17 Fixes after the red team: E1 leaves the "connecting" state when discovery found the instance and on E2 "Change"; stored column widths are fitted to the window (fitColumns: the sized columns shrink in proportion to their width above DsLayout.columnMin, so the history keeps its minimum); the onboarding card runs its main action on Enter only from the card itself or a text field, a focused button or radio keeps Enter for itself, and the text fields no longer submit on their own, so one Enter is one action. Red team round 1 (four reproduced Majors), each with a test.
D-M4-18-15 2026-10-17 One pure function routeStart (apps/client/lib/onboarding/start_route.dart) decides the screen of every start and "Connect" from what is stored for the instance (no session, a session of an active device, a session of a waiting device), the discovery result and what getDevice says about the device. A certificate error, setup_required and a too old app show E1 with their text; a stored session of the remembered instance at that address otherwise opens its local data whatever else the network answered (no answer, a timeout, a 4xx or 5xx page of a reverse proxy, a captive portal): the main view, or E4 for a waiting device, where the server's answer about the device wins over the stored state; without a session a found instance shows E2 and everything else "No instance at this address". E4 stores its verification code and created_at as the instance secret onboarding/pending_device (deleted at the login and by the wiper), so a waiting device started offline shows E4 with its code and polls until the network returns. This refines D-M4-18-13, which opened the local data only without any answer. A table test runs every stored state times every network result times "comes online" or "stays offline" through the controller against the fake server. Delta red team: a proxy's 502, 503, 504 sent a signed-in person to E1, and a waiting device started offline landed in the empty main view; both were instances of one routing rule spread over two places.
D-M4-18-16 2026-10-18 The ticket was ported from its integration base onto main with only its own commits, stacked on the port of M4-17; the English docs of main win: the two paragraphs of 15 9 and 15 11 sit in the English chapter, and code comments point to 01-product.md and 15-client-architecture.md. The lock file is resolved again on main (flutter pub get, only dart_style differs from the base, as on main), the two goldens of E1 with the "app too old" banner are rerendered, and the goldens compare with DsGoldenFileComparator (PL/5). sonar.coverage.exclusions leaves apps/*/macos/**, apps/*/windows/** and apps/*/tool/** out of the coverage measure. The skip line of the Riverpod generation step is codegen-riverpod-dart, like the drift and Pigeon steps. osv-scanner.toml gets the license overrides of flutter_driver, integration_test, fuchsia_remote_debug_protocol, flutter_web_plugins, sync_http and webdriver, which the base had from M6-12 and M6-02. The fake server of M4-17 lists the local login driver in auth_providers of discovery, as the server does (loginoptions lists every enabled provider); without it E2 showed no login name and every integration test of the app failed on the desktop runners. Main carries its own value of color.warning.subtle (palette.amber.100), which the banner shows (as D-M4-07-9 found); without the comparator the CI goldens recorded on macOS fail on the Linux runner at anti-aliased edges, as those of design_system and bothy_viz did. The runners run only on the platform runners and write no report the Linux scan reads (as D-M4-10-7 for the plugin members), and flutter test --coverage records only lib/, so the tested branding tool counted as uncovered (as D-M4-06-8 for the members' tools).
D-M4-18-17 2026-10-19 The fake server of sync_engine carries a gzip and base64 copy of docs/api/openapi.yaml and docs/api/asyncapi.yaml in lib/src/testing/contract_documents.g.dart (ContractSchemas.embedded); ContractSchemas.load still walks up to docs/api/ and uses the copy only when there is none. tools/catalog/contract_documents_dart.py writes it (make contract-documents), and make codegen-check decodes it and fails when its text differs from docs/api/, so a change to the API documents needs a regeneration. The macOS app sandbox stays on in every build configuration; no asset is added to the app. Steering ruling 2026-10-19: the app's integration tests ran on a device for the first time, and inside the macOS app sandbox the fake could not read the contract files (working directory /, no access to the repository), so no route and no contract check existed. A Flutter asset would ship 1 MB of YAML in every release build, a sandbox exception would weaken debug builds; the copy is test code in package:sync_engine/testing.dart, which no release build imports (tools/arch-check rule 10). The copy is random-looking text, so .gitleaks.toml allows its path; docs/api/ itself stays scanned.
D-M4-18-18 2026-10-19 The Windows failure "file in use" when a test deleted its data folder is a test teardown issue, not a product one: the product wipe (AccountWiper, every sign-out cause) awaits the engine stop and LocalDatabase.wipe, which closes the database before it deletes the files, and only then deletes the instance folder; logout_wipes_test.dart checks on the Windows job that the folder is gone. Only a test that removes the app leaves the close to the controller's dispose, which does not wait (the app quits). FakeInstance.stop retries the delete, within a bounded time, until that close is done. Steering ruling 2026-10-19: check the product path first and fix it if it has the same bug; reading the code and the device run of the wipe test show it does not.
D-M4-18-19 2026-10-19 apps/client/platform-tests.json runs each integration test file in its own flutter test <file> -d macos or -d windows call; a new file needs its line there. On both desktop runners every file after the first failed with "Unable to start the app on the device" (the log reader of the next app start stopped) so each file gets its own call and a fresh app process, and a failure names its file.
D-M4-18-20 2026-10-19 Discovery accepts an instance_id only as a UUID (canonicalUuid of domain, stored in lowercase); any other value is "no instance" in E1, and a remembered instance whose ID is no UUID is not read back. Review finding on PR #138: the ID names the data folder, the device key scope and the secret prefix, and the wipe deletes that folder recursively, so a server answering .. could make a sign-out delete data outside its instance.
D-M4-18-21 2026-10-20 A test that removes the app waits for the close of its services before the fake stops and the data folder goes: InstanceServices.close returns the one close begun first (the app's dispose begins it without waiting), and TestApp.stop and SecondApp.stop await it. The bounded retry of FakeInstance (D-M4-18-18, D-M4-25-9) stays only as the last resort. The same failure came back on the Windows runner after the retry: pairing_test and login_pending_confirm_test failed with PathAccessException (errno 32) on client-flow* once the bounded retry (closeTimeout) ran out, so the close the dispose began had not ended while the fake stopped under it. Awaited before the fake stops, the close ended at once in every repeated run of the three affected files on the Windows runner (a diagnostic build on 2026-10-20), and no delete needed a retry. Test: services_close_test.dart.