From af100136ac795c1a4ab86f88022734b0b907e9ef Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 10:28:50 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(linux):=20add=20workmanager=5Flinux=20?= =?UTF-8?q?=E2=80=94=20systemd-based=20background=20execution=20(fixes=20#?= =?UTF-8?q?324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs.json | 4 + docs/index.mdx | 3 +- docs/linux.mdx | 145 +++++++ melos.yaml | 1 + workmanager/README.md | 10 + workmanager/lib/src/workmanager_impl.dart | 3 + workmanager/pubspec.yaml | 2 + workmanager_linux/CHANGELOG.md | 10 + workmanager_linux/DESIGN.md | 93 ++++ workmanager_linux/LICENSE | 21 + workmanager_linux/README.md | 126 ++++++ workmanager_linux/analysis_options.yaml | 5 + workmanager_linux/lib/execution.dart | 76 ++++ .../lib/src/background_runner.dart | 101 +++++ workmanager_linux/lib/src/payload_store.dart | 83 ++++ workmanager_linux/lib/src/process_runner.dart | 39 ++ workmanager_linux/lib/src/systemd.dart | 159 +++++++ workmanager_linux/lib/src/systemd_units.dart | 81 ++++ workmanager_linux/lib/workmanager_linux.dart | 400 ++++++++++++++++++ workmanager_linux/pubspec.yaml | 22 + .../test/background_runner_test.dart | 151 +++++++ .../test/command_construction_test.dart | 274 ++++++++++++ .../test/payload_store_test.dart | 88 ++++ .../test/workmanager_linux_test.dart | 309 ++++++++++++++ 24 files changed, 2205 insertions(+), 1 deletion(-) create mode 100644 docs/linux.mdx create mode 100644 workmanager_linux/CHANGELOG.md create mode 100644 workmanager_linux/DESIGN.md create mode 100644 workmanager_linux/LICENSE create mode 100644 workmanager_linux/README.md create mode 100644 workmanager_linux/analysis_options.yaml create mode 100644 workmanager_linux/lib/execution.dart create mode 100644 workmanager_linux/lib/src/background_runner.dart create mode 100644 workmanager_linux/lib/src/payload_store.dart create mode 100644 workmanager_linux/lib/src/process_runner.dart create mode 100644 workmanager_linux/lib/src/systemd.dart create mode 100644 workmanager_linux/lib/src/systemd_units.dart create mode 100644 workmanager_linux/lib/workmanager_linux.dart create mode 100644 workmanager_linux/pubspec.yaml create mode 100644 workmanager_linux/test/background_runner_test.dart create mode 100644 workmanager_linux/test/command_construction_test.dart create mode 100644 workmanager_linux/test/payload_store_test.dart create mode 100644 workmanager_linux/test/workmanager_linux_test.dart diff --git a/docs.json b/docs.json index 085ccf59..192d9bd0 100644 --- a/docs.json +++ b/docs.json @@ -45,6 +45,10 @@ "title": "Web (experimental)", "href": "/web" }, + { + "title": "Linux (experimental)", + "href": "/linux" + }, { "title": "Troubleshooting", "href": "/troubleshooting" diff --git a/docs/index.mdx b/docs/index.mdx index c30a9d39..f0121be6 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -20,7 +20,8 @@ Execute Dart code in the background, even when your app is closed. Perfect for d | iOS | ✅ Full | Background Fetch + BGTaskScheduler APIs | | macOS | ⚠️ Partial | One-off + periodic tasks via NSBackgroundActivityScheduler while the app is running (not after quit) | | Web | ⚠️ Experimental | Service Worker + Web Worker based background execution (`workmanager_web`) — see [Web (experimental)](web) | -| Windows/Linux | ❌ Not supported | No background task APIs | +| Linux | ⚠️ Experimental | systemd user units based background execution (`workmanager_linux`) — see [Linux (experimental)](linux) | +| Windows | ❌ Not supported | No background task APIs | ## Platform Capability Matrix diff --git a/docs/linux.mdx b/docs/linux.mdx new file mode 100644 index 00000000..ec69b3c8 --- /dev/null +++ b/docs/linux.mdx @@ -0,0 +1,145 @@ +--- +title: "Linux (experimental)" +description: systemd-based background execution on Linux +--- + +Experimental Linux support is provided by a new `workmanager_linux` package. +Unlike the web, Linux has a real OS scheduler, so tasks are scheduled with +**systemd user units** and actually run when the app is closed. The package is +pure Dart (no native code, no Pigeon): it drives `systemctl --user` / +`systemd-run --user` through an injectable process runner. + +## How it works + +| Task type | Mechanism | +|---|---| +| One-off | Transient units via `systemd-run --user --unit=workmanager- --on-active=` (or `--no-block` for immediate runs). Transient units vanish after the task ran. | +| Periodic | A `.timer`/`.service` unit pair in `~/.config/systemd/user/`. `OnUnitActiveSec` drives the frequency, `OnStartupSec` the initial delay, and `Persistent=true` provides WorkManager-style catch-up of runs missed while the system was off. | + +When a timer fires, the service launches your app binary with +`--background-task --payload ` in a headless mode. Your +`main()` detects that invocation, runs the callback dispatcher, and exits +instead of opening a window. The payload is the `inputData` JSON persisted at +registration time under `$XDG_DATA_HOME/workmanager/payloads/` (Android-style +on-disk payload). + +## Requirements + +- A systemd-based Linux distribution with a systemd **user session**. +- Flatpak/Snap sandboxing is not supported (apps there cannot write user + units). + +## Setup + +### 1. Dependencies + +```yaml +dependencies: + workmanager: ^0.10.0 + workmanager_linux: ^0.1.0 +``` + +The main `workmanager` package auto-delegates to `workmanager_linux` on +Linux, so scheduling through `Workmanager()` just works: + +```dart +Workmanager().initialize(callbackDispatcher); +Workmanager().registerOneOffTask( + "task-id", + "sync", + initialDelay: Duration(minutes: 5), +); +Workmanager().registerPeriodicTask( + "periodic-id", + "sync", + frequency: Duration(hours: 1), +); +``` + +### 2. Headless `main()` + +Your `main()` must check for the `--background-task` invocation before +starting the UI: + +```dart +import 'package:workmanager/workmanager.dart'; +import 'package:workmanager_linux/workmanager_linux.dart'; + +Future main(List args) async { + if (await WorkmanagerLinux.maybeRunBackgroundTask(args, callbackDispatcher)) { + // Launched headless by systemd: the task ran, the result was logged and + // the process exited (0 = success, 1 = failure). + return; + } + runApp(const MyApp()); +} + +@pragma('vm:entry-point') +void callbackDispatcher() { + WorkmanagerLinux.executeTask((taskName, inputData) async { + // Your background work here. Flutter plugins are allowed. + return true; + }); +} +``` + +Note the dispatcher registers with `WorkmanagerLinux.executeTask` — not +`Workmanager().executeTask` — because the headless process has no native +platform-channel counterpart to handshake with. This mirrors how +`workmanager_web` uses its `WorkmanagerExecution` registry. + +### 3. Verify scheduling + +```bash +systemctl --user list-timers # workmanager-*.timer units appear +journalctl --user -u 'workmanager-*' # task runs and failures +``` + +## ⚠️ The `enable-linger` caveat + +systemd user units only run while the **user session is active**. When the +app is launched from a normal desktop session this is a non-issue, but if +you expect tasks to run while nobody is logged in graphically (or after +logout), enable linger for the user: + +```bash +sudo loginctl enable-linger $USER +``` + +Linger keeps the user's systemd manager (and its timers) running without a +login session. On some desktops, user services additionally only start after +the user's first graphical login of the machine. + +Also make sure the environment the app runs in can reach the user manager +(`XDG_RUNTIME_DIR`/DBus). Desktop sessions set this up automatically. + +## Honest limitations + +- **Constraints are accepted but ignored** (`networkType`, `requiresCharging`, + ...). No battery/AC/network gating in v1. +- **Backoff policy is accepted but ignored** — failed one-off tasks are not + retried; a failed periodic task just waits for the next interval. +- **`existingWorkPolicy` is effectively `REPLACE`** — re-registering a unique + name overwrites the units; `KEEP` is not implemented. +- **Tags are accepted but not tracked** — `cancelByTag` throws + `UnsupportedError` (cancel by unique name or `cancelAll` instead). +- **Frequency is honored as-is** (no Android-style 15-minute floor; systemd + resolves to whole seconds). +- **iOS-only task types** (`registerProcessingTask`, health research, + continued processing) throw `UnsupportedError`. +- `isScheduledByUniqueName` maps to `systemctl is-active` on the task's + timer unit; `printScheduledTasks` returns the raw `systemctl list-timers` + lines for workmanager units. + +## Testing + +`workmanager_linux` is pure Dart with an injectable process runner, so the +whole test suite runs anywhere — no systemd needed: + +```bash +cd workmanager_linux +dart test +``` + +See the [package README](../../workmanager_linux/README.md) and +[DESIGN.md](../../workmanager_linux/DESIGN.md) for details. diff --git a/melos.yaml b/melos.yaml index 7494b037..97426b12 100644 --- a/melos.yaml +++ b/melos.yaml @@ -4,6 +4,7 @@ packages: - workmanager_platform_interface - workmanager_android - workmanager_apple + - workmanager_linux - workmanager_web - example scripts: diff --git a/workmanager/README.md b/workmanager/README.md index db2c4657..2e8b2713 100644 --- a/workmanager/README.md +++ b/workmanager/README.md @@ -8,6 +8,14 @@ > PWA required, Flutter-free dispatcher). See > [workmanager_web/README.md](../workmanager_web/README.md). +> ⚠️ **Experimental Linux support** is available through the new +> `workmanager_linux` package (systemd user units: `systemd-run` transient +> units for one-off tasks, `.timer`/`.service` unit pairs for periodic +> tasks, with `Persistent=true` catch-up). Tasks launch the app in headless +> `--background-task` mode. Requires a systemd user session; constraints, +> backoff and tags are not supported yet. See +> [workmanager_linux/README.md](../workmanager_linux/README.md). + [![pub package](https://img.shields.io/pub/v/workmanager.svg)](https://pub.dartlang.org/packages/workmanager) [![pub points](https://img.shields.io/pub/points/workmanager)](https://pub.dev/packages/workmanager/score) @@ -61,6 +69,8 @@ This plugin uses a federated architecture with platform-specific implementations - **workmanager**: Main package providing the unified API - **workmanager_android**: Android implementation using WorkManager - **workmanager_apple**: iOS implementation using BGTaskScheduler + macOS implementation using NSBackgroundActivityScheduler +- **workmanager_web**: Web implementation using Service Worker + Web Worker (experimental) +- **workmanager_linux**: Linux implementation using systemd user units (experimental) ## 🐛 Support & Issues diff --git a/workmanager/lib/src/workmanager_impl.dart b/workmanager/lib/src/workmanager_impl.dart index 51784bcb..77c20ce4 100644 --- a/workmanager/lib/src/workmanager_impl.dart +++ b/workmanager/lib/src/workmanager_impl.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import 'package:workmanager_platform_interface/workmanager_platform_interface.dart'; import 'package:workmanager_android/workmanager_android.dart'; import 'package:workmanager_apple/workmanager_apple.dart'; +import 'package:workmanager_linux/workmanager_linux.dart'; import 'package:workmanager_web/workmanager_web.dart'; /// Function that executes your background work. @@ -113,6 +114,8 @@ class Workmanager { WorkmanagerPlatform.instance = WorkmanagerAndroid(); } else if (Platform.isIOS || Platform.isMacOS) { WorkmanagerPlatform.instance = WorkmanagerApple(); + } else if (Platform.isLinux) { + WorkmanagerPlatform.instance = WorkmanagerLinux(); } } } diff --git a/workmanager/pubspec.yaml b/workmanager/pubspec.yaml index f79c2952..a2bdfb98 100644 --- a/workmanager/pubspec.yaml +++ b/workmanager/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: workmanager_android: ^0.10.3 workmanager_apple: ^0.9.9 workmanager_web: ^0.1.3 + workmanager_linux: + path: ../workmanager_linux dev_dependencies: test: ^1.25.15 diff --git a/workmanager_linux/CHANGELOG.md b/workmanager_linux/CHANGELOG.md new file mode 100644 index 00000000..a5c342b4 --- /dev/null +++ b/workmanager_linux/CHANGELOG.md @@ -0,0 +1,10 @@ +## 0.1.0 + +- Experimental Linux implementation of `workmanager` using systemd user + units: + - one-off tasks via transient `systemd-run --user` units, + - periodic tasks via `.timer`/`.service` unit pairs + (`OnUnitActiveSec`, `Persistent=true` for catch-up), + - headless `--background-task` execution mode for the callback dispatcher. +- No native code, no Pigeon: pure Dart talking to `systemctl`/`systemd-run` + through an injectable process runner. diff --git a/workmanager_linux/DESIGN.md b/workmanager_linux/DESIGN.md new file mode 100644 index 00000000..21030a50 --- /dev/null +++ b/workmanager_linux/DESIGN.md @@ -0,0 +1,93 @@ +# Design notes + +This document records the decisions behind `workmanager_linux` (2026-08-03). + +## Goal + +Implement the workmanager contract — "execute a Dart callback in the +background even when the app is closed" — on Linux, as honestly and testably +as possible, as a PR the maintainer can run and review. Linux has a real OS +scheduler (unlike the web), so this is a real implementation, not an +approximation: systemd *user* units. + +## Chosen strategy: systemd user units + headless `--background-task` mode + +The design doc (`docs/desktop-support.mdx`) compared systemd user units with +a self-daemonizing Dart isolate and chose systemd for v1: it is OS-native, +survives crashes, and provides catch-up semantics out of the box. + +- **One-off tasks** → transient units via + `systemd-run --user --unit=workmanager- --on-active=`. + `--no-block` + no timer trigger for immediate runs. `--collect` unloads the + transient units after completion (even on failure), so failed one-off runs + don't linger as failed units. +- **Periodic tasks** → a `.timer`/`.service` pair in + `~/.config/systemd/user/`: + - `OnUnitActiveSec=` — re-fires this long after the previous run. + - `OnStartupSec=` — one-shot first-fire offset (exact + initialDelay semantics for free, since systemd timers fire when *any* + directive elapses). + - `Persistent=true` — WorkManager-style catch-up of runs missed while the + system was off. + - `Type=oneshot` service — the unit completes when the app process exits, + so a failing task shows up as a failed unit in the journal. +- **Execution** → the unit runs the app binary + (`Platform.resolvedExecutable` embedded at registration time) with + `--background-task --payload `. The app's `main()` calls + `WorkmanagerLinux.maybeRunBackgroundTask(args, callbackDispatcher)` which + detects the invocation, runs the dispatcher, invokes the handler and exits + with `0`/`1`. + +## Why pure Dart with an injectable process runner + +No Pigeon and no native plugin: everything is `systemctl --user` / +`systemd-run --user` invocations plus unit files. All external effects flow +through a `ProcessRunner` abstraction (defaulting to `Process.run`), and the +units/payload directories are constructor-injectable, so the whole suite is +pure Dart unit tests that never touch systemd. + +## Deterministic naming instead of a registry + +systemd unit names only allow `[a-zA-Z0-9:_.\-]` and unique names are +user-controlled. Instead of storing a registry, every unit name and payload +path derives from a stable 32-bit FNV-1a hash of the `uniqueName` +(`workmanager-.timer/.service`, payload +`$XDG_DATA_HOME/workmanager/payloads/workmanager-.json`). Registering, +querying (`is-active`), cancelling and listing all re-derive the same names +with no bookkeeping, and re-registering naturally replaces the previous +units. + +## What is intentionally not implemented (v1) + +- **Constraints** (network/battery/charging): accepted, ignored. The design + doc's DBus/NetworkManager shims are real work; punted. +- **Backoff**: accepted, ignored. No retry semantics for failed one-off tasks + in v1 (periodic tasks just wait for the next interval). +- **`existingWorkPolicy`**: effectively `REPLACE`; `KEEP` not implemented. +- **Tags / `cancelByTag`**: tags are accepted but not tracked, so + `cancelByTag` throws `UnsupportedError`. A payload-side tag registry would + make this implementable later. +- **iOS-only task types** (`registerProcessingTask`, health research, + continued processing): `UnsupportedError`. +- **`workmanager` core parity details**: `printScheduledTasks` returns raw + `systemctl list-timers` lines filtered to workmanager units. + +## Headless dispatcher registration + +The headless process runs the full Flutter engine, so the dispatcher *may* +use Flutter plugins — unlike the web worker bundle. But it must register via +`WorkmanagerLinux.executeTask` (a `WorkmanagerExecution`-style registry, +mirroring `workmanager_web`'s `execution.dart`) instead of +`Workmanager().executeTask`: the latter awaits a platform-channel handshake +(`backgroundChannelInitialized`) that has no native counterpart on Linux and +would hang/throw in a headless process. + +## Known follow-ups + +- Migrate to the `BackgroundTaskResult` enum with #712 (this package and + `workmanager_web` implement the current `Future` API). +- `workmanager/test/backward_compatibility_test.dart` still expects a + placeholder (`UnimplementedError`) on Linux hosts; once the Windows port + lands too, that expectation should be updated. +- CI wiring for `dart test` in `workmanager_linux` (a melos test script + entry or a GitHub Actions job). diff --git a/workmanager_linux/LICENSE b/workmanager_linux/LICENSE new file mode 100644 index 00000000..a26bf35d --- /dev/null +++ b/workmanager_linux/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 The Flutter Workmanager Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/workmanager_linux/README.md b/workmanager_linux/README.md new file mode 100644 index 00000000..4f988063 --- /dev/null +++ b/workmanager_linux/README.md @@ -0,0 +1,126 @@ +# workmanager_linux + +Experimental Linux implementation of `workmanager` for Flutter, backed by +**systemd user units**. Unlike the web, Linux has a real OS scheduler, so the +plugin contract — "execute Dart code in the background, even when the app is +closed" — is implemented with actual systemd timers, not an approximation. + +> ⚠️ **EXPERIMENTAL.** Read [Honest limitations](#honest-limitations) before +> using this package. Requires a systemd user session; Flatpak/Snap sandboxing +> is not supported yet. + +## How it works + +| Task type | Mechanism | +|---|---| +| One-off | Transient units via `systemd-run --user --unit=workmanager- --on-active=` (or `--no-block` for immediate runs). Transient units vanish after the task ran. | +| Periodic | A `.timer`/`.service` unit pair in `~/.config/systemd/user/`. The timer uses `OnUnitActiveSec` for the frequency and `Persistent=true` for WorkManager-style catch-up of runs missed while the system was off. | + +The service/timer launches your app binary in **headless mode** with +`--background-task --payload `. The payload is the +`inputData` JSON persisted at registration time (Android-style on-disk +payload) under `$XDG_DATA_HOME/workmanager/payloads/`. + +There is no native code and no Pigeon: the package is pure Dart that shells +out to `systemctl --user` / `systemd-run --user` through an injectable +process runner (so all tests run without systemd). + +## Setup + +### 1. Requirements + +- A systemd-based Linux distribution with a **user session**. +- The user session must be able to reach the systemd user manager. When the + app is launched from a normal desktop session this just works; for + headless/autostart contexts run `loginctl enable-linger $USER` so user + units keep running after logout (see the caveat below). +- Flatpak and Snap sandboxes cannot write user units — punted for v1. + +### 2. Add the dependency + +```yaml +dependencies: + workmanager: ^0.10.0 + workmanager_linux: ^0.1.0 +``` + +The main `workmanager` package delegates to this package on Linux +automatically, so `Workmanager()` works out of the box. Registering with the +main package API is all you need for scheduling: + +```dart +Workmanager().initialize(callbackDispatcher); +Workmanager().registerOneOffTask("task-id", "sync", initialDelay: Duration(minutes: 5)); +Workmanager().registerPeriodicTask("periodic-id", "sync", frequency: Duration(hours: 1)); +``` + +### 3. Headless `main()` + +Your `main()` must detect the `--background-task` invocation and run the +callback instead of starting the UI: + +```dart +Future main(List args) async { + if (await WorkmanagerLinux.maybeRunBackgroundTask(args, callbackDispatcher)) { + // The process was launched headless by systemd to run a background task. + // The result was logged and the process already exited (0 = success). + return; + } + runApp(const MyApp()); +} + +@pragma('vm:entry-point') +void callbackDispatcher() { + // Register with WorkmanagerLinux.executeTask — not Workmanager().executeTask — + // because the headless process has no native platform-channel counterpart. + WorkmanagerLinux.executeTask((taskName, inputData) async { + print("Background task: $taskName"); + // Your background work here. Flutter plugins are allowed. + return true; + }); +} +``` + +The dispatcher's exit code is the task result: `0` on success, `1` on failure +or when no handler was registered — so failed runs show up as failed units in +`journalctl --user`. + +## Honest limitations + +- **Constraints are accepted but ignored** (`networkType`, `requiresCharging`, + ...). No battery/AC/network gating in v1. +- **Backoff policy is accepted but ignored** — failed one-off tasks are not + retried. A failed periodic task simply waits for the next interval. +- **`existingWorkPolicy` is effectively `REPLACE`**: re-registering a unique + name overwrites the units (the previous timer is replaced). `KEEP` is not + implemented. +- **Tags are accepted but not tracked**, so `cancelByTag` throws + `UnsupportedError` (a tag registry is needed; cancel by unique name or use + `cancelAll` instead). +- **Frequency is honored as-is** (no Android-style 15-minute floor; systemd + supports arbitrary intervals). Frequencies below 1 minute are not useful in + practice — systemd resolves `OnUnitActiveSec` to whole seconds. +- **User timers stop while the user is logged out** unless + `loginctl enable-linger $USER` is set. On many desktops, systemd user + services also only run after the first graphical login of that user. +- `registerProcessingTask`, `registerHealthResearchTask`, + `registerContinuedProcessingTask` throw `UnsupportedError` (iOS-only task + types). +- Flatpak/Snap sandboxing is unsupported (no way to write user units). + +## Testing + +The package is pure Dart with an injectable process runner — the test suite +asserts the exact `systemctl`/`systemd-run` commands, unit file contents, +payload round-trips and the headless argument parsing, and runs anywhere +without systemd: + +```bash +cd workmanager_linux +dart test +``` + +## Design + +See [DESIGN.md](DESIGN.md) for the decisions behind the systemd-based +approach. diff --git a/workmanager_linux/analysis_options.yaml b/workmanager_linux/analysis_options.yaml new file mode 100644 index 00000000..fde48859 --- /dev/null +++ b/workmanager_linux/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +linter: + rules: + - public_member_api_docs diff --git a/workmanager_linux/lib/execution.dart b/workmanager_linux/lib/execution.dart new file mode 100644 index 00000000..0811fa0a --- /dev/null +++ b/workmanager_linux/lib/execution.dart @@ -0,0 +1,76 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +/// Flutter-free execution registry shared by the interactive app and the +/// headless `--background-task` process. +/// +/// The headless process launched by a systemd unit runs the whole Flutter +/// engine (unlike the web worker bundle), so the dispatcher may use Flutter +/// plugins. The small execution contract still lives in this Flutter-free +/// library so the dispatcher can register its handler without touching +/// platform channels (there is no native counterpart on Linux to talk to). +/// +/// A dispatcher written for Linux registers its handler with +/// [WorkmanagerExecution.executeTask]: +/// +/// ```dart +/// @pragma('vm:entry-point') +/// void callbackDispatcher() { +/// WorkmanagerLinux.executeTask((taskName, inputData) async { +/// // Background work. Flutter plugins are allowed here. +/// return true; +/// }); +/// } +/// ``` +library; + +/// Signature of the handler invoked when a background task runs. +/// +/// [taskName] is the value passed when registering the task; [inputData] is +/// the registered input data (int, bool, double, String and their +/// lists/maps), loaded from the on-disk payload. +typedef BackgroundTaskHandler = Future Function( + String taskName, + Map? inputData, +); + +/// Shared registry that holds the currently registered background task +/// handler. +/// +/// One instance is shared by every execution context: the interactive app +/// (where the dispatcher registers its handler) and the headless +/// `--background-task` process (where the handler is invoked). Keeping the +/// registry in a plain class means the headless runner never has to set up +/// Flutter platform channels. +class WorkmanagerExecution { + WorkmanagerExecution._(); + + /// The process-wide singleton. + static final WorkmanagerExecution instance = WorkmanagerExecution._(); + + /// The handler registered by the most recent [executeTask] call. + BackgroundTaskHandler? taskHandler; + + /// The callback dispatcher passed to + /// `WorkmanagerLinux().initialize(...)` (or directly to + /// [WorkmanagerLinux.maybeRunBackgroundTask]). + Function? callbackDispatcher; + + /// Registers [handler] as the background task handler (mirrors + /// `Workmanager().executeTask(...)`). + void executeTask(BackgroundTaskHandler handler) { + taskHandler = handler; + } + + /// Runs the registered handler with [taskName] and [inputData]. + /// + /// Returns `false` when no handler has been registered yet. + Future runTask(String taskName, Map? inputData) { + final handler = taskHandler; + if (handler == null) { + return Future.value(false); + } + return handler(taskName, inputData); + } +} diff --git a/workmanager_linux/lib/src/background_runner.dart b/workmanager_linux/lib/src/background_runner.dart new file mode 100644 index 00000000..36a26323 --- /dev/null +++ b/workmanager_linux/lib/src/background_runner.dart @@ -0,0 +1,101 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import '../execution.dart'; +import 'payload_store.dart'; + +/// A parsed `--background-task` invocation from the process arguments. +/// +/// A systemd unit launches the app with `--background-task ` (and +/// optionally `--payload ` pointing at the JSON payload written at +/// registration time); the app's `main()` hands its arguments to +/// [WorkmanagerLinux.maybeRunBackgroundTask], which detects and runs the +/// invocation. +class BackgroundTaskInvocation { + /// Creates an invocation. + const BackgroundTaskInvocation({required this.taskName, this.payloadPath}); + + /// The task name passed to the background task handler. + final String taskName; + + /// Path of the JSON payload file, when one was passed. + final String? payloadPath; + + /// Parses [args] for a `--background-task` invocation. + /// + /// Returns `null` when [args] do not contain `--background-task` followed + /// by a task name (the app should start normally). + static BackgroundTaskInvocation? tryParse(List args) { + final index = args.indexOf('--background-task'); + if (index < 0 || index + 1 >= args.length) { + return null; + } + final taskName = args[index + 1]; + String? payloadPath; + final payloadIndex = args.indexOf('--payload'); + if (payloadIndex >= 0 && payloadIndex + 1 < args.length) { + payloadPath = args[payloadIndex + 1]; + } + return BackgroundTaskInvocation( + taskName: taskName, + payloadPath: payloadPath, + ); + } +} + +/// Executes a background invocation in the headless process. +/// +/// The flow mirrors the interactive app: the [BackgroundTaskInvocation] is +/// loaded, the callback dispatcher runs so it can register its handler with +/// [WorkmanagerExecution.executeTask], the handler is invoked with the +/// payload, and the result is reported. +class BackgroundTaskRunner { + /// Creates a runner. + /// + /// [payloadDirectory] roots the [PayloadStore] used to load payloads; it + /// only matters when a relative `--payload` path is passed. + BackgroundTaskRunner({String? payloadDirectory}) + : _payloadStore = PayloadStore( + Directory(payloadDirectory ?? Directory.systemTemp.path), + ); + + final PayloadStore _payloadStore; + + /// Runs [invocation]. + /// + /// Returns `true` when the registered handler reported success, `false` + /// when the handler failed, threw, or was never registered (no handler + /// called [WorkmanagerExecution.executeTask]). + Future run( + BackgroundTaskInvocation invocation, + Function callbackDispatcher, + ) async { + final inputData = invocation.payloadPath == null + ? null + : await _payloadStore.load(invocation.payloadPath!); + try { + callbackDispatcher(); + } on Object catch (error, stackTrace) { + stderr.writeln( + 'workmanager_linux: callback dispatcher threw for task ' + '"${invocation.taskName}": $error\n$stackTrace', + ); + return false; + } + try { + return await WorkmanagerExecution.instance.runTask( + invocation.taskName, + inputData, + ); + } on Object catch (error, stackTrace) { + stderr.writeln( + 'workmanager_linux: background task "${invocation.taskName}" threw: ' + '$error\n$stackTrace', + ); + return false; + } + } +} diff --git a/workmanager_linux/lib/src/payload_store.dart b/workmanager_linux/lib/src/payload_store.dart new file mode 100644 index 00000000..0368cf49 --- /dev/null +++ b/workmanager_linux/lib/src/payload_store.dart @@ -0,0 +1,83 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'systemd.dart'; + +/// Persists task `inputData` to disk (Android-style on-disk payload) so the +/// headless `--background-task` process can load it back later. +/// +/// Each payload is a JSON file named after the deterministic hash of the +/// task's [uniqueName], so re-registering a task overwrites its payload and +/// cancelling a task can remove it without scanning systemd state. +class PayloadStore { + /// Creates a store rooted at [directory]. + PayloadStore(this.directory); + + /// Directory the payload JSON files live in. + final Directory directory; + + /// Returns the payload file path for [uniqueName]. + String payloadPath(String uniqueName) { + return '${directory.path}/workmanager-${SystemdNames.hash(uniqueName)}.json'; + } + + /// Writes [inputData] for [uniqueName] and returns the file path, or `null` + /// when [inputData] is `null` (nothing to persist; the headless process + /// then receives `null` input data). + Future write( + String uniqueName, + Map? inputData, + ) async { + if (inputData == null) { + return null; + } + final file = File(payloadPath(uniqueName)); + await file.parent.create(recursive: true); + await file.writeAsString(jsonEncode(inputData)); + return file.path; + } + + /// Loads and decodes the payload at [path]. + /// + /// Returns `null` when the file is missing or not valid JSON (the caller + /// treats that as "no input data" and keeps running). + Future?> load(String path) async { + try { + final file = File(path); + if (!await file.exists()) { + return null; + } + final decoded = jsonDecode(await file.readAsString()); + if (decoded is Map) { + return decoded.cast(); + } + return null; + } on Object { + return null; + } + } + + /// Deletes the payload for [uniqueName], if present. + Future delete(String uniqueName) async { + final file = File(payloadPath(uniqueName)); + if (await file.exists()) { + await file.delete(); + } + } + + /// Deletes every payload file in the store. + Future clear() async { + if (!await directory.exists()) { + return; + } + await for (final entity in directory.list()) { + if (entity is File) { + await entity.delete(); + } + } + } +} diff --git a/workmanager_linux/lib/src/process_runner.dart b/workmanager_linux/lib/src/process_runner.dart new file mode 100644 index 00000000..f2d69e0f --- /dev/null +++ b/workmanager_linux/lib/src/process_runner.dart @@ -0,0 +1,39 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +/// Runs external processes for the workmanager Linux implementation. +/// +/// Swappable in tests so no real `systemctl`/`systemd-run` is ever needed. +abstract class ProcessRunner { + /// Runs [executable] with [arguments] and returns the result. + Future run( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + }); +} + +/// Default [ProcessRunner] backed by [Process.run]. +class SystemProcessRunner implements ProcessRunner { + /// Creates a runner. + const SystemProcessRunner(); + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + }) { + return Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + ); + } +} diff --git a/workmanager_linux/lib/src/systemd.dart b/workmanager_linux/lib/src/systemd.dart new file mode 100644 index 00000000..3f78a100 --- /dev/null +++ b/workmanager_linux/lib/src/systemd.dart @@ -0,0 +1,159 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +/// Deterministic systemd unit naming and command construction for the +/// workmanager Linux implementation. +/// +/// Everything here is pure string/list construction: the platform +/// implementation feeds the results to a [ProcessRunner], and tests assert on +/// them with a fake runner — no systemd required. +library; + +/// Derives systemd unit names from task [uniqueName]s. +class SystemdNames { + SystemdNames._(); + + /// Prefix of every unit created by this package. + static const String unitPrefix = 'workmanager-'; + + /// Stable 32-bit FNV-1a hash of [uniqueName], hex-encoded. + /// + /// Used instead of the raw unique name because systemd unit names only + /// allow `[a-zA-Z0-9:_.\-]` and unique names are user-controlled. The hash + /// is deterministic across processes, so registering, querying and + /// cancelling a task all derive the same unit name without a registry. + static String hash(String uniqueName) { + var hash = 0x811c9dc5; // FNV-1a 32-bit offset basis. + const prime = 0x01000193; // FNV-1a 32-bit prime. + for (final codeUnit in uniqueName.codeUnits) { + hash ^= codeUnit; + hash = (hash * prime) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); + } + + /// Base unit name (no `.timer`/`.service` suffix) for [uniqueName]. + static String unit(String uniqueName) => '$unitPrefix${hash(uniqueName)}'; + + /// Timer unit name for [uniqueName]. + static String timerUnit(String uniqueName) => '${unit(uniqueName)}.timer'; + + /// Service unit name for [uniqueName]. + static String serviceUnit(String uniqueName) => '${unit(uniqueName)}.service'; + + /// Sanitizes a human-readable [description] for use in a unit file + /// `Description=` line (no newlines or control characters). + static String description(String description) { + return description.replaceAll(RegExp(r'[\x00-\x1f\x7f]'), ' '); + } +} + +/// Builds the argument lists for the `systemctl` / `systemd-run` invocations +/// used to schedule, query and cancel tasks. +class SystemdCommands { + /// Creates a command builder. + /// + /// [systemctl] and [systemdRun] are the executables to invoke (usually + /// `systemctl` and `systemd-run`; injectable for tests). + const SystemdCommands({required this.systemctl, required this.systemdRun}); + + /// The `systemctl` executable. + final String systemctl; + + /// The `systemd-run` executable. + final String systemdRun; + + /// Schedules a one-off task. + /// + /// A positive [delay] produces a transient timer + /// (`--on-active=`); a zero delay runs the command immediately as + /// a transient service (`--no-block`). `--collect` unloads the transient + /// units after they finish, even on failure, so failed one-off runs do not + /// linger in the unit list. + List runOneOff({ + required String unit, + required Duration delay, + required List appCommand, + }) { + final base = [systemdRun, '--user', '--collect', '--unit=$unit']; + if (delay > Duration.zero) { + base.add('--on-active=${delay.inSeconds}'); + } else { + base.add('--no-block'); + } + return [...base, ...appCommand]; + } + + /// Reloads the user manager after unit files changed on disk. + List daemonReload() => [systemctl, '--user', 'daemon-reload']; + + /// Enables and starts [timerUnit] (used for periodic tasks). + List enableNow(String timerUnit) => + [systemctl, '--user', 'enable', '--now', timerUnit]; + + /// Stops the timer and service units for [unit]. + List stop(String unit) => [ + systemctl, '--user', 'stop', '$unit.timer', + '$unit.service', // + ]; + + /// Disables [timerUnit] so it does not start on the next login/boot. + List disable(String timerUnit) => + [systemctl, '--user', 'disable', timerUnit]; + + /// Clears the failed state of the units for [unit]. + List resetFailed(String unit) => [ + systemctl, '--user', 'reset-failed', '$unit.timer', + '$unit.service', // + ]; + + /// Returns whether [timerUnit] is currently active. + /// + /// `systemctl is-active` exits 0 only when the unit is active; every other + /// state (inactive, failed, unknown) is treated as "not scheduled". + List isActive(String timerUnit) => + [systemctl, '--user', 'is-active', timerUnit]; + + /// Lists all timers known to the user manager, machine readable. + List listTimers() => [ + systemctl, + '--user', + 'list-timers', + '--all', + '--no-legend', + '--plain', + ]; + + /// Stops every workmanager timer (used by `cancelAll`). + List stopAllTimers() => [ + systemctl, + '--user', + 'stop', + '${SystemdNames.unitPrefix}*.timer', + ]; + + /// Stops every workmanager service (used by `cancelAll`). + List stopAllServices() => [ + systemctl, + '--user', + 'stop', + '${SystemdNames.unitPrefix}*.service', + ]; + + /// Disables every workmanager timer (used by `cancelAll`). + List disableAllTimers() => [ + systemctl, + '--user', + 'disable', + '${SystemdNames.unitPrefix}*.timer', + ]; + + /// Clears the failed state of every workmanager unit (used by `cancelAll`). + List resetFailedAll() => [ + systemctl, + '--user', + 'reset-failed', + '${SystemdNames.unitPrefix}*', + ]; +} diff --git a/workmanager_linux/lib/src/systemd_units.dart b/workmanager_linux/lib/src/systemd_units.dart new file mode 100644 index 00000000..2fba90d0 --- /dev/null +++ b/workmanager_linux/lib/src/systemd_units.dart @@ -0,0 +1,81 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'systemd.dart'; + +/// Builds the content of the persistent systemd user unit files written for +/// periodic tasks (a `.timer`/`.service` pair). +/// +/// One-off tasks never touch these builders: they are scheduled with +/// transient `systemd-run` units instead. +class SystemdUnitFiles { + SystemdUnitFiles._(); + + /// Builds the `.service` unit for a periodic task. + /// + /// [description] is a human-readable summary shown by systemctl; + /// [execArgs] is the full command line (binary plus `--background-task` + /// arguments) run when the timer fires. `Type=oneshot` makes the unit + /// complete when the app process exits, so a failing task shows up as a + /// failed unit in the journal. + static String service({ + required String description, + required List execArgs, + }) { + final exec = execArgs.map(SystemdUnitFiles.quoteExecArgument).join(' '); + return ''' +[Unit] +Description=${SystemdNames.description(description)} + +[Service] +Type=oneshot +ExecStart=$exec +'''; + } + + /// Builds the `.timer` unit for a periodic task. + /// + /// [frequency] maps to `OnUnitActiveSec` (the timer re-fires this long + /// after the previous run finished) and [initialDelay] maps to + /// `OnStartupSec` (one-shot, relative to when the timer starts). When both + /// are present the timer fires at `startup + initialDelay` first and then + /// every [frequency]. `Persistent=true` gives WorkManager-style catch-up: + /// a run missed while the system was off executes on the next wake. + static String timer({ + required String description, + required Duration frequency, + Duration? initialDelay, + required String serviceUnit, + }) { + final buffer = StringBuffer() + ..writeln('[Unit]') + ..writeln('Description=${SystemdNames.description(description)}') + ..writeln() + ..writeln('[Timer]'); + if (initialDelay != null && initialDelay > Duration.zero) { + buffer.writeln('OnStartupSec=${initialDelay.inSeconds}'); + } + buffer + ..writeln('OnUnitActiveSec=${frequency.inSeconds}') + ..writeln('Persistent=true') + ..writeln('Unit=$serviceUnit') + ..writeln() + ..writeln('[Install]') + ..writeln('WantedBy=timers.target'); + return buffer.toString(); + } + + /// Quotes a single [ExecStart](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStart=) + /// argument for a unit file. + /// + /// Arguments without whitespace, quotes or backslashes are passed through + /// unchanged; anything else is double-quoted with `\` and `"` escaped. + static String quoteExecArgument(String argument) { + if (!argument.contains(RegExp(r'[\s"\\]'))) { + return argument; + } + final escaped = argument.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + return '"$escaped"'; + } +} diff --git a/workmanager_linux/lib/workmanager_linux.dart b/workmanager_linux/lib/workmanager_linux.dart new file mode 100644 index 00000000..0adc1f7a --- /dev/null +++ b/workmanager_linux/lib/workmanager_linux.dart @@ -0,0 +1,400 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:workmanager_platform_interface/workmanager_platform_interface.dart'; + +import 'execution.dart'; +import 'src/background_runner.dart'; +import 'src/payload_store.dart'; +import 'src/process_runner.dart'; +import 'src/systemd.dart'; +import 'src/systemd_units.dart'; + +export 'execution.dart'; + +/// Callback invoked when a running background task is stopped by the platform +/// before it finished. +/// +/// Accepted for API parity with the native plugin; systemd never reports a +/// stop reason, so this handler is never invoked on Linux. +typedef BackgroundTaskStoppedHandler = Future Function( + String taskName, StopReason stopReason); + +/// Linux implementation of [WorkmanagerPlatform] backed by systemd *user* +/// units. +/// +/// Experimental. Scheduling runs through `systemctl --user` / +/// `systemd-run --user` and therefore requires a systemd user session (see +/// the `enable-linger` caveat in `docs/linux.mdx`). The package is pure Dart: +/// no Pigeon, no native code, and the process runner is injectable so tests +/// never need a real systemd. +/// +/// How tasks are scheduled: +/// +/// * **One-off tasks** — transient units via +/// `systemd-run --user --unit=workmanager- --on-active=`, +/// or `--no-block` for an immediate run. Transient units vanish after the +/// task ran, so nothing lingers. +/// * **Periodic tasks** — a `.timer`/`.service` unit pair in +/// `~/.config/systemd/user/`. The timer uses `OnUnitActiveSec` for the +/// frequency and `Persistent=true` for WorkManager-style catch-up of runs +/// missed while the system was off. The service is `Type=oneshot` and +/// launches the app in headless `--background-task` mode. +/// +/// See the package README and `docs/linux.mdx` for the honest list of +/// unsupported surface (constraints, backoff, tags, `cancelByTag`). +class WorkmanagerLinux extends WorkmanagerPlatform { + /// Creates a Linux platform implementation. + /// + /// Every dependency is injectable so unit tests can run without systemd: + /// [processRunner] receives all `systemctl`/`systemd-run` invocations, + /// [binaryPath] is the app executable embedded in the units (defaults to + /// [Platform.resolvedExecutable]), [unitsDirectory] is where persistent + /// unit files are written (defaults to `$XDG_CONFIG_HOME/systemd/user` or + /// `~/.config/systemd/user`) and [payloadDirectory] is where input data + /// payloads are persisted (defaults to + /// `$XDG_DATA_HOME/workmanager/payloads` or + /// `~/.local/share/workmanager/payloads`). + WorkmanagerLinux({ + ProcessRunner? processRunner, + String? systemctlPath, + String? systemdRunPath, + String? binaryPath, + String? unitsDirectory, + String? payloadDirectory, + }) : _processRunner = processRunner ?? const SystemProcessRunner(), + _systemctl = systemctlPath ?? 'systemctl', + _systemdRun = systemdRunPath ?? 'systemd-run', + _binaryPath = binaryPath ?? Platform.resolvedExecutable, + _unitsDirectory = unitsDirectory ?? _defaultUnitsDirectory(), + _payloadStore = PayloadStore( + Directory(payloadDirectory ?? _defaultPayloadDirectory()), + ); + + final ProcessRunner _processRunner; + final String _systemctl; + final String _systemdRun; + final String _binaryPath; + final String _unitsDirectory; + final PayloadStore _payloadStore; + + late final SystemdCommands _commands = + SystemdCommands(systemctl: _systemctl, systemdRun: _systemdRun); + + /// Registers this implementation as the default [WorkmanagerPlatform] for + /// Linux. Called automatically by the `workmanager` package's platform + /// selection; can also be called manually. + static void registerWith() { + WorkmanagerPlatform.instance = WorkmanagerLinux(); + } + + /// Registers the background task handler (mirrors + /// `Workmanager().executeTask(...)`). + /// + /// Call this from your callback dispatcher. Unlike + /// `Workmanager().executeTask`, it never touches platform channels, so it + /// works in the headless process launched by systemd. + /// + /// [onTaskStopped] is accepted for API parity with the native plugin but + /// has no effect on Linux. + static void executeTask( + BackgroundTaskHandler backgroundTaskHandler, { + BackgroundTaskStoppedHandler? onTaskStopped, + }) { + WorkmanagerExecution.instance.executeTask(backgroundTaskHandler); + } + + /// Detects and runs a headless `--background-task` invocation in [args]. + /// + /// Call this at the top of `main()` so systemd-launched runs execute the + /// registered callback and exit, instead of opening a window: + /// + /// ```dart + /// Future main(List args) async { + /// if (await WorkmanagerLinux.maybeRunBackgroundTask( + /// args, callbackDispatcher)) { + /// return; // Handled: the process already logged and exited. + /// } + /// runApp(const MyApp()); + /// } + /// ``` + /// + /// Returns `false` (without side effects) when [args] contain no + /// `--background-task` flag, so the app starts normally. When an + /// invocation is found, the dispatcher runs, the payload is loaded, the + /// registered handler is invoked and the process exits with `0` on success + /// or `1` on failure (so systemd records failed runs in the journal). + /// + /// [payloadDirectory] overrides where payload files are looked up (only + /// relevant for relative `--payload` paths). + static Future maybeRunBackgroundTask( + List args, + Function callbackDispatcher, { + String? payloadDirectory, + }) async { + final invocation = BackgroundTaskInvocation.tryParse(args); + if (invocation == null) { + return false; + } + final runner = BackgroundTaskRunner(payloadDirectory: payloadDirectory); + final success = await runner.run(invocation, callbackDispatcher); + stdout.writeln( + 'workmanager_linux: background task "${invocation.taskName}" ' + 'finished ${success ? 'successfully' : 'with failure'}', + ); + exit(success ? 0 : 1); + } + + @override + Future initialize( + Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') + bool isInDebugMode = false, + }) async { + if (!Platform.isLinux) { + throw UnsupportedError( + 'workmanager_linux can only be used on Linux. ' + 'On other platforms use the `workmanager` package instead.', + ); + } + // Stored so headless invocations in the same process can find the + // dispatcher; `maybeRunBackgroundTask` also receives it directly. + WorkmanagerExecution.instance.callbackDispatcher = callbackDispatcher; + } + + @override + Future registerOneOffTask( + String uniqueName, + String taskName, { + Map? inputData, + Duration? initialDelay, + Constraints? constraints, + ExistingWorkPolicy? existingWorkPolicy, + BackoffPolicy? backoffPolicy, + Duration? backoffPolicyDelay, + String? tag, + OutOfQuotaPolicy? outOfQuotaPolicy, + ForegroundServiceConfig? foregroundServiceConfig, + }) async { + final payloadPath = await _payloadStore.write(uniqueName, inputData); + final command = _commands.runOneOff( + unit: SystemdNames.unit(uniqueName), + delay: initialDelay ?? Duration.zero, + appCommand: _appCommand(taskName, payloadPath), + ); + await _run(command); + } + + @override + Future registerPeriodicTask( + String uniqueName, + String taskName, { + Duration? frequency, + Duration? flexInterval, + Map? inputData, + Duration? initialDelay, + Constraints? constraints, + ExistingPeriodicWorkPolicy? existingWorkPolicy, + BackoffPolicy? backoffPolicy, + Duration? backoffPolicyDelay, + String? tag, + ForegroundServiceConfig? foregroundServiceConfig, + }) async { + final resolvedFrequency = frequency ?? const Duration(minutes: 15); + final payloadPath = await _payloadStore.write(uniqueName, inputData); + final unit = SystemdNames.unit(uniqueName); + final serviceUnit = '$unit.service'; + final timerUnit = '$unit.timer'; + final description = + SystemdNames.description('Workmanager task "$taskName" ($uniqueName)'); + await _writeUnitFile( + serviceUnit, + SystemdUnitFiles.service( + description: description, + execArgs: _appCommand(taskName, payloadPath), + ), + ); + await _writeUnitFile( + timerUnit, + SystemdUnitFiles.timer( + description: description, + frequency: resolvedFrequency, + initialDelay: initialDelay, + serviceUnit: serviceUnit, + ), + ); + await _run(_commands.daemonReload()); + await _run(_commands.enableNow(timerUnit)); + } + + @override + Future registerProcessingTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + Constraints? constraints, + }) async { + throw UnsupportedError('Processing tasks are not supported on Linux.'); + } + + @override + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + Constraints? constraints, + }) async { + throw UnsupportedError('Health research tasks are not supported on Linux.'); + } + + @override + Future registerContinuedProcessingTask( + String uniqueName, + String taskName, { + String? title, + String? subtitle, + Map? inputData, + }) async { + throw UnsupportedError( + 'Continued processing tasks are not supported on Linux.'); + } + + @override + Future cancelByUniqueName(String uniqueName) async { + final unit = SystemdNames.unit(uniqueName); + await _runBestEffort(_commands.stop(unit)); + await _runBestEffort(_commands.disable('$unit.timer')); + await _runBestEffort(_commands.resetFailed(unit)); + await _deleteUnitFiles(unit); + await _runBestEffort(_commands.daemonReload()); + await _payloadStore.delete(uniqueName); + } + + @override + Future cancelByTag(String tag) async { + // A tag registry would be needed to map tags back to unique names; not + // implemented in v1 (see docs/linux.mdx). + throw UnsupportedError( + 'cancelByTag is not supported on Linux. Tags are accepted at ' + 'registration time but not tracked; cancel tasks by unique name ' + 'or use cancelAll.', + ); + } + + @override + Future cancelAll() async { + await _runBestEffort(_commands.stopAllTimers()); + await _runBestEffort(_commands.stopAllServices()); + await _runBestEffort(_commands.disableAllTimers()); + await _runBestEffort(_commands.resetFailedAll()); + await _clearUnitFiles(); + await _runBestEffort(_commands.daemonReload()); + await _payloadStore.clear(); + } + + @override + Future isScheduledByUniqueName(String uniqueName) async { + final result = await _runBestEffort( + _commands.isActive(SystemdNames.timerUnit(uniqueName))); + return result.exitCode == 0; + } + + @override + Future printScheduledTasks() async { + final result = await _runBestEffort(_commands.listTimers()); + final lines = result.stdout.toString().split('\n'); + return lines + .where((line) => line.contains(SystemdNames.unitPrefix)) + .join('\n'); + } + + /// The command line embedded in units and passed to `systemd-run`: + /// ` --background-task [--payload ]`. + List _appCommand(String taskName, String? payloadPath) { + return [ + _binaryPath, + '--background-task', + taskName, + if (payloadPath != null) '--payload', + if (payloadPath != null) payloadPath, + ]; + } + + Future _writeUnitFile(String fileName, String content) async { + final file = File('$_unitsDirectory/$fileName'); + await file.parent.create(recursive: true); + await file.writeAsString(content); + } + + Future _deleteUnitFiles(String unit) async { + final directory = Directory(_unitsDirectory); + if (!await directory.exists()) { + return; + } + for (final suffix in const ['.timer', '.service']) { + final file = File('${directory.path}/$unit$suffix'); + if (await file.exists()) { + await file.delete(); + } + } + } + + Future _clearUnitFiles() async { + final directory = Directory(_unitsDirectory); + if (!await directory.exists()) { + return; + } + await for (final entity in directory.list()) { + final name = entity.uri.pathSegments.last; + final isOurs = name.startsWith(SystemdNames.unitPrefix) && + (name.endsWith('.timer') || name.endsWith('.service')); + if (isOurs && entity is File) { + await entity.delete(); + } + } + } + + /// Runs [command], turning a missing `systemctl`/`systemd-run` executable + /// into a descriptive [StateError]. + Future _run(List command) async { + try { + return await _processRunner.run(command.first, command.sublist(1)); + } on ProcessException catch (error) { + throw StateError( + 'workmanager_linux: could not run "${command.first}" (${error.message}). ' + 'Scheduling requires a systemd user session; see docs/linux.mdx for ' + 'setup and the enable-linger caveat.', + ); + } + } + + /// Like [_run] but reports failures as a non-zero exit instead of + /// throwing (used by cancellation and queries, where missing units are + /// expected). + Future _runBestEffort(List command) async { + try { + return await _run(command); + } on StateError { + return ProcessResult(0, 1, '', ''); + } + } +} + +String _defaultUnitsDirectory() { + final configHome = Platform.environment['XDG_CONFIG_HOME']; + final home = Platform.environment['HOME'] ?? Directory.systemTemp.path; + return '${configHome ?? '$home/.config'}/systemd/user'; +} + +String _defaultPayloadDirectory() { + final dataHome = Platform.environment['XDG_DATA_HOME']; + final home = Platform.environment['HOME'] ?? Directory.systemTemp.path; + return '${dataHome ?? '$home/.local/share'}/workmanager/payloads'; +} diff --git a/workmanager_linux/pubspec.yaml b/workmanager_linux/pubspec.yaml new file mode 100644 index 00000000..2e222cab --- /dev/null +++ b/workmanager_linux/pubspec.yaml @@ -0,0 +1,22 @@ +name: workmanager_linux +description: Linux (experimental) implementation of workmanager using systemd user units (timers and transient units) for background task execution. +version: 0.1.0 +# publish_to: none +homepage: https://github.com/fluttercommunity/flutter_workmanager +repository: https://github.com/fluttercommunity/flutter_workmanager +issue_tracker: https://github.com/fluttercommunity/flutter_workmanager/issues + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: ">=3.38.0" + +dependencies: + flutter: + sdk: flutter + workmanager_platform_interface: ^0.10.1 + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^6.0.0 + test: ^1.25.15 diff --git a/workmanager_linux/test/background_runner_test.dart b/workmanager_linux/test/background_runner_test.dart new file mode 100644 index 00000000..b76f4d1f --- /dev/null +++ b/workmanager_linux/test/background_runner_test.dart @@ -0,0 +1,151 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:workmanager_linux/execution.dart'; +import 'package:workmanager_linux/src/background_runner.dart'; + +void main() { + group('BackgroundTaskInvocation.tryParse', () { + test('returns null without --background-task', () { + expect(BackgroundTaskInvocation.tryParse([]), isNull); + expect( + BackgroundTaskInvocation.tryParse(['--foo', 'bar']), isNull); + }); + + test('returns null when the task name is missing', () { + expect(BackgroundTaskInvocation.tryParse(['--background-task']), + isNull); + }); + + test('parses the task name', () { + final invocation = BackgroundTaskInvocation.tryParse( + ['--background-task', 'sync']); + expect(invocation, isNotNull); + expect(invocation!.taskName, 'sync'); + expect(invocation.payloadPath, isNull); + }); + + test('parses the payload path', () { + final invocation = BackgroundTaskInvocation.tryParse([ + '--background-task', + 'sync', + '--payload', + '/tmp/workmanager-1234abcd.json', + ]); + expect(invocation, isNotNull); + expect(invocation!.taskName, 'sync'); + expect(invocation.payloadPath, '/tmp/workmanager-1234abcd.json'); + }); + + test('ignores unrelated leading arguments', () { + final invocation = BackgroundTaskInvocation.tryParse([ + '/opt/app/example', + '--background-task', + 'sync', + '--payload', + '/tmp/payload.json', + ]); + expect(invocation, isNotNull); + expect(invocation!.taskName, 'sync'); + expect(invocation.payloadPath, '/tmp/payload.json'); + }); + }); + + group('BackgroundTaskRunner', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('wm-runner-test-'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('runs the dispatcher and invokes the registered handler', () async { + final payloadPath = '${tempDir.path}/payload.json'; + File(payloadPath).writeAsStringSync('{"key": "value", "n": 42}'); + + var dispatcherRuns = 0; + String? seenTaskName; + Map? seenData; + + WorkmanagerExecution.instance.taskHandler = (taskName, inputData) async { + seenTaskName = taskName; + seenData = inputData; + return true; + }; + addTearDown(() => WorkmanagerExecution.instance.taskHandler = null); + + final runner = BackgroundTaskRunner(); + final result = await runner.run( + BackgroundTaskInvocation( + taskName: 'sync', + payloadPath: payloadPath, + ), + () { + dispatcherRuns++; + }, + ); + + expect(dispatcherRuns, 1); + expect(seenTaskName, 'sync'); + expect(seenData, {'key': 'value', 'n': 42}); + expect(result, isTrue); + }); + + test('passes null input data when no payload was written', () async { + Object? seenData = 'unset'; + WorkmanagerExecution.instance.taskHandler = (taskName, inputData) async { + seenData = inputData; + return true; + }; + addTearDown(() => WorkmanagerExecution.instance.taskHandler = null); + + final result = await BackgroundTaskRunner().run( + const BackgroundTaskInvocation(taskName: 'sync'), + () {}, + ); + + expect(seenData, isNull); + expect(result, isTrue); + }); + + test('returns false when no handler is registered', () async { + WorkmanagerExecution.instance.taskHandler = null; + final result = await BackgroundTaskRunner().run( + const BackgroundTaskInvocation(taskName: 'sync'), + () {}, + ); + expect(result, isFalse); + }); + + test('returns false when the handler throws', () async { + WorkmanagerExecution.instance.taskHandler = (taskName, inputData) async { + throw StateError('boom'); + }; + addTearDown(() => WorkmanagerExecution.instance.taskHandler = null); + + final result = await BackgroundTaskRunner().run( + const BackgroundTaskInvocation(taskName: 'sync'), + () {}, + ); + expect(result, isFalse); + }); + + test('returns false when the dispatcher throws synchronously', () async { + WorkmanagerExecution.instance.taskHandler = null; + final result = await BackgroundTaskRunner().run( + const BackgroundTaskInvocation(taskName: 'sync'), + () => throw StateError('bad dispatcher'), + ); + expect(result, isFalse); + }); + }); +} diff --git a/workmanager_linux/test/command_construction_test.dart b/workmanager_linux/test/command_construction_test.dart new file mode 100644 index 00000000..09d27daf --- /dev/null +++ b/workmanager_linux/test/command_construction_test.dart @@ -0,0 +1,274 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:workmanager_linux/src/systemd.dart'; +import 'package:workmanager_linux/src/systemd_units.dart'; + +void main() { + group('SystemdNames', () { + test('unit names are deterministic and stable across calls', () { + expect(SystemdNames.unit('my-task'), SystemdNames.unit('my-task')); + expect(SystemdNames.hash('my-task'), SystemdNames.hash('my-task')); + }); + + test('different unique names produce different units', () { + expect(SystemdNames.unit('task-a'), isNot(SystemdNames.unit('task-b'))); + }); + + test('unit names only use safe systemd characters', () { + for (final name in [ + 'simple', + 'with space', + 'with/slash', + 'with:colon', + 'uni🙂code', + '', + ]) { + final unit = SystemdNames.unit(name); + expect( + unit, + matches(RegExp(r'^workmanager-[0-9a-f]{8}$')), + reason: 'unexpected unit name for "$name": $unit', + ); + } + }); + + test('timer and service units derive from the base unit', () { + final base = SystemdNames.unit('demo'); + expect(SystemdNames.timerUnit('demo'), '$base.timer'); + expect(SystemdNames.serviceUnit('demo'), '$base.service'); + }); + + test('description strips control characters', () { + expect( + SystemdNames.description('line one\nline two\t\x00'), + 'line one line two ', + ); + }); + }); + + group('SystemdUnitFiles.service', () { + test('is a Type=oneshot unit running the app headless', () { + final content = SystemdUnitFiles.service( + description: 'Workmanager task "sync" (task-id)', + execArgs: [ + '/opt/app/bin/example', + '--background-task', + 'sync', + '--payload', + '/home/u/.local/share/workmanager/payloads/workmanager-1234abcd.json', + ], + ); + expect(content, contains('[Unit]')); + expect( + content, + contains('Description=Workmanager task "sync" (task-id)'), + ); + expect(content, contains('[Service]')); + expect(content, contains('Type=oneshot')); + expect( + content, + contains( + 'ExecStart=/opt/app/bin/example --background-task sync ' + '--payload /home/u/.local/share/workmanager/payloads/' + 'workmanager-1234abcd.json', + ), + ); + }); + + test('quotes arguments containing spaces', () { + final content = SystemdUnitFiles.service( + description: 'quoted', + execArgs: [ + '/opt/My App/example', + '--background-task', + 'task with space', + ], + ); + expect( + content, + contains('ExecStart="/opt/My App/example" --background-task ' + '"task with space"'), + ); + }); + }); + + group('SystemdUnitFiles.timer', () { + test('uses OnUnitActiveSec for the frequency and enables catch-up', () { + final content = SystemdUnitFiles.timer( + description: 'periodic', + frequency: const Duration(minutes: 15), + serviceUnit: 'workmanager-1234abcd.service', + ); + expect(content, contains('[Timer]')); + expect(content, contains('OnUnitActiveSec=900')); + expect(content, contains('Persistent=true')); + expect( + content, + contains('Unit=workmanager-1234abcd.service'), + ); + expect(content, contains('[Install]')); + expect(content, contains('WantedBy=timers.target')); + expect(content, isNot(contains('OnStartupSec'))); + }); + + test('adds OnStartupSec for the initial delay', () { + final content = SystemdUnitFiles.timer( + description: 'periodic', + frequency: const Duration(hours: 1), + initialDelay: const Duration(minutes: 5), + serviceUnit: 'workmanager-1234abcd.service', + ); + expect(content, contains('OnStartupSec=300')); + expect(content, contains('OnUnitActiveSec=3600')); + }); + + test('omits OnStartupSec for zero or negative delays', () { + for (final delay in [ + null, + Duration.zero, + const Duration(seconds: -5), + ]) { + final content = SystemdUnitFiles.timer( + description: 'periodic', + frequency: const Duration(minutes: 15), + initialDelay: delay, + serviceUnit: 'workmanager-1234abcd.service', + ); + expect(content, isNot(contains('OnStartupSec')), + reason: 'delay was $delay'); + } + }); + }); + + group('SystemdCommands', () { + const commands = + SystemdCommands(systemctl: 'systemctl', systemdRun: 'systemd-run'); + final appCommand = [ + '/opt/app/example', + '--background-task', + 'sync', + '--payload', + '/tmp/payload.json', + ]; + + test('one-off with delay uses a transient timer', () { + final args = commands.runOneOff( + unit: 'workmanager-1234abcd', + delay: const Duration(minutes: 5), + appCommand: appCommand, + ); + expect(args, [ + 'systemd-run', + '--user', + '--collect', + '--unit=workmanager-1234abcd', + '--on-active=300', + ...appCommand, + ]); + }); + + test('one-off without delay runs immediately with --no-block', () { + final args = commands.runOneOff( + unit: 'workmanager-1234abcd', + delay: Duration.zero, + appCommand: appCommand, + ); + expect(args, contains('--no-block')); + expect(args, isNot(contains(startsWith('--on-active')))); + }); + + test('cancel, query and list commands', () { + expect(commands.stop('workmanager-1234abcd'), [ + 'systemctl', + '--user', + 'stop', + 'workmanager-1234abcd.timer', + 'workmanager-1234abcd.service', + ]); + expect(commands.disable('workmanager-1234abcd.timer'), [ + 'systemctl', + '--user', + 'disable', + 'workmanager-1234abcd.timer', + ]); + expect(commands.resetFailed('workmanager-1234abcd'), [ + 'systemctl', + '--user', + 'reset-failed', + 'workmanager-1234abcd.timer', + 'workmanager-1234abcd.service', + ]); + expect(commands.isActive('workmanager-1234abcd.timer'), [ + 'systemctl', + '--user', + 'is-active', + 'workmanager-1234abcd.timer', + ]); + expect(commands.daemonReload(), [ + 'systemctl', + '--user', + 'daemon-reload', + ]); + expect(commands.enableNow('workmanager-1234abcd.timer'), [ + 'systemctl', + '--user', + 'enable', + '--now', + 'workmanager-1234abcd.timer', + ]); + expect(commands.listTimers(), [ + 'systemctl', + '--user', + 'list-timers', + '--all', + '--no-legend', + '--plain', + ]); + expect(commands.stopAllTimers(), [ + 'systemctl', + '--user', + 'stop', + 'workmanager-*.timer', + ]); + expect(commands.stopAllServices(), [ + 'systemctl', + '--user', + 'stop', + 'workmanager-*.service', + ]); + expect(commands.disableAllTimers(), [ + 'systemctl', + '--user', + 'disable', + 'workmanager-*.timer', + ]); + expect(commands.resetFailedAll(), [ + 'systemctl', + '--user', + 'reset-failed', + 'workmanager-*', + ]); + }); + }); + + group('SystemdUnitFiles.quoteExecArgument', () { + test('passes plain arguments through unchanged', () { + expect(SystemdUnitFiles.quoteExecArgument('--background-task'), + '--background-task'); + expect(SystemdUnitFiles.quoteExecArgument('/opt/app/example'), + '/opt/app/example'); + }); + + test('quotes arguments with spaces', () { + expect(SystemdUnitFiles.quoteExecArgument('my task'), '"my task"'); + }); + + test('escapes quotes and backslashes inside quotes', () { + expect(SystemdUnitFiles.quoteExecArgument('a"b'), r'"a\"b"'); + expect(SystemdUnitFiles.quoteExecArgument(r'a\b'), r'"a\\b"'); + }); + }); +} diff --git a/workmanager_linux/test/payload_store_test.dart b/workmanager_linux/test/payload_store_test.dart new file mode 100644 index 00000000..13c0e020 --- /dev/null +++ b/workmanager_linux/test/payload_store_test.dart @@ -0,0 +1,88 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:workmanager_linux/src/payload_store.dart'; + +void main() { + late Directory tempDir; + late PayloadStore store; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('wm-payload-test-'); + store = PayloadStore(tempDir); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('payload path is deterministic per unique name', () { + expect(store.payloadPath('task-a'), store.payloadPath('task-a')); + expect(store.payloadPath('task-a'), isNot(store.payloadPath('task-b'))); + }); + + test('write/load round-trips nested input data', () async { + final path = await store.write('task-a', { + 'count': 3, + 'ratio': 0.5, + 'flag': true, + 'label': 'sync', + 'nested': { + 'list': [1, 'two', false], + }, + }); + expect(path, store.payloadPath('task-a')); + expect(File(path!).existsSync(), isTrue); + + final loaded = await store.load(path); + expect(loaded, { + 'count': 3, + 'ratio': 0.5, + 'flag': true, + 'label': 'sync', + 'nested': { + 'list': [1, 'two', false], + }, + }); + }); + + test('write returns null for null input data and writes no file', () async { + final path = await store.write('task-a', null); + expect(path, isNull); + expect(File(store.payloadPath('task-a')).existsSync(), isFalse); + }); + + test('load returns null for missing or invalid payloads', () async { + expect(await store.load('${tempDir.path}/nope.json'), isNull); + + final bad = File('${tempDir.path}/bad.json'); + await bad.writeAsString('not json {'); + expect(await store.load(bad.path), isNull); + }); + + test('overwriting a payload replaces its content', () async { + await store.write('task-a', {'run': 1}); + final path = await store.write('task-a', {'run': 2}); + expect(await store.load(path!), {'run': 2}); + }); + + test('delete removes the payload file', () async { + await store.write('task-a', {'run': 1}); + await store.delete('task-a'); + expect(File(store.payloadPath('task-a')).existsSync(), isFalse); + }); + + test('clear removes every payload', () async { + await store.write('task-a', {'run': 1}); + await store.write('task-b', {'run': 2}); + await store.clear(); + expect(File(store.payloadPath('task-a')).existsSync(), isFalse); + expect(File(store.payloadPath('task-b')).existsSync(), isFalse); + }); +} diff --git a/workmanager_linux/test/workmanager_linux_test.dart b/workmanager_linux/test/workmanager_linux_test.dart new file mode 100644 index 00000000..85f624d3 --- /dev/null +++ b/workmanager_linux/test/workmanager_linux_test.dart @@ -0,0 +1,309 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:workmanager_linux/src/process_runner.dart'; +import 'package:workmanager_linux/src/systemd.dart'; +import 'package:workmanager_linux/workmanager_linux.dart'; + +/// Records every invocation and returns scriptable results; never touches a +/// real systemd. +class FakeProcessRunner implements ProcessRunner { + final List> calls = >[]; + int exitCode = 0; + String stdout = ''; + bool throwOnRun = false; + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + }) async { + if (throwOnRun) { + throw ProcessException(executable, arguments, 'no systemd here'); + } + calls.add([executable, ...arguments]); + return ProcessResult(0, exitCode, stdout, ''); + } +} + +void main() { + late Directory tempDir; + late String unitsDir; + late String payloadDir; + late FakeProcessRunner runner; + late WorkmanagerLinux linux; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('wm-linux-test-'); + unitsDir = '${tempDir.path}/units'; + payloadDir = '${tempDir.path}/payloads'; + runner = FakeProcessRunner(); + linux = WorkmanagerLinux( + processRunner: runner, + binaryPath: '/opt/app/example', + unitsDirectory: unitsDir, + payloadDirectory: payloadDir, + ); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + WorkmanagerExecution.instance.taskHandler = null; + }); + + String payloadPathFor(String uniqueName) => + '$payloadDir/workmanager-${SystemdNames.hash(uniqueName)}.json'; + + test('registerOneOffTask schedules a transient unit for an immediate run', + () async { + await linux.registerOneOffTask('task-a', 'sync', + inputData: {'key': 'value'}); + + expect(runner.calls, hasLength(1)); + expect(runner.calls.single, [ + 'systemd-run', + '--user', + '--collect', + '--unit=workmanager-${SystemdNames.hash('task-a')}', + '--no-block', + '/opt/app/example', + '--background-task', + 'sync', + '--payload', + payloadPathFor('task-a'), + ]); + final payload = File(payloadPathFor('task-a')); + expect(payload.existsSync(), isTrue); + expect(jsonDecode(payload.readAsStringSync()), + {'key': 'value'}); + }); + + test('registerOneOffTask honors the initial delay', () async { + await linux.registerOneOffTask( + 'task-a', + 'sync', + initialDelay: const Duration(minutes: 5), + ); + + expect(runner.calls.single, contains('--on-active=300')); + expect(runner.calls.single, isNot(contains('--no-block'))); + }); + + test('registerOneOffTask omits --payload when inputData is null', () async { + await linux.registerOneOffTask('task-a', 'sync'); + expect(runner.calls.single, isNot(contains('--payload'))); + expect(File(payloadPathFor('task-a')).existsSync(), isFalse); + }); + + test('registerPeriodicTask writes unit files and enables the timer', + () async { + await linux.registerPeriodicTask( + 'task-p', + 'periodic', + inputData: {'freq': 'hourly'}, + frequency: const Duration(hours: 1), + initialDelay: const Duration(minutes: 2), + ); + + final serviceFile = + File('$unitsDir/workmanager-${SystemdNames.hash('task-p')}.service'); + final timerFile = + File('$unitsDir/workmanager-${SystemdNames.hash('task-p')}.timer'); + expect(serviceFile.existsSync(), isTrue); + expect(timerFile.existsSync(), isTrue); + + final service = serviceFile.readAsStringSync(); + expect(service, contains('Type=oneshot')); + expect( + service, + contains('ExecStart=/opt/app/example --background-task ' + 'periodic --payload ${payloadPathFor('task-p')}')); + + final timer = timerFile.readAsStringSync(); + expect(timer, contains('OnUnitActiveSec=3600')); + expect(timer, contains('OnStartupSec=120')); + expect(timer, contains('Persistent=true')); + expect(timer, + contains('Unit=workmanager-${SystemdNames.hash('task-p')}.service')); + + expect(runner.calls, >[ + ['systemctl', '--user', 'daemon-reload'], + [ + 'systemctl', + '--user', + 'enable', + '--now', + 'workmanager-${SystemdNames.hash('task-p')}.timer', + ], + ]); + }); + + test('registerPeriodicTask defaults the frequency to 15 minutes', () async { + await linux.registerPeriodicTask('task-p', 'periodic'); + final timerFile = + File('$unitsDir/workmanager-${SystemdNames.hash('task-p')}.timer'); + expect(timerFile.readAsStringSync(), contains('OnUnitActiveSec=900')); + }); + + test('cancelByUniqueName stops, disables and removes the units', () async { + await linux.registerPeriodicTask('task-p', 'periodic'); + runner.calls.clear(); + + await linux.cancelByUniqueName('task-p'); + + expect(runner.calls, >[ + [ + 'systemctl', + '--user', + 'stop', + 'workmanager-${SystemdNames.hash('task-p')}.timer', + 'workmanager-${SystemdNames.hash('task-p')}.service', + ], + [ + 'systemctl', + '--user', + 'disable', + 'workmanager-${SystemdNames.hash('task-p')}.timer', + ], + [ + 'systemctl', + '--user', + 'reset-failed', + 'workmanager-${SystemdNames.hash('task-p')}.timer', + 'workmanager-${SystemdNames.hash('task-p')}.service', + ], + ['systemctl', '--user', 'daemon-reload'], + ]); + expect( + File('$unitsDir/workmanager-${SystemdNames.hash('task-p')}.timer') + .existsSync(), + isFalse); + expect( + File('$unitsDir/workmanager-${SystemdNames.hash('task-p')}.service') + .existsSync(), + isFalse); + }); + + test('cancelAll stops every workmanager unit and clears state', () async { + await linux.registerPeriodicTask('task-p', 'periodic'); + await linux.registerOneOffTask('task-a', 'sync', + inputData: {'k': 'v'}); + runner.calls.clear(); + + await linux.cancelAll(); + + expect(runner.calls, >[ + ['systemctl', '--user', 'stop', 'workmanager-*.timer'], + ['systemctl', '--user', 'stop', 'workmanager-*.service'], + ['systemctl', '--user', 'disable', 'workmanager-*.timer'], + ['systemctl', '--user', 'reset-failed', 'workmanager-*'], + ['systemctl', '--user', 'daemon-reload'], + ]); + expect(Directory(unitsDir).listSync(), isEmpty); + expect(Directory(payloadDir).listSync(), isEmpty); + }); + + test('isScheduledByUniqueName maps the is-active exit code', () async { + runner.exitCode = 0; + expect(await linux.isScheduledByUniqueName('task-a'), isTrue); + expect(runner.calls.single, [ + 'systemctl', + '--user', + 'is-active', + 'workmanager-${SystemdNames.hash('task-a')}.timer', + ]); + + runner.calls.clear(); + runner.exitCode = 3; + expect(await linux.isScheduledByUniqueName('task-a'), isFalse); + }); + + test('printScheduledTasks returns only workmanager timer lines', () async { + runner.stdout = [ + 'Mon 2026-08-03 12:00:00 BST 1h 0s left Sun 2026-08-02 12:00:00 BST 1 day ago workmanager-1234abcd.timer workmanager-1234abcd.service', + 'Mon 2026-08-03 13:00:00 BST 2h 0s left Mon 2026-08-02 13:00:00 BST 1 day ago user-backup.timer user-backup.service', + ].join('\n'); + + final result = await linux.printScheduledTasks(); + + expect(result, contains('workmanager-1234abcd.timer')); + expect(result, isNot(contains('user-backup.timer'))); + expect(runner.calls.single, [ + 'systemctl', + '--user', + 'list-timers', + '--all', + '--no-legend', + '--plain', + ]); + }); + + test('missing systemctl surfaces a descriptive StateError', () async { + runner.throwOnRun = true; + await expectLater( + linux.registerOneOffTask('task-a', 'sync'), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('systemd user session'), + )), + ); + }); + + test('unsupported APIs throw UnsupportedError', () async { + await expectLater( + linux.registerProcessingTask('u', 't'), throwsUnsupportedError); + await expectLater( + linux.registerHealthResearchTask('u', 't'), throwsUnsupportedError); + await expectLater(linux.registerContinuedProcessingTask('u', 't'), + throwsUnsupportedError); + await expectLater(linux.cancelByTag('tag'), throwsUnsupportedError); + }); + + test('executeTask registers the handler in the execution registry', () async { + String? seenTask; + WorkmanagerLinux.executeTask((taskName, inputData) async { + seenTask = taskName; + return true; + }); + final result = await WorkmanagerExecution.instance.runTask('sync', null); + expect(result, isTrue); + expect(seenTask, 'sync'); + }); + + test('initialize stores the dispatcher on Linux, rejects other hosts', + () async { + var ran = false; + void dispatcher() { + ran = true; + } + + if (Platform.isLinux) { + await linux.initialize(dispatcher); + expect( + WorkmanagerExecution.instance.callbackDispatcher, same(dispatcher)); + expect(ran, isFalse); + } else { + // The initialize() guard rejects non-Linux hosts with a clear error. + await expectLater(linux.initialize(dispatcher), throwsUnsupportedError); + expect(ran, isFalse); + } + }); + + test('maybeRunBackgroundTask returns false for normal app arguments', + () async { + final result = + await WorkmanagerLinux.maybeRunBackgroundTask([], () {}); + expect(result, isFalse); + expect(runner.calls, isEmpty); + }); +} From 3fe77e0cc8e595f165a49c9bf800a96f64141235 Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 16:33:30 +0100 Subject: [PATCH 2/4] =?UTF-8?q?test:=20Linux=20host=20now=20succeeds=20in?= =?UTF-8?q?=20initialize()=20=E2=80=94=20workmanager=5Flinux=20implements?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/backward_compatibility_test.dart | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/workmanager/test/backward_compatibility_test.dart b/workmanager/test/backward_compatibility_test.dart index b451a64f..99ad2cf3 100644 --- a/workmanager/test/backward_compatibility_test.dart +++ b/workmanager/test/backward_compatibility_test.dart @@ -13,12 +13,23 @@ void main() { // This test verifies that existing code using isInDebugMode will still compile // The parameter is deprecated but should not break existing code // - // Behavior differs by host: on platforms without a plugin implementation - // (e.g. Linux/Windows test hosts) the placeholder throws - // UnimplementedError. On macOS/iOS/Android the platform implementation is - // selected and the call fails with a channel error because no plugin host - // is registered in the test environment. - final expectedError = (Platform.isLinux || Platform.isWindows) + // Behavior differs by host: Linux now has a real implementation + // (workmanager_linux) whose initialize is pure Dart — it succeeds + // in-process without touching systemd. Windows still uses the + // placeholder and throws UnimplementedError. On macOS/iOS/Android the + // platform implementation is selected and the call fails with a channel + // error because no plugin host is registered in the test environment. + if (Platform.isLinux) { + await Workmanager().initialize( + callbackDispatcher, + // ignore: deprecated_member_use_from_same_package + isInDebugMode: true, // Deprecated but still compiles + ); + await Workmanager().initialize(callbackDispatcher); + return; + } + + final expectedError = Platform.isWindows ? throwsA(isA()) : throwsA(anything); From d86e3668b65787d1f821aa4fa3c26b14ff9bc183 Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 16:55:02 +0100 Subject: [PATCH 3/4] test: initialize Flutter binding in backward-compat test On Linux the platform initialize() now succeeds (workmanager_linux), so the flow reaches _prepareInProcessExecution -> WorkmanagerFlutterApi.setUp, which requires ServicesBinding. Ensure the binding like other tests do. --- workmanager/test/backward_compatibility_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workmanager/test/backward_compatibility_test.dart b/workmanager/test/backward_compatibility_test.dart index 99ad2cf3..ece6ae1b 100644 --- a/workmanager/test/backward_compatibility_test.dart +++ b/workmanager/test/backward_compatibility_test.dart @@ -8,6 +8,8 @@ void callbackDispatcher() { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('Backward compatibility', () { test('initialize() still accepts isInDebugMode parameter', () async { // This test verifies that existing code using isInDebugMode will still compile From 398618c8663d29c8f10f061406ffa6f4607f12d8 Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 17:56:09 +0100 Subject: [PATCH 4/4] fix: accept expedited param on Linux (no-op, API parity with 0.10.2) --- workmanager_linux/lib/workmanager_linux.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workmanager_linux/lib/workmanager_linux.dart b/workmanager_linux/lib/workmanager_linux.dart index 0adc1f7a..9fce2ca0 100644 --- a/workmanager_linux/lib/workmanager_linux.dart +++ b/workmanager_linux/lib/workmanager_linux.dart @@ -174,6 +174,8 @@ class WorkmanagerLinux extends WorkmanagerPlatform { Map? inputData, Duration? initialDelay, Constraints? constraints, + // Accepted for API parity; systemd has no expedited concept. + bool expedited = false, ExistingWorkPolicy? existingWorkPolicy, BackoffPolicy? backoffPolicy, Duration? backoffPolicyDelay,