English · Português
dovetail_platform_channel
The channel between Flutter and the desktop operating system. Window, tray, login item, deep link, notification and handing off to the default application — on Windows, macOS and Linux.
When Tauri leaves, the webview goes with it, and with the webview go things nobody had listed: the borderless window, the tray icon, opening a link in the system browser. This package is where those capabilities live.
The boundary
Flutter desktop app <- the business rules, and only here
├── your bridge package <- channel to the Rust core
├── dovetail_platform_channel <- this package: channel to the OS
└── dovetail_rust_core <- Dart<->Rust mechanics
This package decides nothing. It does not hide the window instead of closing it, does not enable autostart on first run, does not choose the tooltip's text. It exposes the capability and returns the event; the app decides.
The clearest example: WindowSurface.closeRequests() is a Stream.
Close-to-tray is policy, and policy belongs to the app.
Installation
dependencies:
dovetail_platform_channel: ^0.1.0
Usage
import 'package:dovetail_platform_channel/dovetail_platform_channel.dart';
Future<void> main() async {
final SingleInstanceVerdict verdict =
await DesktopPlatformChannel.claimSingleInstance('com.example.app');
if (!verdict.mayRun) {
return;
}
final DesktopPlatform platform = await DesktopPlatformChannel.ensureInitialized(
const DesktopAppSpec(
window: WindowSpec(
size: Size(1200, 720),
minimumSize: Size(1024, 640),
),
applicationId: 'com.example.app',
displayName: 'Example',
notificationGuid: '00000000-0000-0000-0000-000000000000',
),
);
platform.window.closeRequests().listen((_) => platform.window.hide());
await platform.tray.attach(
icon: const TrayIconAsset('assets/tray.png'),
menu: const <TrayEntry>[
TrayCommand(id: 'open', label: 'Open'),
TraySeparator(),
TrayCommand(id: 'quit', label: 'Quit'),
],
);
platform.tray.commands().listen(handleTrayCommand);
runApp(const App());
}
None of the numbers here are package defaults: 1200x720 and the name come
from the app, because window size and name are a product decision — and under
white-labelling, a reseller's.
The capability probe
Instead of Platform.isLinux scattered through the app:
if (platform.supports(PlatformCapability.trayTooltip)) {
await platform.tray.setTooltip(message);
}
The table as measured today:
| Capability | Windows | macOS | Linux |
|---|---|---|---|
| borderless window, prevent close, skip taskbar | ✓ | ✓ | ✓ |
| tray icon and menu | ✓ | ✓ | ✓ |
| tray tooltip | ✓ | ✓ | ✗ |
| panel anchored to the tray | ✓ | ✓ | ✗ |
| login item | ✓ | ✓ | ✓ |
| deep link | ✓ | ✓ | ✓ |
| notification | ✓ | ✓ | ✓ |
| open in the default application | ✓ | ✓ | ✓ |
| single instance | ✗ | ✓ | ✓ |
A deep link that arrives while the app is already running comes through a
second process, not through app_links. The deepLinks the channel delivers
joins both sources: whoever listens to links() sees the system's URL as well
as the one the single-instance guard forwarded. Without that join, a
ForwardedLaunch arrived with the URL in arguments and never reached the
inbox.
On Windows claimSingleInstance answers unavailable, not primary: the
named guard exists in windows/single_instance_guard.cpp and is not yet wired
to Dart, so two clicks open two windows and each deep link opens a third.
mayRun is true in both states that may proceed — use it to decide whether
to run, and isGuarded to know whether the guarantee actually exists on this
platform.
The tooltip does not exist on Linux because tray_manager does not implement
it there. Since the tooltip is today the only channel for a message that
matters, setTooltip falls back to a disabled entry at the top of the menu
instead of vanishing silently — and that entry never appears in commands().
The anchored panel does not exist on Linux for two reasons combined:
StatusNotifierItem publishes a menu and nothing else, and Wayland does not
let a client position its own toplevel. There, MenuOnlyPanelSurface
reports that there is no panel instead of showing an empty window.
Single instance is ✓ on macOS and Linux, where a Unix domain socket solves it —
dart:io supports them on those two. On Windows it does not: there it needs a
named mutex plus a hidden window with WM_COPYDATA, and that is C++ that
has never been compiled, because no machine here is Windows. It is declared
false on purpose, with a test, so nobody confuses "written" with "works".
Surfaces
| Surface | Contract |
|---|---|
WindowSurface |
show/hide/focus/minimize/restore/maximize, dragging by a custom bar, prevent close, skip taskbar, always-on-top, setBounds/bounds, frameChanges(), closeRequests() |
TraySurface |
icon, menu as sealed data, tooltip with the Linux fallback, commands(), gestures() |
PanelSurface |
opens the main window anchored to the tray, dismissals(); on Linux it reports absence |
DisplayProbe |
cursor point, work area of the display under a point, all work areas |
WindowPlacement · WindowStateStore · WindowStateKeeper |
remembers size, position and maximised state between sessions, when DesktopAppSpec.stateDirectory says where |
SingleInstanceVerdict · ForwardedLaunch |
primary/secondary, and the arguments the second instance forwarded |
LaunchAtLogin |
isEnabled · enable · disable |
DeepLinkInbox |
initialLink() · links() |
SystemNotifier |
show · cancel · cancelAll |
ExternalOpener |
openUrl · canOpenUrl |
BundleInfo |
name, version, build, identifier |
Window state is opt-in for one reason: the package does not choose where to
write on the user's disk. Pass stateDirectory in the DesktopAppSpec — the
app's support directory, typically — and the channel loads the saved position,
clamps it to the displays that exist now, and starts saving it back. Without
it, nothing is read or written.
A position saved on a display that was disconnected between sessions is not applied: the window would open where the user cannot find it. And saving waits for the window to settle, because dragging emits one event per pixel and writing on each turns a window move into hundreds of disk writes.
The menu is data, not a callback: TrayCommand, TraySeparator and
TraySubmenu in a sealed hierarchy. The id comes back through commands() and
the app decides what to do — the same as what Tauri did by emitting an event
instead of calling a function.
What does not come in here
- Clipboard. Flutter already does it, with
Clipboard.setData. A package for that would be dead weight. - Registering the deep link scheme. On Windows it is a write to
HKLMthat only the elevated installer performs; on Linux it is a.desktopfile installed by the package. It belongs to the installer, not to the running app. - Installing the privileged service and the updater. Those belong to the installer and the pipeline.
- Any policy. See above.
Development
flutter test
flutter analyze
The decisions, and what was left out, are in ARCHITECTURE.md.