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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion Tools/windows/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ it, setup verifies/installs its own directory. `-InstallRoot` supports custom
locations and must also be supplied when managing that custom installation.
`-NoScheduledTask` retains the explicit development/portable mode.

The daemon task has a logon trigger and a one-minute repeating trigger with
`MultipleInstancesPolicy` `IgnoreNew`, the Windows counterpart of launchd
`KeepAlive`: a tick is a no-op while the daemon runs and relaunches it within a
minute once it has stopped, and battery limits never refuse or kill it. (Task
Scheduler's restart-on-failure setting is deliberately not used; it fires only
when a task cannot launch, never when the launched process exits.) Upgrade and
Uninstall disable the task before ending it, so neither is fought by the
trigger; a refused Uninstall re-registers it. In the shell, `Ctrl+R` Reconnect
also starts the registered task (`schtasks /Run`, no elevation) when the daemon
endpoint is missing and no daemon is starting, and falls back to launching a
shell-owned daemon when no task is registered. The status line reports the outcome.

Standalone provenance checks use the selected package's declared provider pins,
so an older setup can verify another release whose pins changed. Signed-package
catalog verification authenticates those declarations. Repository commands
Expand Down Expand Up @@ -299,7 +311,11 @@ hash mismatch; no certificate is added to trust stores. This contract requires
the SDK on the build/CI host, not the installation target, and is not proof of
production publisher trust. `Packaging.Scheduler.Tests.ps1` exercises an owned
idle task through native stop/delete and verifies the actual missing-task
HRESULT without suppressing account or permission errors.
HRESULT without suppressing account or permission errors. It also registers the
generated daemon task under an owned name with its action swapped for a harmless
command, and proves on the real scheduler that the repeating trigger relaunches
an action that has exited and that `Stop-InstalledDaemon` disables the task so an
explicit stop is not undone (this part waits about two minutes).

## Publishing a Windows release

