Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"title": "Web (experimental)",
"href": "/web"
},
{
"title": "Linux (experimental)",
"href": "/linux"
},
{
"title": "Troubleshooting",
"href": "/troubleshooting"
Expand Down
3 changes: 2 additions & 1 deletion docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
145 changes: 145 additions & 0 deletions docs/linux.mdx
Original file line number Diff line number Diff line change
@@ -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-<hash> --on-active=<delay>` (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 <taskName> --payload <path>` 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<void> main(List<String> 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.
1 change: 1 addition & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ packages:
- workmanager_platform_interface
- workmanager_android
- workmanager_apple
- workmanager_linux
- workmanager_web
- example
scripts:
Expand Down
10 changes: 10 additions & 0 deletions workmanager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions workmanager/lib/src/workmanager_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions workmanager/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions workmanager/test/backward_compatibility_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,30 @@ 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
// 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<UnimplementedError>())
: throwsA(anything);

Expand Down
10 changes: 10 additions & 0 deletions workmanager_linux/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions workmanager_linux/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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-<hash> --on-active=<delay>`.
`--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=<frequency>` — re-fires this long after the previous run.
- `OnStartupSec=<initialDelay>` — 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 <taskName> --payload <path>`. 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-<hash>.timer/.service`, payload
`$XDG_DATA_HOME/workmanager/payloads/workmanager-<hash>.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<bool>` 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).
21 changes: 21 additions & 0 deletions workmanager_linux/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading