From 4282fed13cbb5c0deee20f5766081eed661fb804 Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 6 Jan 2026 21:32:52 +0100 Subject: [PATCH] Add windows install scripts --- .gitignore | 1 + README.md | 26 ++--- install-windows-task.bat | 7 ++ install-windows-task.ps1 | 194 +++++++++++++++++++++++++++++++++++++ install-windows.bat | 112 --------------------- uninstall-windows-task.bat | 7 ++ uninstall-windows-task.ps1 | 74 ++++++++++++++ uninstall-windows.bat | 55 ----------- 8 files changed, 298 insertions(+), 178 deletions(-) create mode 100644 install-windows-task.bat create mode 100644 install-windows-task.ps1 delete mode 100644 install-windows.bat create mode 100644 uninstall-windows-task.bat create mode 100644 uninstall-windows-task.ps1 delete mode 100644 uninstall-windows.bat diff --git a/.gitignore b/.gitignore index d087038..4db4159 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ luxtools-client +*.exe .vscode/ \ No newline at end of file diff --git a/README.md b/README.md index c56c511..d5fb6e5 100644 --- a/README.md +++ b/README.md @@ -87,42 +87,46 @@ Keep token/config on uninstall: Notes: -- Installs to `/opt/luxtools-client/luxtools-client` -- Creates `/etc/systemd/system/luxtools-client.service` -- Stores config (including the generated token) in `/etc/luxtools-client/luxtools-client.env` +- Installs to `~/.local/share/luxtools-client/luxtools-client` +- Creates a systemd *user* unit at `~/.config/systemd/user/luxtools-client.service` +- Stores config (including the token) in `~/.config/luxtools-client/luxtools-client.env` -### Windows (Service) +### Windows (Scheduled Task at logon) -Run from an elevated (Administrator) Command Prompt. +Because this tool needs to open File Explorer (a GUI app) in the *current user session*, it should run as a per-user Scheduled Task triggered “At log on” (similar to a systemd *user* service). Install / update: ```bat -install-windows.bat +install-windows-task.bat ``` Optional flags: ```bat -install-windows.bat --listen 127.0.0.1:9000 +install-windows-task.bat --listen 127.0.0.1:9000 + +REM Restrict allowed folders (repeatable) +install-windows-task.bat --allow "%USERPROFILE%" --allow "D:\Data" ``` Uninstall: ```bat -uninstall-windows.bat +uninstall-windows-task.bat ``` Keep token/config on uninstall: ```bat -uninstall-windows.bat --keep-config +uninstall-windows-task.bat --keep-config ``` Notes: -- Installs to `%ProgramFiles%\LuxtoolsClient\luxtools-client.exe` -- Stores the generated token in `%ProgramData%\LuxtoolsClient\token.txt` +- Installs to `%LOCALAPPDATA%\luxtools-client\luxtools-client.exe` +- Stores config (including the token) in `%LOCALAPPDATA%\luxtools-client\config.json` +- Re-running the install script updates the EXE in place and restarts the task. ## API diff --git a/install-windows-task.bat b/install-windows-task.bat new file mode 100644 index 0000000..1ba1ff4 --- /dev/null +++ b/install-windows-task.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install-windows-task.ps1" %* +set EXITCODE=%ERRORLEVEL% +exit /b %EXITCODE% diff --git a/install-windows-task.ps1 b/install-windows-task.ps1 new file mode 100644 index 0000000..9047654 --- /dev/null +++ b/install-windows-task.ps1 @@ -0,0 +1,194 @@ +[CmdletBinding()] +param( + [string]$Listen = "127.0.0.1:8765", + [string[]]$Allow = @() +) + +$ErrorActionPreference = 'Stop' + +$ServiceName = "luxtools-client" +$TaskName = $ServiceName + +function Write-Usage { + @" +Usage: + install-windows-task.bat [--listen host:port] [--allow ]... + +Installs/updates $ServiceName as a Windows Scheduled Task (per-user, runs at logon). +- Re-running updates the installed binary and restarts the task. +- A stable token is stored in: %LOCALAPPDATA%\$ServiceName\config.json + +Options: + --listen host:port Listen address (default: 127.0.0.1:8765) + --allow PATH Allowed path prefix (repeatable). If none, any path is allowed. +"@ | Write-Host +} + +function Quote-Arg([string]$s) { + if ($null -eq $s) { return '""' } + # Simple CreateProcess-compatible quoting: wrap in quotes if whitespace or quotes exist. + if ($s -match '[\s\"]') { + $escaped = $s -replace '"', '\\"' + return '"' + $escaped + '"' + } + return $s +} + +function New-Base64UrlToken([int]$numBytes = 32) { + $bytes = New-Object byte[] $numBytes + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $b64 = [Convert]::ToBase64String($bytes) + # Base64URL without padding + return ($b64.TrimEnd('=') -replace '\+','-' -replace '/','_') +} + +function Stop-ExistingInstance([string]$exePath) { + try { + if (Get-Command Stop-ScheduledTask -ErrorAction SilentlyContinue) { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null + } else { + schtasks.exe /End /TN $TaskName 2>$null | Out-Null + } + } catch { + # best-effort + } + + try { + $procs = Get-Process -Name $ServiceName -ErrorAction SilentlyContinue + foreach ($p in $procs) { + try { + if ($p.Path -and (Split-Path -Path $p.Path -Resolve) -ieq (Split-Path -Path $exePath -Resolve)) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + } catch { + # best-effort + } + } + } catch { + # best-effort + } +} + +# Parse args in a bash-like style to match the README expectation. +$rawArgs = @($args) +for ($i = 0; $i -lt $rawArgs.Count; $i++) { + switch ($rawArgs[$i]) { + '-h' { Write-Usage; exit 0 } + '--help' { Write-Usage; exit 0 } + '--listen' { + if ($i + 1 -ge $rawArgs.Count) { throw "--listen requires a value" } + $Listen = $rawArgs[$i + 1] + $i++ + continue + } + '--allow' { + if ($i + 1 -ge $rawArgs.Count) { throw "--allow requires a value" } + $p = $rawArgs[$i + 1] + if ($p -and $p.Trim().Length -gt 0) { $Allow += $p } + $i++ + continue + } + default { + throw "Unknown arg: $($rawArgs[$i])" + } + } +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcExe = Join-Path $scriptDir "$ServiceName.exe" +if (-not (Test-Path -LiteralPath $srcExe)) { + Write-Error "Missing binary next to script: $srcExe`nBuild it first (e.g. 'go build -o $ServiceName.exe .') and re-run." +} + +$installDir = Join-Path $env:LOCALAPPDATA $ServiceName +$exePath = Join-Path $installDir "$ServiceName.exe" +$configPath = Join-Path $installDir "config.json" + +New-Item -ItemType Directory -Force -Path $installDir | Out-Null + +# Load existing config if present (preserve TOKEN across updates). +$existingToken = $null +if (Test-Path -LiteralPath $configPath) { + try { + $cfg = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + if ($cfg -and $cfg.Token) { $existingToken = [string]$cfg.Token } + } catch { + # ignore malformed config; will regenerate + } +} + +$tokenSuggested = if ($existingToken) { "" } else { New-Base64UrlToken } +Write-Host "" +if ($existingToken) { + Write-Host "A token is already configured. Press Enter to keep it, or paste a new one." +} else { + Write-Host "No token configured yet. Press Enter to use a generated token, or paste your own." +} + +$tokenInput = Read-Host -Prompt "Token" # not secure, but matches Linux behavior +$tokenInput = ("" + $tokenInput).Trim() + +$token = if ($tokenInput.Length -gt 0) { $tokenInput } elseif ($existingToken) { $existingToken } else { $tokenSuggested } +if (-not $token -or $token.Trim().Length -eq 0) { + throw "Failed to determine token" +} + +# Persist config. +$config = [ordered]@{ + Listen = $Listen + Token = $token + Allow = @($Allow) +} +($config | ConvertTo-Json -Depth 4) | Set-Content -LiteralPath $configPath -Encoding UTF8 + +# Update behavior: stop existing instance, then replace binary. +Stop-ExistingInstance -exePath $exePath + +$tmpExe = Join-Path $installDir ("$ServiceName.tmp.{0}.exe" -f ([Guid]::NewGuid().ToString('N'))) +Copy-Item -LiteralPath $srcExe -Destination $tmpExe -Force + +try { + Move-Item -LiteralPath $tmpExe -Destination $exePath -Force +} catch { + # If replace failed, try removing and retry once. + Remove-Item -LiteralPath $exePath -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $tmpExe -Destination $exePath -Force +} + +# Build the argument string for the scheduled task. +$argList = @('-listen', $Listen, '-token', $token) +foreach ($p in $Allow) { + if ($p -and $p.Trim().Length -gt 0) { + $argList += @('-allow', $p) + } +} + +$argString = ($argList | ForEach-Object { Quote-Arg $_ }) -join ' ' + +# Register/update the scheduled task (per-user, interactive at logon). +$principalUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + +$action = New-ScheduledTaskAction -Execute $exePath -Argument $argString +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $principalUser +$principal = New-ScheduledTaskPrincipal -UserId $principalUser -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + +$task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings +Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null + +# Start now (best-effort). +try { + Start-ScheduledTask -TaskName $TaskName | Out-Null +} catch { + # best-effort +} + +Write-Host "" +Write-Host "Installed/updated $ServiceName (Scheduled Task)." +Write-Host "- Binary: $exePath" +Write-Host "- Task: $TaskName" +Write-Host "- Config: $configPath" +Write-Host "Token (set this in the plugin config): $token" +Write-Host "" +Write-Host "To view task status: schtasks /Query /TN $TaskName /V" +Write-Host "To run it manually: schtasks /Run /TN $TaskName" diff --git a/install-windows.bat b/install-windows.bat deleted file mode 100644 index 3974772..0000000 --- a/install-windows.bat +++ /dev/null @@ -1,112 +0,0 @@ -@echo off -setlocal EnableExtensions EnableDelayedExpansion - -set "SERVICE_NAME=LuxtoolsClient" -set "DISPLAY_NAME=luxtools-client" -set "INSTALL_DIR=%ProgramFiles%\LuxtoolsClient" -set "BIN_PATH=%INSTALL_DIR%\luxtools-client.exe" -set "DATA_DIR=%ProgramData%\LuxtoolsClient" -set "TOKEN_FILE=%DATA_DIR%\token.txt" -set "LISTEN=127.0.0.1:8765" -set "SCRIPT_DIR=%~dp0" -set "SRC_EXE=%SCRIPT_DIR%luxtools-client.exe" - -rem Optional args: -rem --listen host:port - -if /I "%~1"=="-h" goto :usage -if /I "%~1"=="--help" goto :usage - -:parse -if "%~1"=="" goto :main -if /I "%~1"=="--listen" ( - set "LISTEN=%~2" - shift - shift - goto :parse -) -echo Unknown arg: %~1 -goto :usage - -:usage -echo Usage: %~nx0 [--listen host:port] -echo. -echo Installs/updates %DISPLAY_NAME% as a Windows Service (auto-start). -echo Re-running updates the installed binary and restarts the service. -echo The token is stored in %TOKEN_FILE%. -exit /b 2 - -:main -rem Require admin -net session >nul 2>&1 -if not "%ERRORLEVEL%"=="0" ( - echo This script must be run as Administrator. - exit /b 1 -) - -if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" >nul 2>&1 -if not exist "%DATA_DIR%" mkdir "%DATA_DIR%" >nul 2>&1 - -if not exist "%SRC_EXE%" ( - echo Missing binary next to script: %SRC_EXE% - echo Build it first ^(e.g. "go build -o luxtools-client.exe ."^) and re-run. - exit /b 1 -) - -set "CURRENT_TOKEN=" -if exist "%TOKEN_FILE%" ( - set /p CURRENT_TOKEN=<"%TOKEN_FILE%" -) - -set "SUGGESTED_TOKEN=" -if "%CURRENT_TOKEN%"=="" ( - rem Generate a URL-safe-ish token; PowerShell is available on modern Windows. - for /f "usebackq delims=" %%T in (`powershell -NoProfile -Command "$b=[byte[]]::new(32);[Security.Cryptography.RandomNumberGenerator]::Fill($b);[Convert]::ToBase64String($b).TrimEnd('=') -replace '\+','-' -replace '/','_'"`) do ( - set "SUGGESTED_TOKEN=%%T" - ) -) - -echo. -if not "%CURRENT_TOKEN%"=="" ( - echo A token is already configured. Press Enter to keep it, or type a new one. -) else ( - echo No token configured yet. Press Enter to use a generated token, or type your own. -) -set /p TOKEN_INPUT=Token: - -if not "%TOKEN_INPUT%"=="" ( - set "TOKEN=%TOKEN_INPUT%" -) else ( - if not "%CURRENT_TOKEN%"=="" ( - set "TOKEN=%CURRENT_TOKEN%" - ) else ( - set "TOKEN=%SUGGESTED_TOKEN%" - ) -) - ->"%TOKEN_FILE%" (echo %TOKEN%) - -copy /Y "%SRC_EXE%" "%BIN_PATH%" >nul -if not "%ERRORLEVEL%"=="0" exit /b 1 - -set "BINPATH=\"%BIN_PATH%\" -listen %LISTEN% -token %TOKEN%" - -sc.exe query "%SERVICE_NAME%" >nul 2>&1 -if "%ERRORLEVEL%"=="0" ( - echo Updating existing service... - sc.exe stop "%SERVICE_NAME%" >nul 2>&1 - sc.exe config "%SERVICE_NAME%" start= auto binPath= "%BINPATH%" DisplayName= "%DISPLAY_NAME%" >nul -) else ( - echo Creating service... - sc.exe create "%SERVICE_NAME%" start= auto binPath= "%BINPATH%" DisplayName= "%DISPLAY_NAME%" >nul -) - -sc.exe start "%SERVICE_NAME%" >nul 2>&1 - -echo. -echo Installed/updated %DISPLAY_NAME%. -echo - Binary: %BIN_PATH% -echo - Token: %TOKEN% -echo - Listen: %LISTEN% -endlocal -exit /b 0 diff --git a/uninstall-windows-task.bat b/uninstall-windows-task.bat new file mode 100644 index 0000000..f0a1379 --- /dev/null +++ b/uninstall-windows-task.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%uninstall-windows-task.ps1" %* +set EXITCODE=%ERRORLEVEL% +exit /b %EXITCODE% diff --git a/uninstall-windows-task.ps1 b/uninstall-windows-task.ps1 new file mode 100644 index 0000000..8b90718 --- /dev/null +++ b/uninstall-windows-task.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [Alias('keep-config')] + [switch]$KeepConfig +) + +$ErrorActionPreference = 'Stop' + +$ServiceName = "luxtools-client" +$TaskName = $ServiceName + +function Write-Usage { + @" +Usage: + uninstall-windows-task.bat [--keep-config] + +Uninstalls $ServiceName Scheduled Task and removes installed files. + +Options: + --keep-config Keep %LOCALAPPDATA%\$ServiceName\config.json on disk. +"@ | Write-Host +} + +# Allow -h/--help as positional args (PowerShell doesn't treat these as built-in). +foreach ($a in @($args)) { + if ($a -eq '-h' -or $a -eq '--help') { + Write-Usage + exit 0 + } +} + +$installDir = Join-Path $env:LOCALAPPDATA $ServiceName +$exePath = Join-Path $installDir "$ServiceName.exe" +$configPath = Join-Path $installDir "config.json" + +# Stop task and any running instance (best-effort). +try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null } catch {} +try { schtasks.exe /End /TN $TaskName 2>$null | Out-Null } catch {} + +try { + $procs = Get-Process -Name $ServiceName -ErrorAction SilentlyContinue + foreach ($p in $procs) { + try { + if ($p.Path -and (Split-Path -Path $p.Path -Resolve) -ieq (Split-Path -Path $exePath -Resolve)) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } +} catch {} + +# Remove task (best-effort). +try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch {} +try { schtasks.exe /Delete /TN $TaskName /F 2>$null | Out-Null } catch {} + +# Remove files. +if (Test-Path -LiteralPath $installDir) { + if ($KeepConfig) { + # Remove everything except config.json + Get-ChildItem -LiteralPath $installDir -Force | ForEach-Object { + if ($_.FullName -ieq $configPath) { return } + try { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } catch {} + } + # If directory became empty except config, keep it. + } else { + try { Remove-Item -LiteralPath $installDir -Recurse -Force -ErrorAction SilentlyContinue } catch {} + } +} + +Write-Host "Uninstalled $ServiceName." +if ($KeepConfig) { + Write-Host "Kept config file: $configPath" +} diff --git a/uninstall-windows.bat b/uninstall-windows.bat deleted file mode 100644 index 04f2c83..0000000 --- a/uninstall-windows.bat +++ /dev/null @@ -1,55 +0,0 @@ -@echo off -setlocal EnableExtensions - -set "SERVICE_NAME=LuxtoolsClient" -set "INSTALL_DIR=%ProgramFiles%\LuxtoolsClient" -set "DATA_DIR=%ProgramData%\LuxtoolsClient" - -rem Optional args: -rem --keep-config - -set "KEEP_CONFIG=0" -if /I "%~1"=="-h" goto :usage -if /I "%~1"=="--help" goto :usage -if /I "%~1"=="--keep-config" set "KEEP_CONFIG=1" - -goto :main - -:usage -echo Usage: %~nx0 [--keep-config] -echo. -echo Uninstalls luxtools-client Windows Service and removes installed files. -echo. -echo Options: -echo --keep-config Keeps %DATA_DIR% (token/config). -exit /b 2 - -:main -net session >nul 2>&1 -if not "%ERRORLEVEL%"=="0" ( - echo This script must be run as Administrator. - exit /b 1 -) - -sc.exe query "%SERVICE_NAME%" >nul 2>&1 -if "%ERRORLEVEL%"=="0" ( - sc.exe stop "%SERVICE_NAME%" >nul 2>&1 - rem Wait up to ~20s for the service to fully stop. - for /L %%i in (1,1,20) do ( - sc.exe query "%SERVICE_NAME%" | findstr /I "STATE" | findstr /I "STOPPED" >nul 2>&1 - if "%ERRORLEVEL%"=="0" goto :stopped - timeout /T 1 /NOBREAK >nul - ) -:stopped - sc.exe delete "%SERVICE_NAME%" >nul 2>&1 -) - -if exist "%INSTALL_DIR%" rmdir /S /Q "%INSTALL_DIR%" >nul 2>&1 -if "%KEEP_CONFIG%"=="0" ( - if exist "%DATA_DIR%" rmdir /S /Q "%DATA_DIR%" >nul 2>&1 -) - -echo Uninstalled luxtools-client. -if "%KEEP_CONFIG%"=="1" echo Kept config directory: %DATA_DIR% -endlocal -exit /b 0