use std::path::Path;
use crate::updater::UpdateError;
pub fn build_shim_script(install_dir: &Path, flow_pid: u32) -> String {
let install = install_dir.display().to_string().replace('\'', "''");
format!(
r#"
$ErrorActionPreference = 'Stop'
$InstallDir = '{install}'
$FlowPid = {flow_pid}
$Log = Join-Path $env:TEMP 'flow-update-shim.log'
function Write-Log($msg) {{
$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
"$stamp [pid=$FlowPid] $msg" | Out-File -Append -Encoding utf8 $Log
}}
function Move-WithRetry($src, $dst, $tries) {{
for ($i = 1; $i -le $tries; $i++) {{
try {{
if (Test-Path $src) {{
Move-Item -Force -ErrorAction Stop $src $dst
}}
return $true
}} catch {{
Start-Sleep -Milliseconds 500
}}
}}
return $false
}}
try {{
Write-Log "shim start; install_dir=$InstallDir"
# 1. Wait up to 30s for the parent flow.exe to exit.
$deadline = (Get-Date).AddSeconds(30)
while ((Get-Date) -lt $deadline) {{
$proc = $null
try {{ $proc = Get-Process -Id $FlowPid -ErrorAction Stop }} catch {{ }}
if (-not $proc) {{ break }}
Start-Sleep -Milliseconds 250
}}
Write-Log "parent flow.exe process gone (or timeout reached)"
# 2. Swap each binary.
foreach ($name in @('flow.exe', 'flowd.exe')) {{
$target = Join-Path $InstallDir $name
$staged = Join-Path $InstallDir (Join-Path '.stage' $name)
$old = Join-Path $InstallDir ($name + '.old')
if (-not (Test-Path $staged)) {{
Write-Log "skip $name (no staged file)"
continue
}}
# Drop stale .old from a prior failed run.
if (Test-Path $old) {{
Remove-Item -Force -ErrorAction SilentlyContinue $old
}}
# Rename the existing binary out of the way (Windows allows renaming
# a running .exe; deletion is what's blocked).
if (Test-Path $target) {{
$ok = Move-WithRetry $target $old 20
if (-not $ok) {{
Write-Log "FAILED to rename $target -> $old after 20 tries; leaving $name in place"
continue
}}
Write-Log "renamed $target -> $old"
}}
# Move the staged replacement into the vacated path.
$ok = Move-WithRetry $staged $target 20
if ($ok) {{
Write-Log "swapped $name"
}} else {{
Write-Log "FAILED to move staged $staged -> $target"
}}
# Clean up the .old file.
if (Test-Path $old) {{
Remove-Item -Force -ErrorAction SilentlyContinue $old
}}
}}
# 3. Remove the .stage directory.
$stageDir = Join-Path $InstallDir '.stage'
if (Test-Path $stageDir) {{
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $stageDir
Write-Log "removed $stageDir"
}}
Write-Log "shim complete"
}} catch {{
Write-Log "ERROR: $_"
}} finally {{
# 4. Self-delete the .ps1 file (best-effort).
$script = $MyInvocation.MyCommand.Path
if ($script) {{
Remove-Item -Force -ErrorAction SilentlyContinue $script
}}
}}
"#
)
}
pub fn spawn_swap_shim(install_dir: &Path, flow_pid: u32) -> Result<(), UpdateError> {
use std::os::windows::process::CommandExt;
const DETACHED: u32 = 0x0000_0200 | 0x0800_0000;
let script_path = install_dir.join(".flow-update-shim.ps1");
let script_body = build_shim_script(install_dir, flow_pid);
std::fs::write(&script_path, &script_body)?;
let mut cmd = std::process::Command::new("powershell.exe");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-ExecutionPolicy",
"Bypass",
"-File",
]);
cmd.arg(&script_path);
cmd.creation_flags(DETACHED);
cmd.spawn()
.map_err(|e| UpdateError::ShimSpawn(e.to_string()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn build_shim_script_bakes_in_install_dir_and_pid() {
let dir = PathBuf::from(r"C:\Program Files\flow-wm");
let script = build_shim_script(&dir, 4242u32);
assert!(
script.contains("$FlowPid = 4242"),
"pid should be interpolated; got:\n{script}"
);
assert!(
script.contains(r"$InstallDir = 'C:\Program Files\flow-wm'"),
"install dir should be interpolated; got:\n{script}"
);
assert!(script.contains("Get-Process"), "missing process poll");
assert!(script.contains("Move-WithRetry"), "missing retry helper");
assert!(script.contains("flow-update-shim.log"), "missing log path");
assert!(
script.contains("$ErrorActionPreference = 'Stop'"),
"missing strict-error preamble"
);
}
#[test]
fn build_shim_script_escapes_single_quotes_in_install_dir() {
let dir = PathBuf::from(r"C:\flow's dir");
let script = build_shim_script(&dir, 1u32);
assert!(
script.contains(r"$InstallDir = 'C:\flow''s dir'"),
"single quote should be doubled for PowerShell; got:\n{script}"
);
let line = script
.lines()
.find(|l| l.contains("$InstallDir ="))
.expect("$InstallDir assignment must exist");
assert!(
!line.contains(r"'C:\flow's dir'"),
"unescaped single quote leaked into: {line}"
);
}
#[test]
fn build_shim_script_targets_both_binaries_and_stage_dir() {
let dir = Path::new(r"C:\flow");
let script = build_shim_script(dir, 100u32);
assert!(
script.contains("@('flow.exe', 'flowd.exe')"),
"swap loop must enumerate both binaries; got:\n{script}"
);
assert!(
script.contains("'.stage'"),
"staged-file lookup must reference .stage; got:\n{script}"
);
assert!(
script.contains(".flow-update-shim.ps1") || script.contains("$MyInvocation"),
"shim should self-delete on completion"
);
}
#[test]
fn build_shim_script_accepts_pid_zero() {
let script = build_shim_script(Path::new(r"C:\flow"), 0u32);
assert!(
script.contains("$FlowPid = 0"),
"pid zero should be interpolated verbatim; got:\n{script}"
);
assert!(
script.contains("AddSeconds(30)"),
"deadline constant missing; got:\n{script}"
);
}
}