| D-M4-30-0 |
2026-10-23 |
The ticket was first built on an integration base and then ported onto main with only its own commits, after M3-23 and M4-25 landed; the English spec chapters of main win (15-client-architecture.md), and the changelog entry and these rulings are fragments (changelog.d/M4-30.md, docs/decisions.d/M4-30.md). Local verification runs the targeted tests of the touched packages; the full suite runs in the pull request CI. |
Build steering of 2026-10-21 and 2026-10-23 over the brief's landing steps. |
| D-M4-30-1 |
2026-10-21 |
Open thread guard: NotificationRouter.onEvent drops a notification.created whose thread is in the engine's focus (EngineFocus.threads) while the focus is not idle (EngineFocus.idle); otherwise it maps the payload with toast_content.dart and shows it with the tag thread:<id> and silent = quiet. security and admin use the tag kind:<kind> and open /settings/devices and /admin; an unknown kind without a thread shows nothing. Place: apps/client/lib/notifications/notification_router.dart. |
13 4.4 makes the server skip such a hint; the guard covers an event already on its way when the focus changes (01 10.3 row all). The focus is what the gateway sees, so client and server use one definition. |
| D-M4-30-2 |
2026-10-21 |
Without package identity (unpackaged Windows build until M7): the presenter initializes flutter_local_notifications with the app name of the build branding, the bundle identifier as app user model ID and an activation GUID derived from it (the first 16 bytes of SHA-256 over notifications:<bundle id> with name-based UUID bits), so no new branding key is needed. cancel and getActiveNotifications are used only with package identity, otherwise skipped with one log line without content; a slot is replaced by showing the next notification under the same ID (31-bit FNV-1a of the tag). A click reaches the running app through the plugin's activation callback and goes to the router like a deep link; a click that started the app comes from getNotificationAppLaunchDetails. On macOS the permission is asked once without awaiting it. Place: apps/client/lib/notifications/notification_presenter.dart. |
15 10.3: those functions need package identity; one stable GUID per branding keeps the activation registration of every build the same. |
| D-M4-30-3 |
2026-10-21 |
idle (13 4.4 delegates it to 15): the app reports idle: true in focus while its window is hidden to the notification area, minimized or not the foreground window, and idle: false as soon as it is the foreground window; no timer, no system idle time. windowFocused combines the app lifecycle, the Flutter window state and WindowBehavior.active (shown, not minimized, focused, from the events of window_manager). Recorded in 15 section 10. Place: apps/client/lib/desktop/window_behavior.dart, apps/client/lib/thread/providers.dart. |
Design note 3; the window state is what the person sees, a timer would borrow a configuration key. |
| D-M4-30-4 |
2026-10-21 |
Start at login per device: on Windows the value <app name> with the quoted path of the executable under HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run through win32_registry (BSD-3-Clause), on macOS the login item SMAppService.mainApp over the runner channel bothy/start_at_login (macOS 13 and later; on macOS 12 the settings say the system offers none). The value lives only in the system, is never written before the person turns it on and is never in user_settings. launch_at_startup was not taken: every version from 0.4 needs win32 5 through win32_registry 2, while flutter_secure_storage 11 needs win32 6, and the only resolvable version (0.0.1) predates null safety. Place: apps/client/lib/desktop/start_at_login.dart, apps/client/macos/Runner/AppDelegate.swift. |
Design note 4 and fixed requirement 9; the brief allows the package the license gate allows. |
| D-M4-30-5 |
2026-10-21 |
macOS uses the same presenter for notification.created while the app is connected (13 5.3); it gets no tray icon and no hide on close: closing the window keeps the system's behaviour of the runner (applicationShouldTerminateAfterLastWindowClosed). The tray and the hide on close are the Windows path (AppPorts.platform == windows). |
Goal of the brief and 15 10.3, which names the notification area for Windows only; macOS gets APNs in M5. |
| D-M4-30-6 |
2026-10-21 |
The tray icon is the runner's windows/runner/resources/app_icon.ico, declared as a Flutter asset because tray_manager reads asset paths. tray_manager stays at 0.5 (MIT, with menu_base, MIT, and shortid, BSD-3-Clause): 0.8 builds a C++ core through native build hooks on every platform. Runner changes: a second start without a link shows the running window (also hidden or minimized) instead of a second instance, and a forwarded link also shows a hidden window. Packages: window_manager 0.5 (MIT, with screen_retriever, MIT), flutter_local_notifications 22 (BSD-3-Clause); deps.dev knows no license for these pub packages, so osv-scanner.toml names each one with the license of its LICENSE file, as for the earlier pub packages. Place: apps/client/pubspec.yaml, osv-scanner.toml, apps/client/windows/runner/. |
15 10.3 names the packages; the window in the notification area must be the one a second start or a link reaches. |
| D-M4-30-7 |
2026-10-21 |
Toast content: title <Channel> ยท <Thread title>, only the channel without a thread title, the product name of the instance (or the app name) without a channel; text <Author>: <excerpt>, <Author>: New message without an excerpt (names), the placeholder alone without an author; a count above 1 adds the unread count as the first line. Without preview fields the channel name comes from the local database and the text stays the placeholder, also when the message is stored locally, so notify.preview_mode = none holds; until M3-22 adds the preview fields (D-M3-23-8) every toast shows this placeholder form. ToastContent.toString names no content. Place: apps/client/lib/notifications/toast_content.dart. |
01 10.2; the preview mode is the person's privacy choice and the client decides nothing beyond the server's fields (R-PN-01). |
| D-M4-30-8 |
2026-10-21 |
Engine and fake: the dispatcher hands notification.created to SyncEngine.notifications (NotificationCreated, HintKind) without storing it, and the ephemeral branch of a catch-up page now runs before the skip of unknown events, because the generated event union holds only durable types (typing, presence and run.updated inside a page were dropped before). FakeWorld.notify decides per recipient with the effective level of 13 3.1 and the kinds of 13 2 and sends to the live connections of the recipient's active devices (13 4.6, connectedDevices from the gateway), records no_route without one, and runs for every message with notifyOnMessages; preferences are set with setNotificationPref without REST and events until M4-33. Place: packages/sync_engine/lib/src/notifications.dart, dispatcher.dart, testing/world_notify.dart, testing/gateway_fake.dart. |
Brief Scope; M4-33 uses the same decision. |
| D-M4-30-9 |
2026-10-21 |
Quit runs InstanceServices.close (engine, database, auth session) and then ends the window; close is idempotent, because the app's providers close the services again when they are disposed. Place: apps/client/lib/desktop/providers.dart, apps/client/lib/app/bootstrap.dart. |
15 10.3: Quit ends the app and the connection; a second close must not fail. |
| D-M4-30-10 |
2026-10-21 |
Differences from the brief's names, following the code: NotificationPresenter.show takes a payload (the app path a click opens) besides tag and silent, and adds clicks, launchPayload and dispose; the wiring lives in lib/notifications/providers.dart and lib/desktop/providers.dart and touches lib/app/app.dart, lib/app/bootstrap.dart (four new ports of AppPorts), lib/thread/providers.dart (windowFocused) and the test support; the macOS runner channel of D-M4-30-4 is outside the brief's file list. The text of the settings note is "Notifications arrive only while the app is running.", because the term table maps "Run" to "Lauf". |
D-W-100, D-W-114: follow the merged code and list each difference. |
| D-M4-30-11 |
2026-10-23 |
A new GatewaySession (a new engine, as after an app start) answers its first hello with identify and the stored cursor, also when an earlier run of the app left a gateway session inside the resume window; later reconnects of the same engine resume as before (forceIdentify starts set and ready clears it). Reproduced in the Windows integration test of this ticket: after Quit and a restart the app resumed the old gateway session, got no snapshot and showed the channel without unread messages; flow_test and session_test hold the rule. Place: packages/sync_engine/lib/src/session.dart, 15 section 7.2. |
04 14: the counters of a session come from the snapshot of ready; resumed carries none, and the counters live only in the memory of the engine. |