Skip to content

Commit d396055

Browse files
authored
feat(windows): neru services via Task Scheduler (#1587)
1 parent 623c0cd commit d396055

12 files changed

Lines changed: 1239 additions & 50 deletions

docs/ARCHITECTURE.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,13 @@ unconditionally and delegates to unexported helpers (`installService`,
394394
[services_darwin.go](../internal/cli/services_darwin.go) (`//go:build darwin`)
395395
drives `launchctl` and `.plist` files,
396396
[services_linux.go](../internal/cli/services_linux.go) (`//go:build linux`)
397-
drives `systemctl --user` and a unit file, and
398-
[services_other.go](../internal/cli/services_other.go)
399-
(`//go:build !darwin && !linux`) returns `CodeNotSupported`. Registration is
400-
shared, so a platform joining the set adds one file and no `init()`.
397+
drives `systemctl --user` and a unit file,
398+
[services_windows.go](../internal/cli/services_windows.go)
399+
(`//go:build windows`) drives the Task Scheduler COM API with an XML task
400+
definition, and [services_other.go](../internal/cli/services_other.go)
401+
(`//go:build !darwin && !linux && !windows`) returns `CodeNotSupported`.
402+
Registration is shared, so a platform joining the set adds one file and no
403+
`init()`.
401404

402405
**`IsRunningFromAppBundle`**[root.go](../internal/cli/root.go) delegates to
403406
a build-tagged implementation: [root_darwin.go](../internal/cli/root_darwin.go)

docs/CLI.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,11 +1509,12 @@ Manage Neru as a system service that starts on login.
15091509
neru services install|uninstall|start|stop|restart|status
15101510
```
15111511
1512-
**Platforms:** macOS, using a launchd user agent, and Linux, using a systemd
1513-
user unit. Windows returns `ERR_NOT_SUPPORTED`. When Neru was installed through
1514-
Nix, Homebrew, home-manager, or another package manager that manages the service
1515-
itself, use that tool instead — on Linux, `install` and `uninstall` both refuse
1516-
rather than touching a unit Neru did not write.
1512+
**Platforms:** macOS, using a launchd user agent; Linux, using a systemd user
1513+
unit; Windows, using a Task Scheduler task with a logon trigger. When Neru was
1514+
installed through Nix, Homebrew, home-manager, or another package manager that
1515+
manages the service itself, use that tool instead — on Linux and Windows,
1516+
`install` and `uninstall` both refuse rather than touching a unit or task Neru
1517+
did not write.
15171518
15181519
| Subcommand | Description |
15191520
| ----------- | ------------------------------------------ |
@@ -1524,11 +1525,18 @@ rather than touching a unit Neru did not write.
15241525
| `restart` | Restart the service |
15251526
| `status` | Report whether the service is installed and running |
15261527
1527-
The definition is a launchd plist in `~/Library/LaunchAgents` on macOS and a
1528-
systemd user unit on Linux; where the Linux unit is written, what it contains,
1529-
and what happens on a machine booted by another init system are in
1528+
The definition is a launchd plist in `~/Library/LaunchAgents` on macOS, a
1529+
systemd user unit on Linux, and a task named `\Neru` in the Task Scheduler root
1530+
folder on Windows; where the Linux unit is written, what it contains, and what
1531+
happens on a machine booted by another init system are in
15301532
[LINUX_SETUP.md](LINUX_SETUP.md#systemd-user-service).
15311533
1534+
The Windows task runs `neru launch` as the installing user with an interactive
1535+
token, restarts it on failure, and carries no execution time limit, so the
1536+
scheduler never stops the daemon on its own. `status` reads the scheduler's own
1537+
task state (running, ready, queued, disabled), and `stop` ends the running
1538+
instance the way `schtasks /End` would. No administrator rights are needed.
1539+
15321540
The macOS agent leaves the daemon's standard output alone — the rotated log file
15331541
already holds every log line — and sends its standard error to
15341542
`~/Library/Logs/neru/daemon.err.log`, beside that log file, where a crash or a

docs/CROSS_PLATFORM.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ that is what [Known Gaps](#known-gaps) tracks, per
198198
| **Screen capture** | ✅ ScreenCaptureKit |`XGetImage` |`wlr-screencopy` | ⚠️ portal ScreenCast, consent ⁵ |`BitBlt`|
199199
| **Vision / OCR detection** | ✅ Vision framework | ⚠️ tesseract, text only ⁶ | ⚠️ tesseract, text only ⁶ | ⚠️ tesseract, text only ⁶ | ⚠️ `Windows.Media.Ocr`, text only ⁶ |
200200
| **Key feed (`neru key`)** |`CGEventPost` | ✅ uinput | ✅ uinput / virtual-keyboard | ✅ uinput | 🟡 `CodeNotSupported` |
201-
| **Service management (`neru services`)** | ✅ launchd user agent | ⚠️ systemd user unit only ² | ⚠️ systemd user unit only ² | ⚠️ systemd user unit only ² | 🟡 `CodeNotSupported` |
201+
| **Service management (`neru services`)** | ✅ launchd user agent | ⚠️ systemd user unit only ² | ⚠️ systemd user unit only ² | ⚠️ systemd user unit only ² | ✅ Task Scheduler logon task |
202202

203203
¹ macOS and Linux resolve font *families* through the OS (NSFont, fontconfig).
204204
Windows only maps the generic aliases `sans` / `serif` / `mono` to Segoe UI /
@@ -1189,9 +1189,7 @@ working, which is exactly why the build exists.
11891189
**Windows**
11901190

11911191
1. Font resolution — alias mapping only, no system font enumeration
1192-
2. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
1193-
installs a launchd agent and Linux a systemd user unit
1194-
3. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
1192+
2. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
11951193
every platform, but only the Unix client checks that for itself before
11961194
connecting. A named pipe carries no ownership a client can read without
11971195
opening it, so the Windows CLI trusts the name it derives from its own SID.

docs/INSTALLATION.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,8 @@ just install # copies neru to ~/.local/bin, offers a systemd user service,
547547
# Windows (from Git Bash): build the exe, then install it
548548
just build-windows
549549
just install # copies neru.exe under %LOCALAPPDATA%, and offers a user PATH entry,
550-
# a Start Menu shortcut, and a login autostart entry
550+
# a Start Menu shortcut, and a Task Scheduler logon task via
551+
# `neru services install`
551552
```
552553

553554
`just install` refuses to run over a Homebrew or Nix-managed install and tells you
@@ -593,7 +594,8 @@ open -a Neru
593594
# Or CLI
594595
neru launch
595596

596-
# Or install as launchd service for auto-startup
597+
# Or install as a login service for auto-startup: a launchd agent on macOS,
598+
# a systemd user unit on Linux, a Task Scheduler task on Windows
597599
neru services install
598600
```
599601

@@ -745,10 +747,13 @@ sudo gpasswd -d "$USER" input
745747
<summary>Windows (PowerShell)</summary>
746748

747749
```powershell
750+
# Stop and remove the Task Scheduler task (if installed); an install made
751+
# before `neru services` reached Windows used a Run key instead
752+
neru services uninstall
753+
Remove-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name Neru -ErrorAction SilentlyContinue
748754
Stop-Process -Name neru -Force -ErrorAction SilentlyContinue
749755
750-
# Autostart and Start Menu shortcut
751-
Remove-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name Neru
756+
# Start Menu shortcut
752757
Remove-Item "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Neru.lnk"
753758
754759
# Binary

internal/cli/services.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ import (
99
// macOS: backed by a launchd user agent.
1010
// Linux: backed by a systemd user unit; other init systems return
1111
// CodeNotSupported.
12+
// Windows: backed by a Task Scheduler logon task for the current user.
1213
// Other platforms: stubbed and returns CodeNotSupported until implemented.
1314
var ServicesCmd = &cobra.Command{
1415
Use: "services",
15-
Short: "Manage the Neru system service (macOS launchd, Linux systemd)",
16+
Short: "Manage the Neru system service (launchd, systemd, Task Scheduler)",
1617
Long: `Manage the Neru system service for automatic startup on login.
1718
18-
On macOS this manages a launchd agent, and on Linux a systemd user unit
19-
ordered after graphical-session.target, so Neru starts automatically once
20-
your graphical session is up. Linux service management covers systemd
21-
only; other init systems report ERR_NOT_SUPPORTED.
19+
On macOS this manages a launchd agent, on Linux a systemd user unit
20+
ordered after graphical-session.target, and on Windows a Task Scheduler
21+
task with a logon trigger, so Neru starts automatically once your
22+
session is up. Linux service management covers systemd only; other init
23+
systems report ERR_NOT_SUPPORTED.
2224
2325
Subcommands:
2426
install Install and load the system service
@@ -33,7 +35,7 @@ Subcommands:
3335
var ServicesInstallCmd = &cobra.Command{
3436
Use: "install",
3537
Short: "Install and load the system service",
36-
Long: `Install the Neru service so it starts automatically on login: a launchd plist loaded with launchctl on macOS, a systemd user unit enabled with systemctl --user on Linux.`,
38+
Long: `Install the Neru service so it starts automatically on login: a launchd plist loaded with launchctl on macOS, a systemd user unit enabled with systemctl --user on Linux, a Task Scheduler logon task on Windows.`,
3739
RunE: func(cmd *cobra.Command, args []string) error {
3840
err := installService()
3941
if err != nil {
@@ -50,7 +52,7 @@ var ServicesInstallCmd = &cobra.Command{
5052
var ServicesUninstallCmd = &cobra.Command{
5153
Use: "uninstall",
5254
Short: "Unload and remove the system service",
53-
Long: `Unload the Neru service and remove the file that describes it — the launchd plist on macOS, the systemd user unit on Linux. Neru will no longer start automatically on login.`,
55+
Long: `Unload the Neru service and remove what describes it — the launchd plist on macOS, the systemd user unit on Linux, the Task Scheduler task on Windows. Neru will no longer start automatically on login.`,
5456
RunE: func(cmd *cobra.Command, args []string) error {
5557
err := uninstallService()
5658
if err != nil {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//go:build integration && windows
2+
3+
package cli
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"strings"
9+
"testing"
10+
11+
"github.com/y3owk1n/neru/internal/derrors"
12+
)
13+
14+
// testTaskPath is a task name no real install uses, so the round trip below
15+
// never touches a \Neru task the machine already has.
16+
func testTaskPath(t *testing.T) string {
17+
t.Helper()
18+
19+
return fmt.Sprintf(`\NeruTest-%d`, os.Getpid())
20+
}
21+
22+
// requireTaskScheduler skips when the scheduler cannot be reached at all,
23+
// which a locked-down session reports as an error on the very first call.
24+
func requireTaskScheduler(t *testing.T, path string) {
25+
t.Helper()
26+
27+
status := statusServiceTask(path)
28+
if strings.HasPrefix(status, "Service status unavailable") {
29+
t.Skipf("skipping: %s", status)
30+
}
31+
}
32+
33+
// TestServiceTask_RoundTrip registers, inspects, drives and deletes a task on
34+
// the real scheduler, which is the only reader whose opinion of the XML counts.
35+
//
36+
// The action is this test binary with `launch` as its argument, which exits at
37+
// once, so the task's state after Run may already be back to ready; the
38+
// assertion is that Run was accepted, and that status reports an installed
39+
// task, not that a daemon stays up.
40+
func TestServiceTask_RoundTrip(t *testing.T) {
41+
path := testTaskPath(t)
42+
requireTaskScheduler(t, path)
43+
44+
t.Cleanup(func() { _ = uninstallServiceTask(path) })
45+
46+
if status := statusServiceTask(path); !strings.Contains(status, "not installed") {
47+
t.Fatalf("statusServiceTask() before install = %q, want not installed", status)
48+
}
49+
50+
err := installServiceTask(path)
51+
if err != nil {
52+
t.Fatalf("installServiceTask() error = %v", err)
53+
}
54+
55+
err = installServiceTask(path)
56+
if !derrors.IsCode(err, derrors.CodeInvalidInput) {
57+
t.Errorf("second installServiceTask() error = %v, want %v", err, derrors.CodeInvalidInput)
58+
}
59+
60+
status := statusServiceTask(path)
61+
if !strings.HasPrefix(status, "Service installed: ") ||
62+
!strings.Contains(status, "enabled at login") {
63+
t.Errorf("statusServiceTask() after install = %q, want installed and enabled", status)
64+
}
65+
66+
for _, step := range []struct {
67+
name string
68+
call func(string) error
69+
}{
70+
{name: "stop", call: func(p string) error { return driveServiceTask("stop", p, stopTask) }},
71+
{name: "start", call: func(p string) error { return driveServiceTask("start", p, runTask) }},
72+
{name: "restart", call: func(p string) error { return driveServiceTask("restart", p, restartTask) }},
73+
} {
74+
err = step.call(path)
75+
if err != nil {
76+
t.Errorf("%s error = %v", step.name, err)
77+
}
78+
}
79+
80+
err = uninstallServiceTask(path)
81+
if err != nil {
82+
t.Fatalf("uninstallServiceTask() error = %v", err)
83+
}
84+
85+
if status := statusServiceTask(path); !strings.Contains(status, "not installed") {
86+
t.Errorf("statusServiceTask() after uninstall = %q, want not installed", status)
87+
}
88+
89+
err = uninstallServiceTask(path)
90+
if err != nil {
91+
t.Errorf("uninstallServiceTask() on nothing = %v, want nil", err)
92+
}
93+
}
94+
95+
// TestDriveServiceTask_RefusesWhenNothingIsInstalled pins that start, stop and
96+
// restart on a machine with no task say so rather than reporting success.
97+
func TestDriveServiceTask_RefusesWhenNothingIsInstalled(t *testing.T) {
98+
path := testTaskPath(t)
99+
requireTaskScheduler(t, path)
100+
101+
err := driveServiceTask("start", path, runTask)
102+
if !derrors.IsCode(err, derrors.CodeInvalidInput) {
103+
t.Fatalf("driveServiceTask() error = %v, want %v", err, derrors.CodeInvalidInput)
104+
}
105+
106+
if !strings.Contains(err.Error(), "neru services install") {
107+
t.Errorf("driveServiceTask() message = %q, want it to name the next step", err.Error())
108+
}
109+
}

0 commit comments

Comments
 (0)