From f11f79a26744171283c57990cd621b1803e6344a Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 10:28:55 +0100 Subject: [PATCH 1/4] feat(windows): add workmanager_windows Task Scheduler based background execution Implements WorkmanagerPlatform for Windows via the schtasks CLI (no COM, no native code): one-off (/SC ONCE) and periodic (/SC DAILY /RI) task registration, on-disk JSON payloads, cancellation, status queries, and a headless --background-task runner mirroring workmanager_web's execution registry. Includes core integration (platform selection branch), docs and pure-Dart unit tests with an injectable process runner. --- docs.json | 3 +- docs/windows.mdx | 136 +++++++ melos.yaml | 1 + workmanager/README.md | 2 +- workmanager/lib/src/workmanager_impl.dart | 7 +- workmanager/pubspec.yaml | 3 + workmanager_windows/CHANGELOG.md | 5 + workmanager_windows/LICENSE | 21 ++ workmanager_windows/README.md | 123 ++++++ workmanager_windows/analysis_options.yaml | 4 + workmanager_windows/lib/execution.dart | 141 +++++++ .../lib/src/payload_store.dart | 83 +++++ .../lib/src/process_runner.dart | 23 ++ workmanager_windows/lib/src/schtasks.dart | 172 +++++++++ .../lib/workmanager_windows.dart | 352 ++++++++++++++++++ workmanager_windows/pubspec.yaml | 29 ++ workmanager_windows/test/execution_test.dart | 178 +++++++++ .../test/payload_store_test.dart | 104 ++++++ workmanager_windows/test/schtasks_test.dart | 226 +++++++++++ .../test/workmanager_windows_test.dart | 312 ++++++++++++++++ 20 files changed, 1922 insertions(+), 3 deletions(-) create mode 100644 docs/windows.mdx create mode 100644 workmanager_windows/CHANGELOG.md create mode 100644 workmanager_windows/LICENSE create mode 100644 workmanager_windows/README.md create mode 100644 workmanager_windows/analysis_options.yaml create mode 100644 workmanager_windows/lib/execution.dart create mode 100644 workmanager_windows/lib/src/payload_store.dart create mode 100644 workmanager_windows/lib/src/process_runner.dart create mode 100644 workmanager_windows/lib/src/schtasks.dart create mode 100644 workmanager_windows/lib/workmanager_windows.dart create mode 100644 workmanager_windows/pubspec.yaml create mode 100644 workmanager_windows/test/execution_test.dart create mode 100644 workmanager_windows/test/payload_store_test.dart create mode 100644 workmanager_windows/test/schtasks_test.dart create mode 100644 workmanager_windows/test/workmanager_windows_test.dart diff --git a/docs.json b/docs.json index 192d9bd0..a923a47f 100644 --- a/docs.json +++ b/docs.json @@ -48,7 +48,8 @@ { "title": "Linux (experimental)", "href": "/linux" - }, + "title": "Windows (Task Scheduler)", + "href": "/windows" }, { "title": "Troubleshooting", "href": "/troubleshooting" diff --git a/docs/windows.mdx b/docs/windows.mdx new file mode 100644 index 00000000..2cbb2c47 --- /dev/null +++ b/docs/windows.mdx @@ -0,0 +1,136 @@ +--- +title: "Windows (Task Scheduler)" +description: Task Scheduler based background execution on Windows +--- + +Windows support is provided by the `workmanager_windows` package. Background +tasks are registered as **per-user Task Scheduler tasks** via the `schtasks` +command line; when a task fires, Windows launches your app executable with a +`--background-task ` argument, the app runs the registered callback +handler headless and exits. v1 is pure Dart — no COM, no C++ and no `win32` +dependency. + +> ⚠️ **EXPERIMENTAL.** Read [Honest limitations](#honest-limitations) before +> using this package. + +## Setup + +### 1. Add the dependency + +```yaml +dependencies: + workmanager_windows: ^0.0.1 +``` + +The main `workmanager` package delegates to this package on Windows, so +`Workmanager()` works there too. + +### 2. Make `main()` headless-aware + +The Task Scheduler launches the app with `--background-task`, so `main()` +must detect that argument and run the dispatcher instead of the UI: + +```dart +import 'package:workmanager/workmanager.dart'; +import 'package:workmanager_windows/workmanager_windows.dart'; + +@pragma('vm:entry-point') +void callbackDispatcher() { + WorkmanagerWindows().executeTask((taskName, inputData) async { + // Do your background work here. The full Flutter engine is available, + // but no window is shown. Return true on success — the return value + // becomes the process exit code Task Scheduler records as the task's + // last result. + return true; + }); +} + +void main(List args) { + if (WorkmanagerWindows.maybeRunBackgroundTask(args, callbackDispatcher)) { + return; + } + Workmanager().initialize(callbackDispatcher); + runApp(const MyApp()); +} +``` + +The headless path runs the dispatcher, invokes the registered handler with +the persisted payload, logs the result and exits. The dispatcher must +register its handler through `WorkmanagerWindows().executeTask(...)` (or +`WorkmanagerExecution.instance.executeTask(...)`), not +`Workmanager().executeTask(...)` — on Windows the handler runs in a separate +process, so it is registered in the shared execution registry instead of the +main package's in-process handler slot. + +### 3. Register tasks as usual + +```dart +final workmanager = Workmanager(); +await workmanager.registerOneOffTask( + 'unique-name', + 'taskName', + inputData: {'key': 'value'}, + initialDelay: const Duration(minutes: 5), +); +await workmanager.registerPeriodicTask( + 'unique-name', + 'taskName', + frequency: const Duration(minutes: 30), +); +``` + +## Supported surface + +- **One-off tasks** — `schtasks /Create /SC ONCE` at `now + initialDelay`. +- **Periodic tasks** — `schtasks /Create /SC DAILY /RI `; the + frequency is clamped to 1 minute..416 days. +- **`inputData`** — persisted as a JSON file under + `%LOCALAPPDATA%\workmanager_windows\payloads\.json` and passed + to the headless process via `--payload-file`. +- **`cancelByUniqueName` / `cancelAll`** — `schtasks /End` (running + instance) + `schtasks /Delete` + payload cleanup. +- **`isScheduledByUniqueName`** — via `schtasks /Query`. +- **`printScheduledTasks`** — JSON list of the plugin's tasks parsed from + `schtasks /Query /FO CSV`. + +iOS-only task types and `cancelByTag` throw `UnsupportedError`. + +## Honest limitations + +- **Per-user tasks, logged-on only.** Tasks run as the user who registered + them and only while that user is logged on. **"Run whether the user is + logged on or not" requires credentials or SYSTEM and therefore admin + rights** (`schtasks /Create /RU ... /RP ...` or `schtasks /Create /RU + SYSTEM`) — not exposed in the v1 API. An administrator can still edit the + registered task in Task Scheduler and enable it. +- **Minute granularity.** There is no sub-minute scheduling. A zero + `initialDelay` is rounded up to the next minute so one-off tasks still run. +- **Locale-sensitive CLI.** `schtasks` parses dates with the system regional + format; v1 emits `MM/DD/YYYY` and assumes an English (en-US) regional + setting. +- **Constraints and backoff are accepted but ignored** (documented no-ops): + Task Scheduler does not expose `WakeToRun`, battery/AC, idle, network or + storage conditions through the `schtasks` CLI, and there is no WorkManager + style retry/backoff. `existingWorkPolicy` is not honored — re-registering a + `uniqueName` always replaces the task (`/F`). +- No guarantee beyond the registered task itself: the app must be installed + in a way that lets Task Scheduler start it, and the machine must be on when + the task fires. +- `printScheduledTasks` assumes the standard `/Query /FO CSV` columns (Task + Name, Next Run Time, Status). + +## Structure + +``` +workmanager_windows/ + lib/ + workmanager_windows.dart # WorkmanagerWindows (WorkmanagerPlatform impl) + execution.dart # Flutter-free handler registry + headless runner + src/ + process_runner.dart # injectable ProcessRunner (schtasks invocation) + schtasks.dart # schtasks command-line builder (unit-tested) + payload_store.dart # on-disk JSON payload persistence +``` + +See the [`workmanager_windows` README](../../workmanager_windows/README.md) +for the full API reference. diff --git a/melos.yaml b/melos.yaml index 97426b12..b831b2ce 100644 --- a/melos.yaml +++ b/melos.yaml @@ -6,6 +6,7 @@ packages: - workmanager_apple - workmanager_linux - workmanager_web + - workmanager_windows - example scripts: get: melos exec -- dart pub get diff --git a/workmanager/README.md b/workmanager/README.md index 2e8b2713..bce27da3 100644 --- a/workmanager/README.md +++ b/workmanager/README.md @@ -71,7 +71,7 @@ This plugin uses a federated architecture with platform-specific implementations - **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) - +- **workmanager_windows**: Windows implementation using the Task Scheduler (experimental, see [docs](../docs/windows.mdx)) ## 🐛 Support & Issues - **Documentation**: [docs.page/fluttercommunity/flutter_workmanager](https://docs.page/fluttercommunity/flutter_workmanager) diff --git a/workmanager/lib/src/workmanager_impl.dart b/workmanager/lib/src/workmanager_impl.dart index 77c20ce4..f98708fa 100644 --- a/workmanager/lib/src/workmanager_impl.dart +++ b/workmanager/lib/src/workmanager_impl.dart @@ -8,6 +8,7 @@ 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'; +import 'package:workmanager_windows/workmanager_windows.dart'; /// Function that executes your background work. /// You should return whether the task ran successfully or not. @@ -109,13 +110,17 @@ class Workmanager { return; } if (WorkmanagerPlatform.instance is! WorkmanagerAndroid && - WorkmanagerPlatform.instance is! WorkmanagerApple) { + WorkmanagerPlatform.instance is! WorkmanagerApple && + WorkmanagerPlatform.instance is! WorkmanagerLinux && + WorkmanagerPlatform.instance is! WorkmanagerWindows) { if (Platform.isAndroid) { WorkmanagerPlatform.instance = WorkmanagerAndroid(); } else if (Platform.isIOS || Platform.isMacOS) { WorkmanagerPlatform.instance = WorkmanagerApple(); } else if (Platform.isLinux) { WorkmanagerPlatform.instance = WorkmanagerLinux(); + } else if (Platform.isWindows) { + WorkmanagerPlatform.instance = WorkmanagerWindows(); } } } diff --git a/workmanager/pubspec.yaml b/workmanager/pubspec.yaml index 02c8d3b8..38c773bf 100644 --- a/workmanager/pubspec.yaml +++ b/workmanager/pubspec.yaml @@ -18,6 +18,9 @@ dependencies: workmanager_apple: ^0.9.9 workmanager_web: ^0.1.3 workmanager_linux: ^0.1.1 + workmanager_windows: + path: ../workmanager_windows +>>>>>>> d094a27 (feat(windows): add workmanager_windows Task Scheduler based background execution) dev_dependencies: test: ^1.25.15 diff --git a/workmanager_windows/CHANGELOG.md b/workmanager_windows/CHANGELOG.md new file mode 100644 index 00000000..4344e78d --- /dev/null +++ b/workmanager_windows/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.0.1 + +- Experimental Windows implementation of `workmanager` based on Task Scheduler + (`schtasks` CLI): one-off and periodic task registration, cancellation, + status queries, and headless `--background-task` execution. diff --git a/workmanager_windows/LICENSE b/workmanager_windows/LICENSE new file mode 100644 index 00000000..c5d510f7 --- /dev/null +++ b/workmanager_windows/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 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_windows/README.md b/workmanager_windows/README.md new file mode 100644 index 00000000..49ae744a --- /dev/null +++ b/workmanager_windows/README.md @@ -0,0 +1,123 @@ +# workmanager_windows + +Windows implementation of `workmanager` for Flutter, backed by **Task +Scheduler**. It registers one-off and periodic tasks as per-user scheduled +tasks via the `schtasks` command line; when a task fires, Windows launches +your app executable with a `--background-task ` argument, the app +runs the registered callback handler headless and exits. + +> ⚠️ **EXPERIMENTAL.** Read [Honest limitations](#honest-limitations) before +> using this package. v1 uses the `schtasks` CLI only — no COM, no native +> code and no `win32` dependency — and is pure Dart. + +## How it works + +| Situation | Mechanism | +|---|---| +| One-off task | `schtasks /Create /SC ONCE` — runs once at `now + initialDelay` (minute granularity). | +| Periodic task | `schtasks /Create /SC DAILY /RI ` — repeats every `frequency` (clamped to ≥ 1 minute, ≤ 416 days). | +| Input data | Persisted as a JSON file in `%LOCALAPPDATA%\workmanager_windows\payloads\.json` and passed to the headless process via `--payload-file`. | +| Execution | Task Scheduler launches ` --background-task [--payload-file ]`; the app runs the registered handler and exits with `0`/`1`. | +| Cancellation | `schtasks /End` (running instance) + `schtasks /Delete` + payload cleanup. | + +## Setup + +### 1. Add the dependency + +```yaml +dependencies: + workmanager_windows: ^0.0.1 +``` + +### 2. Make `main()` headless-aware + +```dart +import 'package:workmanager/workmanager.dart'; +import 'package:workmanager_windows/workmanager_windows.dart'; + +@pragma('vm:entry-point') +void callbackDispatcher() { + WorkmanagerWindows().executeTask((taskName, inputData) async { + // Do your background work here. The full Flutter engine is available, + // but no window is shown. + return true; + }); +} + +void main(List args) { + if (WorkmanagerWindows.maybeRunBackgroundTask(args, callbackDispatcher)) { + return; + } + Workmanager().initialize(callbackDispatcher); + runApp(const MyApp()); +} +``` + +### 3. Register tasks as usual + +```dart +final workmanager = Workmanager(); +await workmanager.registerOneOffTask( + 'unique-name', + 'taskName', + inputData: {'key': 'value'}, + initialDelay: const Duration(minutes: 5), +); +await workmanager.registerPeriodicTask( + 'unique-name', + 'taskName', + frequency: const Duration(minutes: 30), +); +``` + +## API + +- `registerOneOffTask(...)` / `registerPeriodicTask(...)` — same signatures as + `Workmanager()` +- `cancelByUniqueName(...)` / `cancelAll()` +- `isScheduledByUniqueName(...)` +- `printScheduledTasks()` — JSON list of the plugin's scheduled tasks +- `maybeRunBackgroundTask(args, callbackDispatcher)` — headless entry point +- `WorkmanagerWindows().executeTask(handler)` — registers the handler inside + the callback dispatcher + +iOS-only task types (`registerProcessingTask`, `registerHealthResearchTask`, +`registerContinuedProcessingTask`) and `cancelByTag` throw +`UnsupportedError`. + +## Honest limitations + +- **Per-user tasks.** Task Scheduler tasks run as the user who registered + them, only while that user is logged on. "Run whether the user is logged on + or not" requires elevated privileges (`schtasks /Create /RU ... /RP ...` or + `SYSTEM`) — not exposed in v1; an admin can enable it for a task manually. +- **Minute granularity.** The scheduler has no sub-minute precision; a zero + `initialDelay` is rounded up to the next minute so one-off tasks still run. +- **Locale-sensitive CLI.** `schtasks` parses dates with the system regional + format; v1 emits `MM/DD/YYYY` and assumes an English (en-US) regional + setting. +- **Constraints/backoff are accepted but ignored** (no-ops): Task Scheduler + does not expose `WakeToRun`, battery/AC, idle or network conditions through + the `schtasks` CLI. Re-registering a `uniqueName` always replaces the task + (`existingWorkPolicy` is not honored). +- No equivalent of WorkManager's guaranteed-while-app-killed semantics beyond + the registered task itself, and no sub-minute or exact-time guarantees. +- `printScheduledTasks` parses `/Query /FO CSV` output and assumes the + standard three columns (TaskName, Next Run Time, Status). + +See [`docs/windows.mdx`](../docs/windows.mdx) for the full documentation. + +## Structure + +``` +lib/ + workmanager_windows.dart # WorkmanagerWindows (WorkmanagerPlatform impl) + execution.dart # Flutter-free handler registry + headless runner + src/ + process_runner.dart # injectable ProcessRunner (schtasks invocation) + schtasks.dart # schtasks command-line builder (unit-tested) + payload_store.dart # on-disk JSON payload persistence +``` + +The `ProcessRunner` is injectable, so the whole package is unit-testable on +any host without Windows. diff --git a/workmanager_windows/analysis_options.yaml b/workmanager_windows/analysis_options.yaml new file mode 100644 index 00000000..901610bf --- /dev/null +++ b/workmanager_windows/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml + +formatter: + page_width: 120 diff --git a/workmanager_windows/lib/execution.dart b/workmanager_windows/lib/execution.dart new file mode 100644 index 00000000..371ffb24 --- /dev/null +++ b/workmanager_windows/lib/execution.dart @@ -0,0 +1,141 @@ +// Copyright 2026 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 and headless runner for Windows. +/// +/// Mirrors `package:workmanager_web/execution.dart`: the callback dispatcher +/// registers its handler with [WorkmanagerExecution], and the headless +/// process started by Task Scheduler (` --background-task `) +/// invokes that handler, logs the result and exits. +library; + +import 'dart:convert'; +import 'dart:io'; + +/// 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 — everything JSON-encodable). +typedef BackgroundTaskHandler = Future Function( + String taskName, + Map? inputData, +); + +/// Shared registry that holds the currently registered background task +/// handler. +/// +/// One instance is shared by the foreground app and the headless processes +/// Task Scheduler starts, so dispatcher code stays identical everywhere. +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 `WorkmanagerWindows().initialize(...)`. + /// + /// Kept so the foreground app can re-run the dispatcher when needed. + 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); + } +} + +/// Returns the task name passed on the command line via +/// `--background-task `, or `null` when [args] do not describe a +/// background-task invocation. +String? backgroundTaskNameFromArgs(List args) { + final index = args.indexOf('--background-task'); + if (index < 0 || index + 1 >= args.length) { + return null; + } + return args[index + 1]; +} + +/// Returns the payload file path passed via `--payload-file `, or +/// `null` when absent. +String? payloadFilePathFromArgs(List args) { + final index = args.indexOf('--payload-file'); + if (index < 0 || index + 1 >= args.length) { + return null; + } + return args[index + 1]; +} + +/// Runs one background task to completion and returns the process exit code +/// Task Scheduler records as the task's last result: +/// +/// * `0` — the handler ran and returned `true`. +/// * `1` — the handler returned `false`, threw, or no handler was registered. +/// +/// [callbackDispatcher] is invoked first so it can register its handler (see +/// [WorkmanagerExecution]); [payloadFilePath] is the JSON payload written at +/// registration time, or `null` when the task was registered without +/// `inputData`. +Future runBackgroundTask( + String taskName, { + required String? payloadFilePath, + required Function callbackDispatcher, +}) async { + try { + final inputData = await readPayloadFile(payloadFilePath); + callbackDispatcher(); + final handler = WorkmanagerExecution.instance.taskHandler; + if (handler == null) { + stderr.writeln( + 'workmanager_windows: no background task handler was registered for ' + '"$taskName". On Windows the callbackDispatcher must register the ' + 'handler with WorkmanagerWindows().executeTask(...) (or ' + 'WorkmanagerExecution.instance.executeTask(...)).', + ); + return 1; + } + final result = await handler(taskName, inputData); + stdout.writeln( + 'workmanager_windows: background task "$taskName" finished ' + '(result: $result).', + ); + return result ? 0 : 1; + } catch (error, stackTrace) { + stderr.writeln( + 'workmanager_windows: background task "$taskName" failed: $error', + ); + stderr.writeln(stackTrace); + return 1; + } +} + +/// Reads and JSON-decodes the payload file at [path]. +/// +/// Returns `null` when [path] is `null`, the file does not exist, or its +/// content is not a JSON object. +Future?> readPayloadFile(String? path) async { + if (path == null) { + return null; + } + final file = File(path); + if (!await file.exists()) { + return null; + } + final decoded = jsonDecode(await file.readAsString()); + return decoded is Map ? decoded : null; +} diff --git a/workmanager_windows/lib/src/payload_store.dart b/workmanager_windows/lib/src/payload_store.dart new file mode 100644 index 00000000..d767edd6 --- /dev/null +++ b/workmanager_windows/lib/src/payload_store.dart @@ -0,0 +1,83 @@ +// Copyright 2026 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'; + +/// Persists task `inputData` as JSON files on disk (an Android-style on-disk +/// payload), one file per `uniqueName`. +/// +/// The file path is embedded in the registered Task Scheduler action and +/// passed to the headless process via `--payload-file `, mirroring how +/// `workmanager_android` keeps task payloads out of the scheduling layer. +class PayloadStore { + /// Creates a store rooted at [directory]. + PayloadStore(this.directory); + + /// Directory holding one `.json` file per registered task. + final Directory directory; + + /// Returns the payload file for [uniqueName]. + /// + /// The name is sanitized (characters outside `[A-Za-z0-9_-]` are replaced + /// with `_`) so a `uniqueName` can never escape [directory]. + File fileFor(String uniqueName) { + final sanitized = uniqueName.replaceAll(RegExp(r'[^A-Za-z0-9_\-]'), '_'); + return File( + '${directory.path}${Platform.pathSeparator}$sanitized.json', + ); + } + + /// Writes [inputData] for [uniqueName]. + /// + /// Returns the written file, or `null` when [inputData] is `null` (no + /// payload file is created). Throws [ArgumentError] when [inputData] is not + /// JSON-encodable. + Future write(String uniqueName, Map? inputData) async { + if (inputData == null) { + return null; + } + final file = fileFor(uniqueName); + try { + await directory.create(recursive: true); + await file.writeAsString(jsonEncode(inputData)); + } on JsonUnsupportedObjectError catch (error) { + throw ArgumentError( + 'inputData for "$uniqueName" is not JSON-encodable: $error', + ); + } + return file; + } + + /// Reads back the payload for [uniqueName], or `null` when absent or not a + /// JSON object. + Future?> read(String uniqueName) async { + final file = fileFor(uniqueName); + if (!await file.exists()) { + return null; + } + final decoded = jsonDecode(await file.readAsString()); + return decoded is Map ? decoded : null; + } + + /// Deletes the payload file for [uniqueName], if any. + Future delete(String uniqueName) async { + final file = fileFor(uniqueName); + if (await file.exists()) { + await file.delete(); + } + } + + /// Deletes every payload file in [directory]. + Future deleteAll() async { + if (!await directory.exists()) { + return; + } + await for (final entity in directory.list()) { + if (entity is File && entity.path.endsWith('.json')) { + await entity.delete(); + } + } + } +} diff --git a/workmanager_windows/lib/src/process_runner.dart b/workmanager_windows/lib/src/process_runner.dart new file mode 100644 index 00000000..2635c910 --- /dev/null +++ b/workmanager_windows/lib/src/process_runner.dart @@ -0,0 +1,23 @@ +// Copyright 2026 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. +/// +/// Injectable so `workmanager_windows` can be unit-tested on any host without +/// a Windows shell: tests substitute a fake that records the command lines +/// instead of executing them. +abstract interface class ProcessRunner { + /// Runs [executable] with [arguments] and waits for it to complete. + Future run(String executable, List arguments); +} + +/// Default [ProcessRunner] backed by `dart:io` [Process.run]. +class DefaultProcessRunner implements ProcessRunner { + const DefaultProcessRunner(); + + @override + Future run(String executable, List arguments) => Process.run(executable, arguments); +} diff --git a/workmanager_windows/lib/src/schtasks.dart b/workmanager_windows/lib/src/schtasks.dart new file mode 100644 index 00000000..dfc53562 --- /dev/null +++ b/workmanager_windows/lib/src/schtasks.dart @@ -0,0 +1,172 @@ +// Copyright 2026 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. + +/// Builds `schtasks` command lines for the Task Scheduler operations used by +/// `workmanager_windows`. +/// +/// v1 deliberately uses the `schtasks` command line only — no COM, no C++ and +/// no `win32` dependency — so every operation is a plain [ProcessRunner] +/// invocation and the package stays pure Dart and fully unit-testable off +/// Windows. +library; + +/// Maximum repetition interval accepted by `schtasks /RI` (416 days). +const int maxRepetitionMinutes = 599940; + +/// Builds `schtasks` command lines. +class Schtasks { + Schtasks._(); + + /// The `schtasks` executable name (resolved from `PATH` on Windows). + static const String executable = 'schtasks'; + + /// Builds `schtasks /Create` arguments for a one-off task that runs once at + /// [startTime]. + static List createOneOff({ + required String taskId, + required String action, + required DateTime startTime, + bool overwrite = true, + }) => + [ + '/Create', + '/TN', + taskId, + '/TR', + action, + '/SC', + 'ONCE', + '/SD', + formatDate(startTime), + '/ST', + formatTime(startTime), + if (overwrite) '/F', + ]; + + /// Builds `schtasks /Create` arguments for a periodic task that repeats + /// every [repeatMinutes] minutes (clamped to 1..[maxRepetitionMinutes]), + /// anchored at [startTime]. + static List createPeriodic({ + required String taskId, + required String action, + required DateTime startTime, + required int repeatMinutes, + bool overwrite = true, + }) => + [ + '/Create', + '/TN', + taskId, + '/TR', + action, + '/SC', + 'DAILY', + '/SD', + formatDate(startTime), + '/ST', + formatTime(startTime), + '/RI', + '${repeatMinutes.clamp(1, maxRepetitionMinutes)}', + if (overwrite) '/F', + ]; + + /// Builds the `/TR` action that launches the app headless: + /// ` --background-task [--payload-file ]`. + /// + /// Paths and values are wrapped in inner quotes so paths containing spaces + /// (e.g. `C:\Program Files\...`) survive Task Scheduler's command line + /// parsing. + static String buildAction({ + required String executablePath, + required String taskName, + String? payloadFilePath, + }) { + final buffer = StringBuffer('"$executablePath"'); + buffer.write(' --background-task "$taskName"'); + if (payloadFilePath != null) { + buffer.write(' --payload-file "$payloadFilePath"'); + } + return buffer.toString(); + } + + /// Builds `schtasks /End` arguments, stopping a running task instance. + static List end(String taskId) => ['/End', '/TN', taskId]; + + /// Builds `schtasks /Delete` arguments, removing the task (including a + /// running instance). + static List delete(String taskId) => ['/Delete', '/TN', taskId, '/F']; + + /// Builds `schtasks /Query` arguments for a single task. + /// + /// Exit code `0` means the task exists. + static List query(String taskId) => ['/Query', '/TN', taskId]; + + /// Builds `schtasks /Query` arguments listing every task as CSV without a + /// header row. + static List queryAllCsv() => ['/Query', '/FO', 'CSV', '/NH']; + + /// Maps a [frequency] to the `schtasks /RI` repetition interval in minutes: + /// sub-minute frequencies are clamped to 1 minute, frequencies above + /// [maxRepetitionMinutes] (416 days) are clamped to it. + static int repeatMinutesFor(Duration frequency) => frequency.inMinutes.clamp(1, maxRepetitionMinutes); + + /// Formats [time] as `MM/DD/YYYY`. + /// + /// `schtasks` parses dates using the system's regional format; the + /// MM/DD/YYYY form requires an English (en-US) style regional setting on + /// the target machine. + static String formatDate(DateTime time) => + '${time.month.toString().padLeft(2, '0')}/${time.day.toString().padLeft(2, '0')}/${time.year}'; + + /// Formats [time] as `HH:mm` (24-hour clock, zero-padded). + static String formatTime(DateTime time) => + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + + /// Rounds [time] to minute granularity, guaranteeing the result is strictly + /// in the future relative to [now]. + /// + /// Task Scheduler never runs a task whose scheduled start has already + /// passed (a one-off task with a past start time silently never fires), so + /// a zero `initialDelay` is rounded up to the next minute. + static DateTime ensureFutureMinute(DateTime time, {DateTime? now}) { + final reference = now ?? DateTime.now(); + final truncated = DateTime(time.year, time.month, time.day, time.hour, time.minute); + if (!truncated.isAfter(reference)) { + return truncated.add(const Duration(minutes: 1)); + } + return truncated; + } + + /// Parses `schtasks /Query /FO CSV` output (with or without header row) + /// into rows keyed by `TaskName`, `NextRunTime` and `Status`. + /// + /// Rows that do not have at least three columns are skipped. The parser is + /// deliberately lenient: quoted fields are unquoted by stripping the outer + /// quotes and splitting on `","` boundaries. + static List> parseQueryCsv(String output) { + final rows = >[]; + for (final line in output.trim().split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + final parts = _splitCsvLine(trimmed); + if (parts.length >= 3) { + rows.add({ + 'TaskName': parts[0], + 'NextRunTime': parts[1], + 'Status': parts[2], + }); + } + } + return rows; + } + + static List _splitCsvLine(String line) { + if (line.startsWith('"') && line.endsWith('"')) { + return line.substring(1, line.length - 1).split('","'); + } + return line.split(','); + } +} diff --git a/workmanager_windows/lib/workmanager_windows.dart b/workmanager_windows/lib/workmanager_windows.dart new file mode 100644 index 00000000..ed0a6150 --- /dev/null +++ b/workmanager_windows/lib/workmanager_windows.dart @@ -0,0 +1,352 @@ +// Copyright 2026 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:convert'; +import 'dart:io'; + +import 'package:workmanager_platform_interface/workmanager_platform_interface.dart'; + +import 'execution.dart'; +import 'src/payload_store.dart'; +import 'src/process_runner.dart'; +import 'src/schtasks.dart'; + +export 'execution.dart'; + +/// Windows implementation of [WorkmanagerPlatform] backed by Task Scheduler. +/// +/// v1 uses the `schtasks` command line only — no COM, no C++ and no `win32` +/// dependency. Registering a task creates a per-user Task Scheduler task +/// whose action launches the app executable with +/// `--background-task `; [maybeRunBackgroundTask] detects that +/// argument in `main()`, runs the registered callback handler headless and +/// exits (see `docs/windows.mdx`). +/// +/// See the package README and `docs/windows.mdx` for the honest limitations +/// (per-user tasks, minute granularity, constraints accepted but ignored). +class WorkmanagerWindows extends WorkmanagerPlatform { + /// Creates the implementation. + /// + /// [processRunner] and [payloadDirectory] are injectable for tests; by + /// default real `schtasks` processes and `%LOCALAPPDATA%` are used. + WorkmanagerWindows({ + ProcessRunner? processRunner, + Directory? payloadDirectory, + }) : processRunner = processRunner ?? const DefaultProcessRunner(), + payloadStore = PayloadStore(payloadDirectory ?? defaultPayloadDirectory()); + + /// Prefix for the Task Scheduler task names owned by this plugin. + /// + /// Registered tasks are stored as `workmanager_` so + /// [cancelAll] and [printScheduledTasks] can identify the plugin's tasks. + static const String taskNamePrefix = 'workmanager_'; + + /// The process runner used for every `schtasks` invocation. + final ProcessRunner processRunner; + + /// The on-disk payload store. + final PayloadStore payloadStore; + + /// The default payload directory: + /// `%LOCALAPPDATA%\workmanager_windows\payloads`. + static Directory defaultPayloadDirectory() { + final localAppData = Platform.environment['LOCALAPPDATA']; + final base = localAppData ?? + '${Platform.environment['USERPROFILE'] ?? '.'}' + '${Platform.pathSeparator}AppData${Platform.pathSeparator}Local'; + return Directory( + '$base${Platform.pathSeparator}workmanager_windows' + '${Platform.pathSeparator}payloads', + ); + } + + /// Registers this implementation as the default [WorkmanagerPlatform] for + /// Windows. Called by the generated plugin registrant. + static void registerWith() { + WorkmanagerPlatform.instance = WorkmanagerWindows(); + } + + /// Maps a [uniqueName] to the Task Scheduler task name. + String taskIdFor(String uniqueName) => '$taskNamePrefix$uniqueName'; + + /// Headless entry point, called at the very top of `main()`: + /// + /// ```dart + /// void main(List args) { + /// if (WorkmanagerWindows.maybeRunBackgroundTask(args, callbackDispatcher)) { + /// return; + /// } + /// runApp(const MyApp()); + /// } + /// ``` + /// + /// Returns `false` (and does nothing) when [args] do not contain + /// `--background-task`, i.e. this is a normal app launch. When the + /// argument is present, runs [callbackDispatcher] so it can register its + /// handler, invokes the handler with the persisted payload, logs the result + /// and terminates the process with an exit code Task Scheduler records + /// (`0` success, `1` failure). + static bool maybeRunBackgroundTask( + List args, + Function callbackDispatcher, + ) { + final taskName = backgroundTaskNameFromArgs(args); + if (taskName == null) { + return false; + } + unawaited(_runHeadlessAndExit(taskName, args, callbackDispatcher)); + return true; + } + + static Future _runHeadlessAndExit( + String taskName, + List args, + Function callbackDispatcher, + ) async { + final exitCode = await runBackgroundTask( + taskName, + payloadFilePath: payloadFilePathFromArgs(args), + callbackDispatcher: callbackDispatcher, + ); + await stdout.flush(); + await stderr.flush(); + exit(exitCode); + } + + @override + Future initialize( + Function callbackDispatcher, { + @Deprecated('Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, + }) async { + WorkmanagerExecution.instance.callbackDispatcher = callbackDispatcher; + } + + /// Registers the background task handler (mirrors + /// `Workmanager().executeTask(...)`). + /// + /// The handler runs in the headless process Task Scheduler starts. It runs + /// with the full Flutter engine available, but no window; keep the work + /// short and return `true` on success (the return value becomes the + /// process exit code Task Scheduler records as the task's last result). + void executeTask(BackgroundTaskHandler backgroundTaskHandler) { + WorkmanagerExecution.instance.executeTask(backgroundTaskHandler); + } + + @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 payloadFile = await payloadStore.write(uniqueName, inputData); + await _createTask( + Schtasks.createOneOff( + taskId: taskIdFor(uniqueName), + action: _buildAction(taskName, payloadFile?.path), + startTime: Schtasks.ensureFutureMinute( + DateTime.now().add(initialDelay ?? Duration.zero), + ), + ), + uniqueName, + ); + } + + @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 payloadFile = await payloadStore.write(uniqueName, inputData); + await _createTask( + Schtasks.createPeriodic( + taskId: taskIdFor(uniqueName), + action: _buildAction(taskName, payloadFile?.path), + startTime: Schtasks.ensureFutureMinute( + DateTime.now().add(initialDelay ?? Duration.zero), + ), + repeatMinutes: Schtasks.repeatMinutesFor( + frequency ?? const Duration(minutes: 15), + ), + ), + uniqueName, + ); + } + + @override + Future registerProcessingTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + Constraints? constraints, + }) async { + throw UnsupportedError('Processing tasks are not supported on Windows.'); + } + + @override + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + Constraints? constraints, + }) async { + throw UnsupportedError( + 'Health research tasks are not supported on Windows.', + ); + } + + @override + Future registerContinuedProcessingTask( + String uniqueName, + String taskName, { + String? title, + String? subtitle, + Map? inputData, + }) async { + throw UnsupportedError( + 'Continued processing tasks are not supported on Windows.', + ); + } + + @override + Future cancelByUniqueName(String uniqueName) async { + final taskId = taskIdFor(uniqueName); + // Stop a running instance first (benign when the task is not running). + await processRunner.run(Schtasks.executable, Schtasks.end(taskId)); + final deleteResult = await processRunner.run( + Schtasks.executable, + Schtasks.delete(taskId), + ); + if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + throw StateError( + 'Failed to cancel "$uniqueName": schtasks exited with ' + '${deleteResult.exitCode}.\n${deleteResult.stderr}', + ); + } + await payloadStore.delete(uniqueName); + } + + @override + Future cancelByTag(String tag) async { + throw UnsupportedError( + 'cancelByTag is not supported on Windows (v1). ' + 'Use cancelByUniqueName or cancelAll instead.', + ); + } + + @override + Future cancelAll() async { + final taskIds = await _listOwnedTaskIds(); + for (final taskId in taskIds) { + await processRunner.run(Schtasks.executable, Schtasks.end(taskId)); + final deleteResult = await processRunner.run( + Schtasks.executable, + Schtasks.delete(taskId), + ); + if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + throw StateError( + 'Failed to delete task "$taskId": schtasks exited with ' + '${deleteResult.exitCode}.\n${deleteResult.stderr}', + ); + } + } + await payloadStore.deleteAll(); + } + + @override + Future isScheduledByUniqueName(String uniqueName) async { + final result = await processRunner.run( + Schtasks.executable, + Schtasks.query(taskIdFor(uniqueName)), + ); + if (result.exitCode == 0) { + return true; + } + if (result.exitCode == 1) { + return false; + } + throw StateError( + 'Failed to query "$uniqueName": schtasks exited with ' + '${result.exitCode}.\n${result.stderr}', + ); + } + + @override + Future printScheduledTasks() async { + final result = await processRunner.run( + Schtasks.executable, + Schtasks.queryAllCsv(), + ); + _throwIfFailed(result, 'query tasks'); + final owned = Schtasks.parseQueryCsv(result.stdout.toString()) + .where((row) => (row['TaskName'] ?? '').startsWith(taskNamePrefix)) + .toList(); + return jsonEncode(owned); + } + + String _buildAction(String taskName, String? payloadFilePath) => Schtasks.buildAction( + executablePath: Platform.resolvedExecutable, + taskName: taskName, + payloadFilePath: payloadFilePath, + ); + + Future _createTask(List args, String uniqueName) async { + final result = await processRunner.run(Schtasks.executable, args); + if (result.exitCode != 0) { + // Roll the payload back so no orphaned file outlives the failed task. + await payloadStore.delete(uniqueName); + throw StateError( + 'Failed to register task: schtasks exited with ${result.exitCode}.\n' + '${result.stderr}', + ); + } + } + + Future> _listOwnedTaskIds() async { + final result = await processRunner.run( + Schtasks.executable, + Schtasks.queryAllCsv(), + ); + _throwIfFailed(result, 'query tasks'); + return Schtasks.parseQueryCsv(result.stdout.toString()) + .map((row) => row['TaskName']) + .whereType() + .where((name) => name.startsWith(taskNamePrefix)) + .toList(); + } + + void _throwIfFailed(ProcessResult result, String operation) { + if (result.exitCode != 0) { + throw StateError( + 'Failed to $operation: schtasks exited with ${result.exitCode}.\n' + '${result.stderr}', + ); + } + } + + bool _isMissingTask(String stderr) { + final normalized = stderr.toLowerCase(); + return normalized.contains('cannot find the file specified') || normalized.contains('does not exist'); + } +} diff --git a/workmanager_windows/pubspec.yaml b/workmanager_windows/pubspec.yaml new file mode 100644 index 00000000..4678d8b2 --- /dev/null +++ b/workmanager_windows/pubspec.yaml @@ -0,0 +1,29 @@ +name: workmanager_windows +description: Windows implementation of the workmanager plugin using the Task Scheduler (schtasks). +version: 0.0.1 +# 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 + +flutter: + plugin: + implements: workmanager + platforms: + windows: + dartPluginClass: WorkmanagerWindows diff --git a/workmanager_windows/test/execution_test.dart b/workmanager_windows/test/execution_test.dart new file mode 100644 index 00000000..f446a807 --- /dev/null +++ b/workmanager_windows/test/execution_test.dart @@ -0,0 +1,178 @@ +// Copyright 2026 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_windows/execution.dart'; + +void main() { + group('WorkmanagerExecution', () { + test('executeTask registers the handler and runTask invokes it', () async { + final execution = WorkmanagerExecution.instance; + execution.executeTask((taskName, inputData) async { + expect(taskName, 'demo'); + expect(inputData, {'key': 'value'}); + return true; + }); + final result = await execution.runTask('demo', { + 'key': 'value', + }); + expect(result, isTrue); + }); + + test('runTask returns false when no handler is registered', () async { + final execution = WorkmanagerExecution.instance; + execution.taskHandler = null; + final result = await execution.runTask('unregistered', null); + expect(result, isFalse); + }); + }); + + group('argument parsing', () { + test('backgroundTaskNameFromArgs returns the task name', () { + expect( + backgroundTaskNameFromArgs([ + '--background-task', + 'demo', + '--payload-file', + 'C:\\p.json', + ]), + 'demo', + ); + }); + + test('backgroundTaskNameFromArgs returns null without the flag', () { + expect(backgroundTaskNameFromArgs(['--foo', 'bar']), isNull); + }); + + test('backgroundTaskNameFromArgs returns null when the flag is last', () { + expect( + backgroundTaskNameFromArgs(['--background-task']), + isNull, + ); + }); + + test('payloadFilePathFromArgs returns the path', () { + expect( + payloadFilePathFromArgs([ + '--background-task', + 'demo', + '--payload-file', + 'C:\\AppData\\payload.json', + ]), + 'C:\\AppData\\payload.json', + ); + }); + + test('payloadFilePathFromArgs returns null when absent', () { + expect( + payloadFilePathFromArgs(['--background-task', 'demo']), + isNull, + ); + }); + }); + + group('runBackgroundTask', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('wm_windows_exec_'); + WorkmanagerExecution.instance.taskHandler = null; + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + Future writePayload(Map payload) async { + final file = File('${tempDir.path}${Platform.pathSeparator}payload.json'); + await file.writeAsString(jsonEncode(payload)); + return file.path; + } + + test('runs the registered handler with the persisted payload', () async { + final payloadPath = await writePayload({'n': 1}); + var ran = false; + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: payloadPath, + callbackDispatcher: () { + WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + ran = true; + expect(taskName, 'demo'); + expect(inputData, {'n': 1}); + return true; + }); + }, + ); + expect(ran, isTrue); + expect(exitCode, 0); + }); + + test('passes null inputData when no payload file was given', () async { + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: null, + callbackDispatcher: () { + WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + expect(inputData, isNull); + return true; + }); + }, + ); + expect(exitCode, 0); + }); + + test('passes null inputData when the payload file is missing', () async { + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: '${tempDir.path}${Platform.pathSeparator}nope.json', + callbackDispatcher: () { + WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + expect(inputData, isNull); + return true; + }); + }, + ); + expect(exitCode, 0); + }); + + test('returns 1 when the handler returns false', () async { + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: null, + callbackDispatcher: () { + WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + return false; + }); + }, + ); + expect(exitCode, 1); + }); + + test('returns 1 when the handler throws', () async { + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: null, + callbackDispatcher: () { + WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + throw StateError('boom'); + }); + }, + ); + expect(exitCode, 1); + }); + + test('returns 1 when no handler was registered', () async { + final exitCode = await runBackgroundTask( + 'demo', + payloadFilePath: null, + callbackDispatcher: () {}, + ); + expect(exitCode, 1); + }); + }); +} diff --git a/workmanager_windows/test/payload_store_test.dart b/workmanager_windows/test/payload_store_test.dart new file mode 100644 index 00000000..8a08a607 --- /dev/null +++ b/workmanager_windows/test/payload_store_test.dart @@ -0,0 +1,104 @@ +// Copyright 2026 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_windows/src/payload_store.dart'; + +void main() { + late Directory tempDir; + late PayloadStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('wm_windows_payload_'); + store = PayloadStore(Directory('${tempDir.path}${Platform.pathSeparator}payloads')); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + group('PayloadStore.write/read', () { + test('round-trips nested inputData through JSON', () async { + final file = await store.write('demo', { + 'string': 'value', + 'int': 42, + 'double': 3.14, + 'bool': true, + 'null': null, + 'list': [1, 'two', false], + 'nested': { + 'deep': {'key': 1} + }, + }); + expect(file, isNotNull); + expect(await store.read('demo'), { + 'string': 'value', + 'int': 42, + 'double': 3.14, + 'bool': true, + 'null': null, + 'list': [1, 'two', false], + 'nested': { + 'deep': {'key': 1} + }, + }); + }); + + test('write with null inputData creates no file and returns null', () async { + final file = await store.write('demo', null); + expect(file, isNull); + expect(await store.read('demo'), isNull); + expect(store.fileFor('demo').existsSync(), isFalse); + }); + + test('read returns null for an unknown uniqueName', () async { + expect(await store.read('missing'), isNull); + }); + + test('rejects non-JSON-encodable inputData', () async { + expect( + () => store.write('demo', {'obj': Object()}), + throwsArgumentError, + ); + }); + }); + + group('PayloadStore.fileFor sanitization', () { + test('replaces path separators and unsafe characters', () { + final file = store.fileFor('../evil/name'); + expect(file.path, contains('__evil_name.json')); + expect(file.path, isNot(contains('..'))); + expect(file.path, startsWith(store.directory.path)); + }); + + test('keeps safe names intact', () { + final file = store.fileFor('my_task-42'); + expect(file.path, endsWith('my_task-42.json')); + }); + }); + + group('PayloadStore.delete', () { + test('deletes a single payload', () async { + await store.write('demo', {'key': 'value'}); + await store.write('other', {'key': 'value'}); + await store.delete('demo'); + expect(await store.read('demo'), isNull); + expect(await store.read('other'), isNotNull); + }); + + test('delete is a no-op for unknown names', () async { + await store.delete('missing'); + }); + + test('deleteAll clears every payload file', () async { + await store.write('one', {'a': 1}); + await store.write('two', {'b': 2}); + await store.deleteAll(); + expect(await store.read('one'), isNull); + expect(await store.read('two'), isNull); + }); + }); +} diff --git a/workmanager_windows/test/schtasks_test.dart b/workmanager_windows/test/schtasks_test.dart new file mode 100644 index 00000000..3f6a7b31 --- /dev/null +++ b/workmanager_windows/test/schtasks_test.dart @@ -0,0 +1,226 @@ +// Copyright 2026 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_windows/src/schtasks.dart'; + +void main() { + group('Schtasks.createOneOff', () { + final startTime = DateTime(2026, 8, 3, 11, 15); + + test('builds a /SC ONCE task with /SD, /ST and /F', () { + expect( + Schtasks.createOneOff( + taskId: 'workmanager_demo', + action: '"C:\\App.exe" --background-task "demo"', + startTime: startTime, + ), + [ + '/Create', + '/TN', + 'workmanager_demo', + '/TR', + '"C:\\App.exe" --background-task "demo"', + '/SC', + 'ONCE', + '/SD', + '08/03/2026', + '/ST', + '11:15', + '/F', + ], + ); + }); + + test('omits /F when overwrite is false', () { + final args = Schtasks.createOneOff( + taskId: 'workmanager_demo', + action: 'app.exe', + startTime: startTime, + overwrite: false, + ); + expect(args, isNot(contains('/F'))); + }); + }); + + group('Schtasks.createPeriodic', () { + final startTime = DateTime(2026, 8, 3, 9, 0); + + test('builds a /SC DAILY task with /RI repetition', () { + expect( + Schtasks.createPeriodic( + taskId: 'workmanager_sync', + action: '"C:\\App.exe" --background-task "sync"', + startTime: startTime, + repeatMinutes: 30, + ), + [ + '/Create', + '/TN', + 'workmanager_sync', + '/TR', + '"C:\\App.exe" --background-task "sync"', + '/SC', + 'DAILY', + '/SD', + '08/03/2026', + '/ST', + '09:00', + '/RI', + '30', + '/F', + ], + ); + }); + + test('clamps the repetition interval to the schtasks range', () { + final args = Schtasks.createPeriodic( + taskId: 'workmanager_sync', + action: 'app.exe', + startTime: startTime, + repeatMinutes: 1000000, + ); + expect(args[args.indexOf('/RI') + 1], '$maxRepetitionMinutes'); + }); + }); + + group('Schtasks.buildAction', () { + test('wraps paths with spaces in inner quotes', () { + expect( + Schtasks.buildAction( + executablePath: 'C:\\Program Files\\My App\\my_app.exe', + taskName: 'demo', + ), + '"C:\\Program Files\\My App\\my_app.exe" --background-task "demo"', + ); + }); + + test('appends --payload-file only when a path is given', () { + final action = Schtasks.buildAction( + executablePath: 'C:\\App.exe', + taskName: 'demo', + payloadFilePath: 'C:\\Users\\me\\payload.json', + ); + expect( + action, + '"C:\\App.exe" --background-task "demo" ' + '--payload-file "C:\\Users\\me\\payload.json"', + ); + }); + }); + + group('Schtasks end/delete/query', () { + test('end stops a running task', () { + expect( + Schtasks.end('workmanager_demo'), + ['/End', '/TN', 'workmanager_demo'], + ); + }); + + test('delete removes a task including a running instance', () { + expect( + Schtasks.delete('workmanager_demo'), + ['/Delete', '/TN', 'workmanager_demo', '/F'], + ); + }); + + test('query targets a single task', () { + expect( + Schtasks.query('workmanager_demo'), + ['/Query', '/TN', 'workmanager_demo'], + ); + }); + + test('queryAllCsv lists all tasks without a header', () { + expect( + Schtasks.queryAllCsv(), + ['/Query', '/FO', 'CSV', '/NH'], + ); + }); + }); + + group('Schtasks formatting', () { + test('formatDate zero-pads month and day as MM/DD/YYYY', () { + expect(Schtasks.formatDate(DateTime(2026, 3, 8, 9, 0)), '03/08/2026'); + expect(Schtasks.formatDate(DateTime(2026, 12, 25, 9, 0)), '12/25/2026'); + }); + + test('formatTime zero-pads as HH:mm on a 24-hour clock', () { + expect(Schtasks.formatTime(DateTime(2026, 8, 3, 9, 5)), '09:05'); + expect(Schtasks.formatTime(DateTime(2026, 8, 3, 23, 59)), '23:59'); + }); + }); + + group('Schtasks.repeatMinutesFor', () { + test('clamps sub-minute frequencies to one minute', () { + expect(Schtasks.repeatMinutesFor(const Duration(seconds: 30)), 1); + expect(Schtasks.repeatMinutesFor(const Duration(seconds: 90)), 1); + }); + + test('maps multi-day frequencies to minutes', () { + expect(Schtasks.repeatMinutesFor(const Duration(days: 2)), 2880); + }); + + test('caps frequencies above the schtasks maximum', () { + expect( + Schtasks.repeatMinutesFor(const Duration(days: 500)), + maxRepetitionMinutes, + ); + }); + }); + + group('Schtasks.ensureFutureMinute', () { + final now = DateTime(2026, 8, 3, 11, 15, 30); + + test('rounds a past or current-minute time up to the next minute', () { + final rounded = Schtasks.ensureFutureMinute( + DateTime(2026, 8, 3, 11, 15, 10), + now: now, + ); + expect(rounded, DateTime(2026, 8, 3, 11, 16)); + }); + + test('keeps a future time truncated to minute granularity', () { + final rounded = Schtasks.ensureFutureMinute( + DateTime(2026, 8, 3, 11, 17, 45), + now: now, + ); + expect(rounded, DateTime(2026, 8, 3, 11, 17)); + }); + }); + + group('Schtasks.parseQueryCsv', () { + test('parses quoted CSV rows into TaskName/NextRunTime/Status', () { + const output = '"workmanager_demo","8/3/2026 11:15:00 AM","Ready"\n' + '"workmanager_sync","8/4/2026 9:00:00 AM","Running"\n' + '"SomeOtherTask","N/A","Disabled"'; + final rows = Schtasks.parseQueryCsv(output); + expect(rows, >[ + { + 'TaskName': 'workmanager_demo', + 'NextRunTime': '8/3/2026 11:15:00 AM', + 'Status': 'Ready', + }, + { + 'TaskName': 'workmanager_sync', + 'NextRunTime': '8/4/2026 9:00:00 AM', + 'Status': 'Running', + }, + { + 'TaskName': 'SomeOtherTask', + 'NextRunTime': 'N/A', + 'Status': 'Disabled', + }, + ]); + }); + + test('skips empty lines and malformed rows', () { + const output = '\n"workmanager_demo","8/3/2026 11:15:00 AM","Ready"\n' + '"too","few"\n'; + final rows = Schtasks.parseQueryCsv(output); + expect(rows, hasLength(1)); + expect(rows.single['TaskName'], 'workmanager_demo'); + }); + }); +} diff --git a/workmanager_windows/test/workmanager_windows_test.dart b/workmanager_windows/test/workmanager_windows_test.dart new file mode 100644 index 00000000..d37e469c --- /dev/null +++ b/workmanager_windows/test/workmanager_windows_test.dart @@ -0,0 +1,312 @@ +// Copyright 2026 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:flutter_test/flutter_test.dart'; +import 'package:workmanager_windows/src/payload_store.dart'; +import 'package:workmanager_windows/src/process_runner.dart'; +import 'package:workmanager_windows/workmanager_windows.dart'; + +/// Records `schtasks` invocations and returns canned results, so the platform +/// implementation is testable without a Windows shell. +class FakeProcessRunner implements ProcessRunner { + final List> calls = >[]; + final List queuedResults = []; + + int exitCode = 0; + String stdout = ''; + String stderr = ''; + + @override + Future run(String executable, List arguments) async { + calls.add(arguments); + final queued = queuedResults.isEmpty ? null : queuedResults.removeAt(0); + return ProcessResult( + 0, + queued?.exitCode ?? exitCode, + queued?.stdout ?? stdout, + queued?.stderr ?? stderr, + ); + } +} + +class FakeProcessResult { + FakeProcessResult({this.exitCode = 0, this.stdout = '', this.stderr = ''}); + + final int exitCode; + final String stdout; + final String stderr; +} + +void main() { + late Directory tempDir; + late FakeProcessRunner runner; + late WorkmanagerWindows windows; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('wm_windows_platform_'); + runner = FakeProcessRunner(); + windows = WorkmanagerWindows( + processRunner: runner, + payloadDirectory: Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ); + WorkmanagerExecution.instance.taskHandler = null; + WorkmanagerExecution.instance.callbackDispatcher = null; + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + group('registration', () { + test('registerOneOffTask creates a /SC ONCE task with payload and action', () async { + await windows.registerOneOffTask( + 'demo', + 'demoTask', + inputData: {'key': 'value'}, + initialDelay: const Duration(minutes: 5), + ); + + expect(runner.calls, hasLength(1)); + final args = runner.calls.single; + expect(args.sublist(0, 5), [ + '/Create', + '/TN', + 'workmanager_demo', + '/TR', + '"${Platform.resolvedExecutable}" --background-task "demoTask" ' + '--payload-file "${tempDir.path}${Platform.pathSeparator}' + 'payloads${Platform.pathSeparator}demo.json"', + ]); + expect(args, containsAllInOrder(['/SC', 'ONCE'])); + expect(args, contains('/SD')); + expect(args, contains('/ST')); + expect(args, contains('/F')); + + // The payload file was persisted and round-trips. + final store = PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ); + expect(await store.read('demo'), {'key': 'value'}); + }); + + test('registerOneOffTask without inputData passes no --payload-file', () async { + await windows.registerOneOffTask('demo', 'demoTask'); + final args = runner.calls.single; + final action = args[args.indexOf('/TR') + 1]; + expect(action, isNot(contains('--payload-file'))); + expect( + PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo').existsSync(), + isFalse, + ); + }); + + test('registerOneOffTask rolls the payload back when schtasks fails', () async { + runner.exitCode = 2; + runner.stderr = 'ERROR: Access is denied.'; + await expectLater( + windows.registerOneOffTask('demo', 'demoTask'), + throwsStateError, + ); + expect( + PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo').existsSync(), + isFalse, + ); + }); + + test('registerOneOffTask rejects non-JSON-encodable inputData', () async { + await expectLater( + windows.registerOneOffTask( + 'demo', + 'demoTask', + inputData: {'obj': Object()}, + ), + throwsArgumentError, + ); + expect(runner.calls, isEmpty); + }); + + test('registerPeriodicTask creates a /SC DAILY task with /RI', () async { + await windows.registerPeriodicTask( + 'sync', + 'syncTask', + frequency: const Duration(minutes: 30), + ); + final args = runner.calls.single; + expect(args.sublist(0, 4), ['/Create', '/TN', 'workmanager_sync', '/TR']); + expect(args, containsAllInOrder(['/SC', 'DAILY'])); + expect(args, containsAllInOrder(['/RI', '30'])); + }); + + test('registerPeriodicTask defaults the frequency to 15 minutes', () async { + await windows.registerPeriodicTask('sync', 'syncTask'); + final args = runner.calls.single; + expect(args, containsAllInOrder(['/RI', '15'])); + }); + }); + + group('cancellation', () { + test('cancelByUniqueName ends and deletes the task and its payload', () async { + await windows.registerOneOffTask( + 'demo', + 'demoTask', + inputData: {'key': 'value'}, + ); + runner.calls.clear(); + final payloadFile = PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo'); + expect(payloadFile.existsSync(), isTrue); + + await windows.cancelByUniqueName('demo'); + + expect(runner.calls, hasLength(2)); + expect(runner.calls[0], ['/End', '/TN', 'workmanager_demo']); + expect( + runner.calls[1], + ['/Delete', '/TN', 'workmanager_demo', '/F'], + ); + expect(payloadFile.existsSync(), isFalse); + }); + + test('cancelByUniqueName is idempotent for a missing task', () async { + runner.exitCode = 1; + runner.stderr = 'ERROR: The system cannot find the file specified.'; + await windows.cancelByUniqueName('demo'); + }); + + test('cancelByUniqueName throws on unexpected schtasks failures', () async { + runner.exitCode = 5; + runner.stderr = 'ERROR: something else.'; + await expectLater(windows.cancelByUniqueName('demo'), throwsStateError); + }); + + test('cancelAll deletes only owned tasks and clears payloads', () async { + await windows.registerOneOffTask( + 'one', + 'oneTask', + inputData: {'k': 1}, + ); + await windows.registerOneOffTask( + 'two', + 'twoTask', + inputData: {'k': 2}, + ); + final store = PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ); + expect(await store.read('one'), isNotNull); + expect(await store.read('two'), isNotNull); + + runner.calls.clear(); + runner.stdout = '"workmanager_one","8/3/2026 11:15:00 AM","Ready"\n' + '"workmanager_two","8/4/2026 9:00:00 AM","Ready"\n' + '"SomeOtherTask","N/A","Disabled"'; + await windows.cancelAll(); + + // One query plus /End and /Delete for each of the two owned tasks. + expect(runner.calls, hasLength(5)); + expect(runner.calls[0], ['/Query', '/FO', 'CSV', '/NH']); + expect(runner.calls[1], ['/End', '/TN', 'workmanager_one']); + expect( + runner.calls[2], + ['/Delete', '/TN', 'workmanager_one', '/F'], + ); + expect(runner.calls[3], ['/End', '/TN', 'workmanager_two']); + expect( + runner.calls[4], + ['/Delete', '/TN', 'workmanager_two', '/F'], + ); + expect(await store.read('one'), isNull); + expect(await store.read('two'), isNull); + }); + }); + + group('queries', () { + test('isScheduledByUniqueName is true when the task exists', () async { + expect(await windows.isScheduledByUniqueName('demo'), isTrue); + expect( + runner.calls.single, + ['/Query', '/TN', 'workmanager_demo'], + ); + }); + + test('isScheduledByUniqueName is false when the task is missing', () async { + runner.exitCode = 1; + expect(await windows.isScheduledByUniqueName('demo'), isFalse); + }); + + test('printScheduledTasks returns only tasks owned by the plugin', () async { + runner.stdout = '"workmanager_demo","8/3/2026 11:15:00 AM","Ready"\n' + '"workmanager_sync","8/4/2026 9:00:00 AM","Running"\n' + '"SomeOtherTask","N/A","Disabled"'; + final json = await windows.printScheduledTasks(); + final decoded = jsonDecode(json) as List; + expect(decoded, hasLength(2)); + expect(decoded[0], { + 'TaskName': 'workmanager_demo', + 'NextRunTime': '8/3/2026 11:15:00 AM', + 'Status': 'Ready', + }); + expect(decoded[1], { + 'TaskName': 'workmanager_sync', + 'NextRunTime': '8/4/2026 9:00:00 AM', + 'Status': 'Running', + }); + }); + }); + + group('dispatcher wiring', () { + test('initialize stores the callback dispatcher in the registry', () async { + void dispatcher() {} + await windows.initialize(dispatcher); + expect(WorkmanagerExecution.instance.callbackDispatcher, dispatcher); + }); + + test('executeTask registers the handler in the registry', () async { + Future handler(String taskName, Map? inputData) async => true; + windows.executeTask(handler); + expect(WorkmanagerExecution.instance.taskHandler, handler); + }); + }); + + group('unsupported surface', () { + test('registerProcessingTask throws UnsupportedError', () { + expect( + () => windows.registerProcessingTask('p', 'p'), + throwsUnsupportedError, + ); + }); + + test('registerHealthResearchTask throws UnsupportedError', () { + expect( + () => windows.registerHealthResearchTask('h', 'h'), + throwsUnsupportedError, + ); + }); + + test('registerContinuedProcessingTask throws UnsupportedError', () { + expect( + () => windows.registerContinuedProcessingTask('c', 'c'), + throwsUnsupportedError, + ); + }); + + test('cancelByTag throws UnsupportedError', () { + expect(() => windows.cancelByTag('tag'), throwsUnsupportedError); + }); + }); + + group('task naming', () { + test('taskIdFor prefixes unique names', () { + expect(windows.taskIdFor('demo'), 'workmanager_demo'); + }); + }); +} From fc01e67cdbf2b6f41084696b91568c71e347d7a3 Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 17:15:08 +0100 Subject: [PATCH 2/4] style: format workmanager_windows to the repo's effective format style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI format job resolves no package-local .dart_tool for the new package, so dart format applies the root package_config's language version — different line-breaking for long annotations and chained conditions than the package-local context the draft was formatted in. Reformat all package files under the CI-equivalent context. --- .../lib/src/payload_store.dart | 9 +- .../lib/src/process_runner.dart | 3 +- workmanager_windows/lib/src/schtasks.dart | 18 +- .../lib/workmanager_windows.dart | 21 +- workmanager_windows/test/execution_test.dart | 30 ++- .../test/payload_store_test.dart | 23 +- .../test/workmanager_windows_test.dart | 255 ++++++++++-------- 7 files changed, 216 insertions(+), 143 deletions(-) diff --git a/workmanager_windows/lib/src/payload_store.dart b/workmanager_windows/lib/src/payload_store.dart index d767edd6..707d98bb 100644 --- a/workmanager_windows/lib/src/payload_store.dart +++ b/workmanager_windows/lib/src/payload_store.dart @@ -24,9 +24,7 @@ class PayloadStore { /// with `_`) so a `uniqueName` can never escape [directory]. File fileFor(String uniqueName) { final sanitized = uniqueName.replaceAll(RegExp(r'[^A-Za-z0-9_\-]'), '_'); - return File( - '${directory.path}${Platform.pathSeparator}$sanitized.json', - ); + return File('${directory.path}${Platform.pathSeparator}$sanitized.json'); } /// Writes [inputData] for [uniqueName]. @@ -34,7 +32,10 @@ class PayloadStore { /// Returns the written file, or `null` when [inputData] is `null` (no /// payload file is created). Throws [ArgumentError] when [inputData] is not /// JSON-encodable. - Future write(String uniqueName, Map? inputData) async { + Future write( + String uniqueName, + Map? inputData, + ) async { if (inputData == null) { return null; } diff --git a/workmanager_windows/lib/src/process_runner.dart b/workmanager_windows/lib/src/process_runner.dart index 2635c910..6dc58dd4 100644 --- a/workmanager_windows/lib/src/process_runner.dart +++ b/workmanager_windows/lib/src/process_runner.dart @@ -19,5 +19,6 @@ class DefaultProcessRunner implements ProcessRunner { const DefaultProcessRunner(); @override - Future run(String executable, List arguments) => Process.run(executable, arguments); + Future run(String executable, List arguments) => + Process.run(executable, arguments); } diff --git a/workmanager_windows/lib/src/schtasks.dart b/workmanager_windows/lib/src/schtasks.dart index dfc53562..5e828afd 100644 --- a/workmanager_windows/lib/src/schtasks.dart +++ b/workmanager_windows/lib/src/schtasks.dart @@ -95,7 +95,12 @@ class Schtasks { /// Builds `schtasks /Delete` arguments, removing the task (including a /// running instance). - static List delete(String taskId) => ['/Delete', '/TN', taskId, '/F']; + static List delete(String taskId) => [ + '/Delete', + '/TN', + taskId, + '/F', + ]; /// Builds `schtasks /Query` arguments for a single task. /// @@ -109,7 +114,8 @@ class Schtasks { /// Maps a [frequency] to the `schtasks /RI` repetition interval in minutes: /// sub-minute frequencies are clamped to 1 minute, frequencies above /// [maxRepetitionMinutes] (416 days) are clamped to it. - static int repeatMinutesFor(Duration frequency) => frequency.inMinutes.clamp(1, maxRepetitionMinutes); + static int repeatMinutesFor(Duration frequency) => + frequency.inMinutes.clamp(1, maxRepetitionMinutes); /// Formats [time] as `MM/DD/YYYY`. /// @@ -131,7 +137,13 @@ class Schtasks { /// a zero `initialDelay` is rounded up to the next minute. static DateTime ensureFutureMinute(DateTime time, {DateTime? now}) { final reference = now ?? DateTime.now(); - final truncated = DateTime(time.year, time.month, time.day, time.hour, time.minute); + final truncated = DateTime( + time.year, + time.month, + time.day, + time.hour, + time.minute, + ); if (!truncated.isAfter(reference)) { return truncated.add(const Duration(minutes: 1)); } diff --git a/workmanager_windows/lib/workmanager_windows.dart b/workmanager_windows/lib/workmanager_windows.dart index ed0a6150..5243b8f8 100644 --- a/workmanager_windows/lib/workmanager_windows.dart +++ b/workmanager_windows/lib/workmanager_windows.dart @@ -35,7 +35,9 @@ class WorkmanagerWindows extends WorkmanagerPlatform { ProcessRunner? processRunner, Directory? payloadDirectory, }) : processRunner = processRunner ?? const DefaultProcessRunner(), - payloadStore = PayloadStore(payloadDirectory ?? defaultPayloadDirectory()); + payloadStore = PayloadStore( + payloadDirectory ?? defaultPayloadDirectory(), + ); /// Prefix for the Task Scheduler task names owned by this plugin. /// @@ -118,7 +120,10 @@ class WorkmanagerWindows extends WorkmanagerPlatform { @override Future initialize( Function callbackDispatcher, { - @Deprecated('Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.', + ) + bool isInDebugMode = false, }) async { WorkmanagerExecution.instance.callbackDispatcher = callbackDispatcher; } @@ -238,7 +243,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && + !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to cancel "$uniqueName": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -264,7 +270,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && + !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to delete task "$taskId": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -305,7 +312,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { return jsonEncode(owned); } - String _buildAction(String taskName, String? payloadFilePath) => Schtasks.buildAction( + String _buildAction(String taskName, String? payloadFilePath) => + Schtasks.buildAction( executablePath: Platform.resolvedExecutable, taskName: taskName, payloadFilePath: payloadFilePath, @@ -347,6 +355,7 @@ class WorkmanagerWindows extends WorkmanagerPlatform { bool _isMissingTask(String stderr) { final normalized = stderr.toLowerCase(); - return normalized.contains('cannot find the file specified') || normalized.contains('does not exist'); + return normalized.contains('cannot find the file specified') || + normalized.contains('does not exist'); } } diff --git a/workmanager_windows/test/execution_test.dart b/workmanager_windows/test/execution_test.dart index f446a807..e3a12942 100644 --- a/workmanager_windows/test/execution_test.dart +++ b/workmanager_windows/test/execution_test.dart @@ -49,10 +49,7 @@ void main() { }); test('backgroundTaskNameFromArgs returns null when the flag is last', () { - expect( - backgroundTaskNameFromArgs(['--background-task']), - isNull, - ); + expect(backgroundTaskNameFromArgs(['--background-task']), isNull); }); test('payloadFilePathFromArgs returns the path', () { @@ -100,7 +97,10 @@ void main() { 'demo', payloadFilePath: payloadPath, callbackDispatcher: () { - WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + WorkmanagerExecution.instance.executeTask(( + taskName, + inputData, + ) async { ran = true; expect(taskName, 'demo'); expect(inputData, {'n': 1}); @@ -117,7 +117,10 @@ void main() { 'demo', payloadFilePath: null, callbackDispatcher: () { - WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + WorkmanagerExecution.instance.executeTask(( + taskName, + inputData, + ) async { expect(inputData, isNull); return true; }); @@ -131,7 +134,10 @@ void main() { 'demo', payloadFilePath: '${tempDir.path}${Platform.pathSeparator}nope.json', callbackDispatcher: () { - WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + WorkmanagerExecution.instance.executeTask(( + taskName, + inputData, + ) async { expect(inputData, isNull); return true; }); @@ -145,7 +151,10 @@ void main() { 'demo', payloadFilePath: null, callbackDispatcher: () { - WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + WorkmanagerExecution.instance.executeTask(( + taskName, + inputData, + ) async { return false; }); }, @@ -158,7 +167,10 @@ void main() { 'demo', payloadFilePath: null, callbackDispatcher: () { - WorkmanagerExecution.instance.executeTask((taskName, inputData) async { + WorkmanagerExecution.instance.executeTask(( + taskName, + inputData, + ) async { throw StateError('boom'); }); }, diff --git a/workmanager_windows/test/payload_store_test.dart b/workmanager_windows/test/payload_store_test.dart index 8a08a607..24d69acc 100644 --- a/workmanager_windows/test/payload_store_test.dart +++ b/workmanager_windows/test/payload_store_test.dart @@ -13,7 +13,9 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('wm_windows_payload_'); - store = PayloadStore(Directory('${tempDir.path}${Platform.pathSeparator}payloads')); + store = PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ); }); tearDown(() async { @@ -30,7 +32,7 @@ void main() { 'null': null, 'list': [1, 'two', false], 'nested': { - 'deep': {'key': 1} + 'deep': {'key': 1}, }, }); expect(file, isNotNull); @@ -42,17 +44,20 @@ void main() { 'null': null, 'list': [1, 'two', false], 'nested': { - 'deep': {'key': 1} + 'deep': {'key': 1}, }, }); }); - test('write with null inputData creates no file and returns null', () async { - final file = await store.write('demo', null); - expect(file, isNull); - expect(await store.read('demo'), isNull); - expect(store.fileFor('demo').existsSync(), isFalse); - }); + test( + 'write with null inputData creates no file and returns null', + () async { + final file = await store.write('demo', null); + expect(file, isNull); + expect(await store.read('demo'), isNull); + expect(store.fileFor('demo').existsSync(), isFalse); + }, + ); test('read returns null for an unknown uniqueName', () async { expect(await store.read('missing'), isNull); diff --git a/workmanager_windows/test/workmanager_windows_test.dart b/workmanager_windows/test/workmanager_windows_test.dart index d37e469c..3143672b 100644 --- a/workmanager_windows/test/workmanager_windows_test.dart +++ b/workmanager_windows/test/workmanager_windows_test.dart @@ -51,7 +51,9 @@ void main() { runner = FakeProcessRunner(); windows = WorkmanagerWindows( processRunner: runner, - payloadDirectory: Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + payloadDirectory: Directory( + '${tempDir.path}${Platform.pathSeparator}payloads', + ), ); WorkmanagerExecution.instance.taskHandler = null; WorkmanagerExecution.instance.callbackDispatcher = null; @@ -62,64 +64,73 @@ void main() { }); group('registration', () { - test('registerOneOffTask creates a /SC ONCE task with payload and action', () async { - await windows.registerOneOffTask( - 'demo', - 'demoTask', - inputData: {'key': 'value'}, - initialDelay: const Duration(minutes: 5), - ); - - expect(runner.calls, hasLength(1)); - final args = runner.calls.single; - expect(args.sublist(0, 5), [ - '/Create', - '/TN', - 'workmanager_demo', - '/TR', - '"${Platform.resolvedExecutable}" --background-task "demoTask" ' - '--payload-file "${tempDir.path}${Platform.pathSeparator}' - 'payloads${Platform.pathSeparator}demo.json"', - ]); - expect(args, containsAllInOrder(['/SC', 'ONCE'])); - expect(args, contains('/SD')); - expect(args, contains('/ST')); - expect(args, contains('/F')); - - // The payload file was persisted and round-trips. - final store = PayloadStore( - Directory('${tempDir.path}${Platform.pathSeparator}payloads'), - ); - expect(await store.read('demo'), {'key': 'value'}); - }); - - test('registerOneOffTask without inputData passes no --payload-file', () async { - await windows.registerOneOffTask('demo', 'demoTask'); - final args = runner.calls.single; - final action = args[args.indexOf('/TR') + 1]; - expect(action, isNot(contains('--payload-file'))); - expect( - PayloadStore( + test( + 'registerOneOffTask creates a /SC ONCE task with payload and action', + () async { + await windows.registerOneOffTask( + 'demo', + 'demoTask', + inputData: {'key': 'value'}, + initialDelay: const Duration(minutes: 5), + ); + + expect(runner.calls, hasLength(1)); + final args = runner.calls.single; + expect(args.sublist(0, 5), [ + '/Create', + '/TN', + 'workmanager_demo', + '/TR', + '"${Platform.resolvedExecutable}" --background-task "demoTask" ' + '--payload-file "${tempDir.path}${Platform.pathSeparator}' + 'payloads${Platform.pathSeparator}demo.json"', + ]); + expect(args, containsAllInOrder(['/SC', 'ONCE'])); + expect(args, contains('/SD')); + expect(args, contains('/ST')); + expect(args, contains('/F')); + + // The payload file was persisted and round-trips. + final store = PayloadStore( Directory('${tempDir.path}${Platform.pathSeparator}payloads'), - ).fileFor('demo').existsSync(), - isFalse, - ); - }); + ); + expect(await store.read('demo'), {'key': 'value'}); + }, + ); - test('registerOneOffTask rolls the payload back when schtasks fails', () async { - runner.exitCode = 2; - runner.stderr = 'ERROR: Access is denied.'; - await expectLater( - windows.registerOneOffTask('demo', 'demoTask'), - throwsStateError, - ); - expect( - PayloadStore( - Directory('${tempDir.path}${Platform.pathSeparator}payloads'), - ).fileFor('demo').existsSync(), - isFalse, - ); - }); + test( + 'registerOneOffTask without inputData passes no --payload-file', + () async { + await windows.registerOneOffTask('demo', 'demoTask'); + final args = runner.calls.single; + final action = args[args.indexOf('/TR') + 1]; + expect(action, isNot(contains('--payload-file'))); + expect( + PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo').existsSync(), + isFalse, + ); + }, + ); + + test( + 'registerOneOffTask rolls the payload back when schtasks fails', + () async { + runner.exitCode = 2; + runner.stderr = 'ERROR: Access is denied.'; + await expectLater( + windows.registerOneOffTask('demo', 'demoTask'), + throwsStateError, + ); + expect( + PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo').existsSync(), + isFalse, + ); + }, + ); test('registerOneOffTask rejects non-JSON-encodable inputData', () async { await expectLater( @@ -140,7 +151,12 @@ void main() { frequency: const Duration(minutes: 30), ); final args = runner.calls.single; - expect(args.sublist(0, 4), ['/Create', '/TN', 'workmanager_sync', '/TR']); + expect(args.sublist(0, 4), [ + '/Create', + '/TN', + 'workmanager_sync', + '/TR', + ]); expect(args, containsAllInOrder(['/SC', 'DAILY'])); expect(args, containsAllInOrder(['/RI', '30'])); }); @@ -153,28 +169,33 @@ void main() { }); group('cancellation', () { - test('cancelByUniqueName ends and deletes the task and its payload', () async { - await windows.registerOneOffTask( - 'demo', - 'demoTask', - inputData: {'key': 'value'}, - ); - runner.calls.clear(); - final payloadFile = PayloadStore( - Directory('${tempDir.path}${Platform.pathSeparator}payloads'), - ).fileFor('demo'); - expect(payloadFile.existsSync(), isTrue); - - await windows.cancelByUniqueName('demo'); - - expect(runner.calls, hasLength(2)); - expect(runner.calls[0], ['/End', '/TN', 'workmanager_demo']); - expect( - runner.calls[1], - ['/Delete', '/TN', 'workmanager_demo', '/F'], - ); - expect(payloadFile.existsSync(), isFalse); - }); + test( + 'cancelByUniqueName ends and deletes the task and its payload', + () async { + await windows.registerOneOffTask( + 'demo', + 'demoTask', + inputData: {'key': 'value'}, + ); + runner.calls.clear(); + final payloadFile = PayloadStore( + Directory('${tempDir.path}${Platform.pathSeparator}payloads'), + ).fileFor('demo'); + expect(payloadFile.existsSync(), isTrue); + + await windows.cancelByUniqueName('demo'); + + expect(runner.calls, hasLength(2)); + expect(runner.calls[0], ['/End', '/TN', 'workmanager_demo']); + expect(runner.calls[1], [ + '/Delete', + '/TN', + 'workmanager_demo', + '/F', + ]); + expect(payloadFile.existsSync(), isFalse); + }, + ); test('cancelByUniqueName is idempotent for a missing task', () async { runner.exitCode = 1; @@ -215,15 +236,19 @@ void main() { expect(runner.calls, hasLength(5)); expect(runner.calls[0], ['/Query', '/FO', 'CSV', '/NH']); expect(runner.calls[1], ['/End', '/TN', 'workmanager_one']); - expect( - runner.calls[2], - ['/Delete', '/TN', 'workmanager_one', '/F'], - ); + expect(runner.calls[2], [ + '/Delete', + '/TN', + 'workmanager_one', + '/F', + ]); expect(runner.calls[3], ['/End', '/TN', 'workmanager_two']); - expect( - runner.calls[4], - ['/Delete', '/TN', 'workmanager_two', '/F'], - ); + expect(runner.calls[4], [ + '/Delete', + '/TN', + 'workmanager_two', + '/F', + ]); expect(await store.read('one'), isNull); expect(await store.read('two'), isNull); }); @@ -232,10 +257,11 @@ void main() { group('queries', () { test('isScheduledByUniqueName is true when the task exists', () async { expect(await windows.isScheduledByUniqueName('demo'), isTrue); - expect( - runner.calls.single, - ['/Query', '/TN', 'workmanager_demo'], - ); + expect(runner.calls.single, [ + '/Query', + '/TN', + 'workmanager_demo', + ]); }); test('isScheduledByUniqueName is false when the task is missing', () async { @@ -243,24 +269,27 @@ void main() { expect(await windows.isScheduledByUniqueName('demo'), isFalse); }); - test('printScheduledTasks returns only tasks owned by the plugin', () async { - runner.stdout = '"workmanager_demo","8/3/2026 11:15:00 AM","Ready"\n' - '"workmanager_sync","8/4/2026 9:00:00 AM","Running"\n' - '"SomeOtherTask","N/A","Disabled"'; - final json = await windows.printScheduledTasks(); - final decoded = jsonDecode(json) as List; - expect(decoded, hasLength(2)); - expect(decoded[0], { - 'TaskName': 'workmanager_demo', - 'NextRunTime': '8/3/2026 11:15:00 AM', - 'Status': 'Ready', - }); - expect(decoded[1], { - 'TaskName': 'workmanager_sync', - 'NextRunTime': '8/4/2026 9:00:00 AM', - 'Status': 'Running', - }); - }); + test( + 'printScheduledTasks returns only tasks owned by the plugin', + () async { + runner.stdout = '"workmanager_demo","8/3/2026 11:15:00 AM","Ready"\n' + '"workmanager_sync","8/4/2026 9:00:00 AM","Running"\n' + '"SomeOtherTask","N/A","Disabled"'; + final json = await windows.printScheduledTasks(); + final decoded = jsonDecode(json) as List; + expect(decoded, hasLength(2)); + expect(decoded[0], { + 'TaskName': 'workmanager_demo', + 'NextRunTime': '8/3/2026 11:15:00 AM', + 'Status': 'Ready', + }); + expect(decoded[1], { + 'TaskName': 'workmanager_sync', + 'NextRunTime': '8/4/2026 9:00:00 AM', + 'Status': 'Running', + }); + }, + ); }); group('dispatcher wiring', () { @@ -271,7 +300,11 @@ void main() { }); test('executeTask registers the handler in the registry', () async { - Future handler(String taskName, Map? inputData) async => true; + Future handler( + String taskName, + Map? inputData, + ) async => + true; windows.executeTask(handler); expect(WorkmanagerExecution.instance.taskHandler, handler); }); From e7d5329319d213e2e02cadaaa2daec8cf400acfc Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 18:03:22 +0100 Subject: [PATCH 3/4] =?UTF-8?q?merge:=20rebase=20onto=20main=20(expedited/?= =?UTF-8?q?status/linux=20merged)=20=E2=80=94=20resolve=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selection block: keep Linux + Windows branches; guard excludes both - pubspec: keep release-bumped versions, add windows path dep - docs/README: keep both platform entries - accept expedited param on Windows (no-op, API parity with 0.10.2) --- workmanager/pubspec.yaml | 1 - workmanager_windows/lib/src/process_runner.dart | 3 +-- workmanager_windows/lib/src/schtasks.dart | 3 +-- workmanager_windows/lib/workmanager_windows.dart | 14 ++++++-------- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/workmanager/pubspec.yaml b/workmanager/pubspec.yaml index 38c773bf..263a461a 100644 --- a/workmanager/pubspec.yaml +++ b/workmanager/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: workmanager_linux: ^0.1.1 workmanager_windows: path: ../workmanager_windows ->>>>>>> d094a27 (feat(windows): add workmanager_windows Task Scheduler based background execution) dev_dependencies: test: ^1.25.15 diff --git a/workmanager_windows/lib/src/process_runner.dart b/workmanager_windows/lib/src/process_runner.dart index 6dc58dd4..2635c910 100644 --- a/workmanager_windows/lib/src/process_runner.dart +++ b/workmanager_windows/lib/src/process_runner.dart @@ -19,6 +19,5 @@ class DefaultProcessRunner implements ProcessRunner { const DefaultProcessRunner(); @override - Future run(String executable, List arguments) => - Process.run(executable, arguments); + Future run(String executable, List arguments) => Process.run(executable, arguments); } diff --git a/workmanager_windows/lib/src/schtasks.dart b/workmanager_windows/lib/src/schtasks.dart index 5e828afd..50739371 100644 --- a/workmanager_windows/lib/src/schtasks.dart +++ b/workmanager_windows/lib/src/schtasks.dart @@ -114,8 +114,7 @@ class Schtasks { /// Maps a [frequency] to the `schtasks /RI` repetition interval in minutes: /// sub-minute frequencies are clamped to 1 minute, frequencies above /// [maxRepetitionMinutes] (416 days) are clamped to it. - static int repeatMinutesFor(Duration frequency) => - frequency.inMinutes.clamp(1, maxRepetitionMinutes); + static int repeatMinutesFor(Duration frequency) => frequency.inMinutes.clamp(1, maxRepetitionMinutes); /// Formats [time] as `MM/DD/YYYY`. /// diff --git a/workmanager_windows/lib/workmanager_windows.dart b/workmanager_windows/lib/workmanager_windows.dart index 5243b8f8..6f6843b1 100644 --- a/workmanager_windows/lib/workmanager_windows.dart +++ b/workmanager_windows/lib/workmanager_windows.dart @@ -146,6 +146,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Map? inputData, Duration? initialDelay, Constraints? constraints, + // Accepted for API parity; Task Scheduler has no expedited concept. + bool expedited = false, ExistingWorkPolicy? existingWorkPolicy, BackoffPolicy? backoffPolicy, Duration? backoffPolicyDelay, @@ -243,8 +245,7 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && - !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to cancel "$uniqueName": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -270,8 +271,7 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && - !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to delete task "$taskId": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -312,8 +312,7 @@ class WorkmanagerWindows extends WorkmanagerPlatform { return jsonEncode(owned); } - String _buildAction(String taskName, String? payloadFilePath) => - Schtasks.buildAction( + String _buildAction(String taskName, String? payloadFilePath) => Schtasks.buildAction( executablePath: Platform.resolvedExecutable, taskName: taskName, payloadFilePath: payloadFilePath, @@ -355,7 +354,6 @@ class WorkmanagerWindows extends WorkmanagerPlatform { bool _isMissingTask(String stderr) { final normalized = stderr.toLowerCase(); - return normalized.contains('cannot find the file specified') || - normalized.contains('does not exist'); + return normalized.contains('cannot find the file specified') || normalized.contains('does not exist'); } } From 56d1c8790a92e7320f89454584a153c67099f8bc Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 3 Aug 2026 18:17:20 +0100 Subject: [PATCH 4/4] style: format workmanager_windows in CI-equivalent context (no package-local .dart_tool) --- workmanager_windows/lib/src/process_runner.dart | 3 ++- workmanager_windows/lib/src/schtasks.dart | 3 ++- workmanager_windows/lib/workmanager_windows.dart | 12 ++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/workmanager_windows/lib/src/process_runner.dart b/workmanager_windows/lib/src/process_runner.dart index 2635c910..6dc58dd4 100644 --- a/workmanager_windows/lib/src/process_runner.dart +++ b/workmanager_windows/lib/src/process_runner.dart @@ -19,5 +19,6 @@ class DefaultProcessRunner implements ProcessRunner { const DefaultProcessRunner(); @override - Future run(String executable, List arguments) => Process.run(executable, arguments); + Future run(String executable, List arguments) => + Process.run(executable, arguments); } diff --git a/workmanager_windows/lib/src/schtasks.dart b/workmanager_windows/lib/src/schtasks.dart index 50739371..5e828afd 100644 --- a/workmanager_windows/lib/src/schtasks.dart +++ b/workmanager_windows/lib/src/schtasks.dart @@ -114,7 +114,8 @@ class Schtasks { /// Maps a [frequency] to the `schtasks /RI` repetition interval in minutes: /// sub-minute frequencies are clamped to 1 minute, frequencies above /// [maxRepetitionMinutes] (416 days) are clamped to it. - static int repeatMinutesFor(Duration frequency) => frequency.inMinutes.clamp(1, maxRepetitionMinutes); + static int repeatMinutesFor(Duration frequency) => + frequency.inMinutes.clamp(1, maxRepetitionMinutes); /// Formats [time] as `MM/DD/YYYY`. /// diff --git a/workmanager_windows/lib/workmanager_windows.dart b/workmanager_windows/lib/workmanager_windows.dart index 6f6843b1..a847d96b 100644 --- a/workmanager_windows/lib/workmanager_windows.dart +++ b/workmanager_windows/lib/workmanager_windows.dart @@ -245,7 +245,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && + !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to cancel "$uniqueName": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -271,7 +272,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { Schtasks.executable, Schtasks.delete(taskId), ); - if (deleteResult.exitCode != 0 && !_isMissingTask(deleteResult.stderr.toString())) { + if (deleteResult.exitCode != 0 && + !_isMissingTask(deleteResult.stderr.toString())) { throw StateError( 'Failed to delete task "$taskId": schtasks exited with ' '${deleteResult.exitCode}.\n${deleteResult.stderr}', @@ -312,7 +314,8 @@ class WorkmanagerWindows extends WorkmanagerPlatform { return jsonEncode(owned); } - String _buildAction(String taskName, String? payloadFilePath) => Schtasks.buildAction( + String _buildAction(String taskName, String? payloadFilePath) => + Schtasks.buildAction( executablePath: Platform.resolvedExecutable, taskName: taskName, payloadFilePath: payloadFilePath, @@ -354,6 +357,7 @@ class WorkmanagerWindows extends WorkmanagerPlatform { bool _isMissingTask(String stderr) { final normalized = stderr.toLowerCase(); - return normalized.contains('cannot find the file specified') || normalized.contains('does not exist'); + return normalized.contains('cannot find the file specified') || + normalized.contains('does not exist'); } }