Expand Down
43 changes: 33 additions & 10 deletions Tools/windows/PackageRuntime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,16 @@ function Get-InstalledDaemons {
([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected)
})
}
function Disable-DaemonTask([string] $name) {
# The repeating trigger would otherwise restart a daemon that an explicit stop just ended.
$result = Invoke-PackageCommand "schtasks.exe" @("/Change", "/TN", $name, "/DISABLE")
Require ($result.ExitCode -eq 0) "scheduled-task disable failed: $($result.Output)"
}
function Stop-InstalledDaemon {
$support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" }
$identity = Get-TaskIdentity $support
if (Test-DaemonTask $identity.name) {
Disable-DaemonTask $identity.name
$result = Invoke-PackageCommand "schtasks.exe" @("/End", "/TN", $identity.name)
Require ($result.ExitCode -eq 0) "scheduled-task stop failed: $($result.Output)"
}
Expand All @@ -293,31 +299,44 @@ function Remove-DaemonTask {
$support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" }
$identity = Get-TaskIdentity $support
if (-not (Test-DaemonTask $identity.name)) { return }
Disable-DaemonTask $identity.name
$result = Invoke-PackageCommand "schtasks.exe" @("/End", "/TN", $identity.name)
Require ($result.ExitCode -eq 0) "scheduled-task stop failed: $($result.Output)"
$result = Invoke-PackageCommand "schtasks.exe" @("/Delete", "/TN", $identity.name, "/F")
Require ($result.ExitCode -eq 0) "scheduled-task removal failed: $($result.Output)"
}
function Start-DaemonTask {
$support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" }
$identity = Get-TaskIdentity $support
New-Item -ItemType Directory -Force $support | Out-Null
$xmlPath = Join-Path (Split-Path $InstallRoot -Parent) "GraphCode-daemon-task.xml"
$taskName = Xml-Escape $identity.name
function New-DaemonTaskXml([hashtable] $identity, [string] $support) {
$sid = Xml-Escape $identity.sid
$command = Xml-Escape (Join-Path $env:SystemRoot "System32\cmd.exe")
$arguments = Xml-Escape "/d /s /c `"set `"GRAPHCODE_SUPPORT_DIR=$support`"`&`&`"$InstallRoot\bin\graphcoded.exe`"`""
$workingDirectory = Xml-Escape (Join-Path $InstallRoot "bin")
$xml = @"
# launchd runs the macOS daemon with KeepAlive; the Windows equivalent is a one-minute
# repeating trigger. MultipleInstancesPolicy IgnoreNew makes every tick a no-op while the
# daemon runs and restarts it, whatever its exit code, once it has stopped. Task Scheduler's
# own RestartOnFailure is not used: it fires only when a task cannot launch, never when a
# launched process exits. Uninstall and upgrade disable the task before ending it, so an
# explicit stop is not undone by the next tick. Battery limits would otherwise refuse or kill
# the daemon on laptops.
return @"
<?xml version="1.0" encoding="UTF-16"?>
<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo><Description>GraphCode daemon for $sid</Description></RegistrationInfo>
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>$sid</UserId></LogonTrigger></Triggers>
<Triggers>
<LogonTrigger><Enabled>true</Enabled><UserId>$sid</UserId></LogonTrigger>
<TimeTrigger><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled></TimeTrigger>
</Triggers>
<Principals><Principal id="Author"><UserId>$sid</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
<Actions Context="Author"><Exec><Command>$command</Command><Arguments>$arguments</Arguments><WorkingDirectory>$workingDirectory</WorkingDirectory></Exec></Actions>
</Task>
"@
}
function Start-DaemonTask {
$support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" }
$identity = Get-TaskIdentity $support
New-Item -ItemType Directory -Force $support | Out-Null
$xmlPath = Join-Path (Split-Path $InstallRoot -Parent) "GraphCode-daemon-task.xml"
$xml = New-DaemonTaskXml $identity $support
[IO.File]::WriteAllText($xmlPath, $xml, [Text.Encoding]::Unicode)
$result = Invoke-PackageCommand "schtasks.exe" @("/Create", "/TN", $identity.name, "/XML", $xmlPath, "/F")
Require ($result.ExitCode -eq 0) "scheduled-task registration failed: $($result.Output)"
Expand Down Expand Up @@ -497,8 +516,11 @@ function Uninstall-Package {
$installed = Test-Path -LiteralPath $InstallRoot
if ($installed) { Assert-InstallRootIdle $daemonManaged }
$daemonWasRunning = $false
$daemonTaskExisted = $false
if ($daemonManaged) {
$daemonWasRunning = @(Get-InstalledDaemons).Count -gt 0
$support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" }
$daemonTaskExisted = Test-DaemonTask (Get-TaskIdentity $support).name
Stop-InstalledDaemon
}
$bin = Join-Path $InstallRoot "bin"
Expand Down Expand Up @@ -551,7 +573,8 @@ function Uninstall-Package {
$rollbackErrors.Add("restoring shortcuts from '$shortcutBackup': $($_.Exception.Message)")
}
}
if ($daemonWasRunning -and $restored) {
# Stopping disabled the task, so a refused uninstall must re-register it, not just restart.
if (($daemonWasRunning -or $daemonTaskExisted) -and $restored) {
try { Start-DaemonTask } catch {
$rollbackErrors.Add("restarting the daemon: $($_.Exception.Message)")
}
Expand Down
63 changes: 63 additions & 0 deletions Tools/windows/Tests/Packaging.Scheduler.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,69 @@ try {
Remove-DaemonTask
if (Test-DaemonTask $identity.name) { throw "Idle task was not removed" }
Write-Output "Native missing-task HRESULT 0x80070002 and idle-task stop/delete: PASS"

# The installed task definition must keep the daemon alive the way launchd KeepAlive does:
# restart it once it has stopped, never refuse or kill it on battery, and let an explicit
# stop stick.
[xml] $taskXml = New-DaemonTaskXml $identity $fixture
$ns = New-Object Xml.XmlNamespaceManager($taskXml.NameTable)
$ns.AddNamespace("t", "http://schemas.microsoft.com/windows/2004/02/mit/task")
function Get-TaskSetting([string] $name) { $taskXml.SelectSingleNode("//t:Settings/t:$name", $ns).InnerText }
$expectedSettings = [ordered]@{
MultipleInstancesPolicy = "IgnoreNew"
DisallowStartIfOnBatteries = "false"
StopIfGoingOnBatteries = "false"
StartWhenAvailable = "true"
ExecutionTimeLimit = "PT0S"
}
foreach ($key in $expectedSettings.Keys) {
if ((Get-TaskSetting $key) -ne $expectedSettings[$key]) {
throw "Task setting $key is '$(Get-TaskSetting $key)', expected '$($expectedSettings[$key])'"
}
}
$triggers = @($taskXml.SelectNodes("//t:Triggers/*", $ns) | ForEach-Object { $_.LocalName })
if (($triggers -join ",") -ne "LogonTrigger,TimeTrigger") { throw "Task triggers are '$($triggers -join ',')'" }
$repetition = $taskXml.SelectSingleNode("//t:TimeTrigger/t:Repetition", $ns)
if ($repetition.Interval -ne "PT1M" -or $repetition.SelectSingleNode("t:Duration", $ns)) {
throw "Daemon task must repeat every minute indefinitely"
}

# Prove the behaviour on the real scheduler with a harmless action in place of the daemon.
New-Item -ItemType Directory -Force $fixture | Out-Null
$marker = Join-Path $fixture "runs.txt"
$taskXml.SelectSingleNode("//t:Exec/t:Command", $ns).InnerText = Join-Path $env:SystemRoot "System32\cmd.exe"
$taskXml.SelectSingleNode("//t:Exec/t:Arguments", $ns).InnerText = "/d /c echo run>>`"$marker`""
$taskXml.SelectSingleNode("//t:Exec/t:WorkingDirectory", $ns).InnerText = $fixture
$xmlPath = Join-Path $fixture "daemon-task.xml"
[IO.File]::WriteAllText($xmlPath, $taskXml.OuterXml, [Text.Encoding]::Unicode)
$created = Invoke-PackageCommand "schtasks.exe" @("/Create", "/TN", $identity.name, "/XML", $xmlPath, "/F")
if ($created.ExitCode -ne 0) { throw "Daemon task definition was rejected by the scheduler: $($created.Output)" }
function Get-RunCount { if (Test-Path -LiteralPath $marker) { @(Get-Content -LiteralPath $marker).Count } else { 0 } }
function Wait-RunCount([int] $count, [int] $seconds) {
$deadline = [DateTime]::UtcNow.AddSeconds($seconds)
while ((Get-RunCount) -lt $count -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 500 }
return (Get-RunCount) -ge $count
}
$started = Invoke-PackageCommand "schtasks.exe" @("/Run", "/TN", $identity.name)
if ($started.ExitCode -ne 0) { throw "Starting the daemon task failed: $($started.Output)" }
if (-not (Wait-RunCount 1 30)) { throw "The daemon task did not run its action" }
# The action has already exited; only the repeating trigger can run it again.
if (-not (Wait-RunCount 2 150)) { throw "A stopped daemon task was not restarted by the repeating trigger" }
Write-Output "Daemon task restarts after its process ends: PASS"

Stop-InstalledDaemon
$runsAtStop = Get-RunCount
Start-Sleep -Seconds 70
if ((Get-RunCount) -ne $runsAtStop) { throw "The daemon task restarted after an explicit stop" }
$live = $folder.GetTask($identity.name.Split("\")[-1])
try {
if ($live.Enabled) { throw "An explicitly stopped daemon task is still enabled" }
} finally {
[void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($live)
}
Remove-DaemonTask
if (Test-DaemonTask $identity.name) { throw "Daemon task was not removed" }
Write-Output "Daemon task stays stopped after an explicit stop and is removable: PASS"
} finally {
try {
if (Test-DaemonTask $identity.name) {
Expand Down
22 changes: 21 additions & 1 deletion graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1562,9 +1562,11 @@ pub const App = struct {
// secret; connection retries replace it with the real endpoint.
const endpoint = self.client.currentDaemonStartupEndpoint(self.allocator) catch &.{};
const lock_name = self.client.currentDaemonLockName(self.allocator) catch &.{};
const task_name = self.client.currentDaemonTaskName(self.allocator) catch &.{};
defer if (endpoint.len != 0) self.allocator.free(endpoint);
defer if (lock_name.len != 0) self.allocator.free(lock_name);
if (endpoint.len != 0 and lock_name.len != 0) self.daemon.start(endpoint, lock_name);
defer if (task_name.len != 0) self.allocator.free(task_name);
if (endpoint.len != 0 and lock_name.len != 0) _ = self.daemon.recover(endpoint, lock_name, task_name);
if (daemon_supervisor_test_hook) {
const state: usize = if (self.daemon.owned) 1 else if (self.daemon.status().len == 0) 2 else 3;
_ = c.SetPropW(
Expand Down Expand Up @@ -5961,9 +5963,27 @@ pub const App = struct {
);
}

/// Reconnect also brings a stopped daemon back: a user who presses it after the daemon
/// ended expects the connection to return, as it does under launchd on macOS.
fn recoverDaemon(self: *App) void {
const endpoint = self.client.currentDaemonStartupEndpoint(self.allocator) catch return;
defer self.allocator.free(endpoint);
const lock_name = self.client.currentDaemonLockName(self.allocator) catch return;
defer self.allocator.free(lock_name);
const task_name = self.client.currentDaemonTaskName(self.allocator) catch &.{};
defer if (task_name.len != 0) self.allocator.free(task_name);
switch (self.daemon.recover(endpoint, lock_name, task_name)) {
.not_needed => {},
.task_started => self.setStatus("Started the GraphCode daemon task; reconnecting"),
.spawned => self.setStatus("Restarted the GraphCode daemon; reconnecting"),
.failed => self.setStatus(self.daemon.status()),
}
}

fn handleAction(self: *App, action: InputRouter.Action) void {
switch (action) {
.reconnect => {
self.recoverDaemon();
self.client.reconnect();
},
.open_folder => self.openFolder(),
Expand Down
55 changes: 55 additions & 0 deletions graphcode-windows/src/DaemonClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ pub const DaemonClient = struct {
return daemonLockName(allocator);
}

pub fn currentDaemonTaskName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 {
_ = self;
return daemonTaskName(allocator);
}

fn validateSupportDirectory(allocator: std.mem.Allocator, support_directory: []const u8) !void {
const normalized = try normalizedSupportPath(allocator, support_directory);
defer std.heap.page_allocator.free(normalized);
Expand Down Expand Up @@ -1844,6 +1849,56 @@ pub fn daemonLockName(allocator: std.mem.Allocator) ![]u8 {
return daemonLockNameFor(allocator, support);
}

/// Name of the per-user scheduled task that Tools\windows\PackageRuntime.ps1
/// (`Get-TaskIdentity`) registers for the daemon that owns `support_directory`:
/// `GraphCode\graphcoded-` plus the first 32 hex digits of SHA-256 over
/// `<SID>|<lowercased full path without a trailing backslash>`.
pub fn daemonTaskNameFor(allocator: std.mem.Allocator, support_directory: []const u8) ![]u8 {
const normalized = try normalizedSupportPath(allocator, support_directory);
defer allocator.free(normalized);
for (normalized) |*byte| {
if (byte.* == '/') byte.* = '\\';
}
const trimmed = std.mem.trimRight(u8, normalized, "\\");
const sid = try currentSID(allocator);
defer allocator.free(sid);
const identity = try std.fmt.allocPrint(allocator, "{s}|{s}", .{ sid, trimmed });
defer allocator.free(identity);
const hash = try sha256Hex(allocator, identity);
defer allocator.free(hash);
return std.fmt.allocPrint(allocator, "GraphCode\\graphcoded-{s}", .{hash[0..32]});
}

pub fn daemonTaskName(allocator: std.mem.Allocator) ![]u8 {
const support = try supportDirectory(allocator);
defer allocator.free(support);
return daemonTaskNameFor(allocator, support);
}

test "daemon task name follows the installer's identity for any spelling of the support path" {
const allocator = std.testing.allocator;
const sid = try currentSID(allocator);
defer allocator.free(sid);
const identity = try std.fmt.allocPrint(allocator, "{s}|c:\\fixture\\.graphcode", .{sid});
defer allocator.free(identity);
const hash = try sha256Hex(allocator, identity);
defer allocator.free(hash);
const expected = try std.fmt.allocPrint(allocator, "GraphCode\\graphcoded-{s}", .{hash[0..32]});
defer allocator.free(expected);
for ([_][]const u8{
"C:\\Fixture\\.graphcode",
"C:\\Fixture\\.graphcode\\",
"c:/fixture/./.graphcode",
}) |spelling| {
const name = try daemonTaskNameFor(allocator, spelling);
defer allocator.free(name);
try std.testing.expectEqualStrings(expected, name);
}
const other = try daemonTaskNameFor(allocator, "C:\\Fixture\\.graphcode-other");
defer allocator.free(other);
try std.testing.expect(!std.mem.eql(u8, expected, other));
}

fn sha256Hex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
var digest: [32]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
Expand Down
Loading
Loading