From d416e52b2f6421c0e1899060f91999a5905ba5ad Mon Sep 17 00:00:00 2001 From: bradtraversy Date: Mon, 27 Jul 2026 10:18:27 -0400 Subject: [PATCH 1/3] feat: add cross-platform background run modes --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 48 +- blueprint/build-plan.md | 4 + blueprint/context/current-feature.md | 318 ++++++++- src/atomic_file.rs | 79 +++ src/background/command.rs | 310 +++++++++ src/background/lifecycle.rs | 934 +++++++++++++++++++++++++++ src/background/linux.rs | 399 ++++++++++++ src/background/macos.rs | 461 +++++++++++++ src/background/mod.rs | 272 ++++++++ src/background/windows.rs | 417 ++++++++++++ src/config.rs | 18 +- src/main.rs | 339 ++++++++-- src/server.rs | 121 ++++ 15 files changed, 3663 insertions(+), 61 deletions(-) create mode 100644 src/atomic_file.rs create mode 100644 src/background/command.rs create mode 100644 src/background/lifecycle.rs create mode 100644 src/background/linux.rs create mode 100644 src/background/macos.rs create mode 100644 src/background/mod.rs create mode 100644 src/background/windows.rs create mode 100644 src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 55b5e4c..ce5517b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -949,6 +949,7 @@ dependencies = [ "sysinfo", "thiserror 2.0.18", "tokio", + "windows-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 73223d3..d502323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ libc = "0.2" netstat2 = "0.11.2" sysinfo = "0.39.5" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/README.md b/README.md index 53f6443..3972410 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ and what can be stopped safely. - **Port lookup** - type a port, see exactly what owns it and act on it - **Exposure labels** - know what is local-only, LAN-visible, or Docker-bound - **Stale hints** - find the dev server you forgot about last week -- **Safe stop and restart** - graceful stop, verified relaunch, and force only - behind a second explicit yes +- **Safe stop** - graceful stop first, with force only behind a second explicit + yes - **Docker and Advanced tabs** - container hints, raw sockets, JSON export Everything runs locally. No accounts, no telemetry. @@ -44,20 +44,48 @@ Binaries and checksums for every platform are on the ## Use ```sh -portdoc # start the dashboard on 127.0.0.1:7788 and open it -portdoc --port 7799 # different port -portdoc --no-open # don't open the browser -portdoc --json # print the snapshot as JSON and exit +portdoc # choose foreground or background in a terminal +portdoc foreground # run attached to this terminal +portdoc ui # foreground alias +portdoc --port 7799 # use a different port +portdoc --no-open # don't open the browser +portdoc --json # print the snapshot as JSON and exit ``` The server binds `127.0.0.1` only; nothing is reachable from the network. -`Ctrl+C` stops it. +Plain `portdoc` asks how to run only when launched in an interactive terminal. +Scripts and other noninteractive launches preserve the foreground behavior +without prompting. `Ctrl+C` stops a foreground server. + +### Background mode + +```sh +portdoc background start # install or refresh, then start now +portdoc background status # show manager and health status +portdoc background stop # stop now, keep sign-in startup enabled +portdoc background disable # stop and remove sign-in startup +portdoc background start --port 7799 # manage a different port +``` + +Background mode uses the current user's native process manager and does not +require administrator privileges: + +- Linux uses a systemd user service named `portdoc.service`. +- macOS uses a LaunchAgent named `com.traversymedia.portdoc`. +- Windows uses a Task Scheduler task named `PortDoc`. + +`background start` starts PortDoc immediately and enables it for future user +sign-ins. It refuses to replace a foreground PortDoc or another application +already using the requested port. `background stop` leaves sign-in startup +configured, while `background disable` removes it. ## Known limitations (v0.1) -- Restart requires an exact PID, executable, argument vector, and working - directory. PortDoc itself, Docker-managed services, and unknown owners cannot - be restarted. +- Background mode on Linux requires systemd. It does not enable user lingering, + so PortDoc starts after sign-in rather than before it. +- Installers do not yet coordinate upgrades with an already running background + instance. Stop it before upgrading, then run `portdoc background start` after + the upgrade. - Windows binaries are Authenticode-signed (as "Brad Traversy") since v0.1.1, so Smart App Control and SmartScreen accept them. - macOS binaries are unsigned; the installer and Homebrew paths avoid diff --git a/blueprint/build-plan.md b/blueprint/build-plan.md index 98c43c6..7696abc 100644 --- a/blueprint/build-plan.md +++ b/blueprint/build-plan.md @@ -34,3 +34,7 @@ - [x] 16b. **Project facts probe** - additive optional `ProjectGroup` fields (explicit contract decision): project description (package.json/Cargo.toml, README first line fallback), dev/build/start scripts, key deps, workspaces, node version, plus last commit age and dirty/clean from local git. Description renders inline in the Projects tab headers; full facts live in a project drawer (decided 2026-07-08: slide-over like the inspect drawer, no routing/detail page - the app has no router and the drawer pattern is established). - [x] 16c. **Project actions** - open in editor (new endpoint), copy cd command, and stop-all-services-in-project built on the feature 12 confirmation contract. Editor decided 2026-07-09: config `editor` key, `code` default. Shipped with two found-in-verification fixes: tilde expansion on `/api/open`/`/api/reveal`, stop-all dialog at App level. - [x] 17. **Signed Windows releases** - Authenticode-sign `portdoc.exe` in the release pipeline so Smart App Control and SmartScreen accept installs without overrides (v0.1.0 finding: SAC hard-blocks the unsigned binary on fresh Win11). Decided 2026-07-10: Azure Artifact Signing, Basic tier ($9.99/mo, 5k signatures), individual validation as "Brad Traversy". Integration is the proven unzip/sign/re-zip step on the Windows build leg (azure/artifact-signing-action@v2, OIDC login, RFC3161 timestamp) with `allow-dirty = ["ci"]`, swapped for cargo-dist's native `azure-windows-sign` when PR #2396 merges. Blocked on Brad's one-time Azure identity validation (1-20 business days); ships as v0.1.1. macOS signing/notarization deliberately deferred (curl/brew paths dodge Gatekeeper; needs the $99/yr Apple Developer membership and rcodesign - decide later). +- [ ] 18. **Cross-platform run modes** - let users choose between an attached foreground server and a native per-user background process on Linux, macOS, and Windows, with safe lifecycle controls and upgrade handling. + - [ ] 18a. **Managed background runtime** - add explicit foreground and background lifecycle commands backed by a systemd user unit on Linux, a launchd LaunchAgent on macOS, and a per-user scheduled task on Windows. Background start runs PortDoc immediately and at future sign-ins. + - [ ] 18b. **Interactive launch choice** - make plain `portdoc` ask interactive terminal users whether to run in the foreground or background, handle already-running instances without a port fight, and keep managed and noninteractive invocations prompt-free. Implement this in the same feature branch as 18a so the work delivers the complete user-facing run-mode choice. + - [ ] 18c. **Safe background upgrades** - make the Linux, macOS, Windows, and Homebrew upgrade paths stop a managed PortDoc before replacing its binary, restore the prior background state after a successful update, and document recovery. diff --git a/blueprint/context/current-feature.md b/blueprint/context/current-feature.md index c0aaad1..9f206c6 100644 --- a/blueprint/context/current-feature.md +++ b/blueprint/context/current-feature.md @@ -1,8 +1,314 @@ -# Current Feature +# Feature: Cross-platform run modes -> **Generated file.** Holds the one feature or fix being built right now. Run -> `/feature ` to spec a build-plan feature, or `/fix ""` for -> an ad-hoc fix. Build one thing at a time; `/complete` archives it (to -> `blueprint/history/features/` or `blueprint/history/fixes/`) and resets this file. +**From build-plan:** features 18a and 18b +**Status:** in progress -_Nothing in progress. Run `/feature` or `/fix` to start._ +## Goal + +Let an interactive plain `portdoc` launch ask whether to run attached to the +current terminal or under the operating system's native per-user process +manager. Also provide explicit, noninteractive lifecycle commands. Background +start must run PortDoc immediately, start it again at future user sign-ins, and +support Linux, macOS, and Windows in the same feature. + +The safety boundary is strict: PortDoc manages only its named native manager +entry. It never selects a listener PID, kills an arbitrary process, or spawns a +replacement server itself. + +## In scope + +- Make plain `portdoc` ask interactive terminal users whether to run: + - in the foreground, attached to the current terminal; or + - in the background, managed by the current user's native operating-system + service manager. +- Keep noninteractive invocations prompt-free. Plain `portdoc` without an + interactive terminal preserves the existing foreground behavior. +- Add these public CLI commands: + - `portdoc foreground` runs the server attached to the current terminal and + preserves `Ctrl+C` behavior. + - `portdoc background start` installs or refreshes the current user's native + manager entry, starts it now, waits for a verified PortDoc health response, + and opens the dashboard unless `--no-open` is set. + - `portdoc background stop` stops the manager entry now but leaves sign-in + startup configured. + - `portdoc background status` reports whether the entry is configured, + whether its native manager considers it active where that state is + available, and whether the configured PortDoc health endpoint is reachable. + - `portdoc background disable` stops the entry, unregisters it, and removes + the generated definition so it will not start at the next sign-in. +- Preserve existing behavior around the new launch choice: + - `portdoc ui` remains a foreground alias. + - `portdoc --json` continues to print a snapshot and exit without touching + run-mode management. + - Global `--port` and `--no-open` continue to work, including + `portdoc background start --port 7799`. +- Add a hidden, noninteractive managed-server entrypoint used only by native + manager definitions. It must never open a browser or prompt for input. +- Use native, per-user supervision without administrator privileges: + - Linux: `portdoc.service` under the systemd user manager, enabled and started + with `systemctl --user`. + - macOS: `com.traversymedia.portdoc` in `~/Library/LaunchAgents`, managed with + `launchctl` in the logged-in user's GUI domain. + - Windows: a least-privilege `PortDoc` task in Task Scheduler, triggered when + the current user signs in and started on demand with `schtasks`. +- Manager definitions use the absolute path returned by + `std::env::current_exe`, pass an explicit port, and invoke only the hidden + managed-server entrypoint. Paths and arguments are escaped for each native + definition format without passing a command string through a shell. +- Configure each manager for one PortDoc instance and bounded restart after an + unexpected failure. An explicit stop or disable must not trigger that restart. +- Capture the current `PATH` in Linux and macOS native definitions when + background start refreshes them, so existing Git, Docker, and configured + editor integrations do not silently disappear under a smaller login-manager + environment. Windows Task Scheduler has no environment field in its execution + action schema, so the task runs as the current user and uses the user + environment cached by Task Scheduler. Do not persist the rest of the + launching process environment or add a shell wrapper. +- Make background start idempotent. Re-running it safely stops the named manager + entry, refreshes its executable path, port, and environment, then starts that + same entry. It never acts on a process found only by port or PID. +- Render and validate a refreshed definition before disturbing an existing + managed instance, replace definitions atomically, and persist a changed + background port only after start succeeds. If a manager operation fails, + preserve the previous config and report the exact manager failure. Full + cross-version rollback belongs to feature 18c. +- Refuse background start when the target port already serves a foreground + PortDoc or a non-PortDoc listener. A background health response is treated as + owned only when the stable native manager entry is also registered. Report + every other conflict without stopping it. +- Add bounded start and stop waits. A manager command or health check that does + not complete in time returns a useful error instead of hanging. +- Handle managed shutdown correctly: + - Linux and macOS accept the normal termination signal and let axum shut down + gracefully. + - Windows uses Task Scheduler's named task lifecycle and never falls back to + `taskkill` against a discovered listener. +- Extend `GET /api/health` additively with the current process ID and run mode so + lifecycle commands can distinguish a managed PortDoc from a foreground + instance. +- Persist the configured background port as an optional field in the existing + local config. Old config files remain valid, and background configuration + does not change ignored services or editor settings. +- Stop and disable continue to address the stable native manager name even when + the local config is missing or malformed. Status reports an unknown health + endpoint in that case instead of guessing which port the existing entry uses. +- Update the README command reference, explain the native manager used on each + operating system, and remove the stale claims that service Restart is still + available. + +## Out of scope + +- Remembering a preferred answer to the interactive question. +- Installer and Homebrew upgrade hooks that stop and restore a managed process + while replacing the binary. That is feature 18c. +- Transactional restoration of a prior binary, definition, config, and running + state after an upgrade failure. That is feature 18c. +- A dashboard button for starting, stopping, or restarting PortDoc itself. +- Restoring the removed Restart action for services shown in the dashboard. +- System-wide services, administrator elevation, running before user sign-in, + or enabling Linux user lingering. +- Non-systemd Linux service managers. Foreground mode remains available and + background commands return a clear unsupported-manager error. +- Automatic updates, remote control, network binding, or telemetry. + +## Build loop + +Build one step at a time, never the whole feature at once. + +1. Plan mode lays out the step before any code. +2. The AI implements just that step. +3. It shows the diff, not full files, and reports the verification evidence. +4. Brad approves before the next step begins. +5. Checkpoint commits remain optional. `/complete` makes the feature-level + commit after every step passes. + +## Build steps + +- [x] **Step 1 - Separate server execution from CLI routing** - extract the + reusable server runner, add explicit `foreground` plus the hidden managed + entrypoint, carry a typed `RunMode` into the health route, and support graceful + termination on Unix while preserving plain `portdoc`, `portdoc ui`, + `--no-open`, `--port`, and `--json`. Add CLI and health-contract tests. + *Done when:* `cargo test`, `cargo clippy -- -D warnings`, and + `cargo fmt --check` pass on Linux; existing commands retain their behavior; + the hidden entrypoint never opens a browser; `/api/health` includes `pid` and + `run_mode`. +- [x] **Step 2a - Lock lifecycle types and config** - add platform-neutral types + for manager configuration, manager status, and typed lifecycle errors; add the + optional persisted background port without changing old config behavior. + *Done when:* zero is rejected as a managed port; pre-18a, missing, malformed, + and unknown-field config cases retain their behavior; a configured background + port round-trips under `cargo test`. +- [x] **Step 2b - Add the manager command runner** - add an injectable, + deadline-aware direct argv runner that captures stdout, stderr, exit code, + spawn failures, read failures, and timeouts without invoking a shell in + production code. *Done when:* focused cross-platform fixtures prove successful + output, nonzero stderr, missing-program errors, and bounded timeout behavior + under `cargo test`. +- [x] **Step 2c - Render all native definitions** - add pure renderers for the + Linux unit, macOS plist, and Windows task XML. Test spaces, quotes, XML + entities, missing config, systemd specifier characters, control characters, + and non-ASCII paths without invoking a real manager. *Done when:* every + definition points at the exact executable, explicit managed entrypoint, and + port; Linux and macOS include the captured `PATH`; Windows contains no invented + environment field or shell wrapper; renderer edge cases pass under + `cargo test`. +- [x] **Step 3 - Implement Linux systemd user management** - write the user unit + atomically, use only `systemctl --user` for enable, start, stop, status, and + disable, and keep stop separate from disable. Never infer ownership from the + listening port. *Done when:* injected-runner tests prove the exact command + sequence, atomic replacement, status mapping, failure propagation, and that + stop does not disable sign-in startup while disable removes the unit. Live + lifecycle proof runs in Step 8 after Step 6 exposes the public commands. +- [x] **Step 4 - Implement macOS LaunchAgent management** - write the per-user + plist atomically, reuse a shared atomic-definition helper extracted from the + Linux implementation, use the current GUI launchd domain for bootstrap, + bootout, kickstart, and status, and keep the plist after stop but remove it on + disable. *Done when:* macOS-targeted tests prove definition paths, XML + escaping, and command sequences, including separate stop and disable behavior. + Live lifecycle proof runs in Step 8 after Step 6 exposes the public commands. +- [x] **Step 5 - Implement Windows scheduled-task management** - register a + current-user logon task at limited privilege from an XML definition, start it + on demand, query registration without locale-dependent status parsing, end + only that named task, and delete only that named task on disable. *Done when:* + Windows-targeted tests prove XML escaping, exact `schtasks` argument + sequences, and separate stop and disable behavior. Live lifecycle proof runs + in Step 8 after Step 6 exposes the public commands. +- [x] **Step 6 - Wire lifecycle commands and conflict handling** - connect the + public background commands to the platform manager, persist the port only + after a successful start, verify `run_mode: background` with bounded retries, + and report not configured, stopped, running, unhealthy, unsupported, and + port-conflict outcomes. Stop and disable address only the stable native + manager entry and verify the resulting manager state. *Done when:* unit tests + cover each state and ownership check; a foreground PortDoc or unrelated + listener on the target port is never stopped; every command exits promptly + with actionable output. +- [x] **Step 7 - Add the interactive launch choice** - prompt only when plain + `portdoc` has an interactive terminal, route foreground and background choices + through the explicit commands, and handle an already-running PortDoc without + creating a port fight. `portdoc ui`, `portdoc --json`, explicit lifecycle + commands, and noninteractive invocations never prompt. *Done when:* CLI tests + cover both choices, EOF or cancelled input, noninteractive behavior, and an + already-running instance; the prompt itself contains only the two requested + run modes. +- [ ] **Step 8 - Document and prove all three platforms** - update README usage + and limitations, remove stale Restart wording, run the full Rust and frontend + gates, and require the Ubuntu, macOS, and Windows CI matrix. Record live + foreground, start, status, stop, sign-in startup, and disable evidence from + each operating system before completion. *Done when:* `cargo fmt --check`, + `cargo test`, `cargo clippy -- -D warnings`, `npm run lint`, and + `npm run build` pass; all three CI operating systems pass; the manual lifecycle + matrix has no unresolved failure. + +## Files / areas + +- `src/main.rs` - CLI routing and compatibility aliases. +- `src/server.rs` - reusable server runner, run mode, health state, and shutdown. +- `src/background/mod.rs` - platform-neutral lifecycle contract, status model, + orchestration, health verification, and typed errors. +- `src/background/linux.rs` - systemd user-unit renderer and commands. +- `src/background/macos.rs` - LaunchAgent plist renderer and launchctl commands. +- `src/background/windows.rs` - Task Scheduler XML renderer and schtasks commands. +- `src/config.rs` - optional background port with backward-compatible serde. +- `Cargo.toml` / `Cargo.lock` - only if a small cross-platform dependency is + justified by implementation. Prefer the standard library and existing tokio. +- `README.md` - explicit command reference, platform terminology, limitations, + and removal of stale Restart claims. +- `.github/workflows/ci.yml` and release workflow only if the existing + three-platform test matrix needs a targeted command or fixture adjustment. + +## Data / contracts + +- `RunMode` has exactly two serialized values in this feature: + `foreground` and `background`. +- `GET /api/health` remains backward compatible and adds: + + ```json + { + "status": "ok", + "version": "", + "pid": 1234, + "run_mode": "foreground" + } + ``` + +- Existing health consumers must continue to work when they read only `status` + and `version`. +- Existing config adds an optional `background_port` integer. Missing means no + remembered managed port and defaults to 7788 when a new background entry is + created. Old config files and unknown future keys remain tolerated. +- Internal `BackgroundSpec` carries the absolute executable path, port, captured + `PATH` where the manager supports it, and platform definition location. +- Internal `BackgroundStatus` separates native registration from endpoint + health. A registered but unhealthy entry must never be reported as running + successfully. +- Successful start health must report `run_mode: background` and a positive PID. +- Public status output uses these stable meanings: + - `not configured` - no named native manager entry exists. + - `stopped` - the entry exists but the configured managed health endpoint is + not reachable. + - `running` - the entry exists and health reports `run_mode: background`. + - `unhealthy` - the manager reports an active or failed entry but the expected + managed health response is absent. + - `port conflict` - the configured port answers as foreground PortDoc or is + occupied by something other than the expected managed instance. +- `background status` exits 0 only for `running`; other states exit nonzero. +- `background stop` never unregisters startup. `background disable` always + stops and unregisters the exact named entry. + +## Testing + +- Rust unit tests are required for all logic-bearing steps. +- Use `Cli::try_parse_from` coverage for public commands, legacy aliases, global + flags, and the hidden managed entrypoint. +- Test health serialization and parsing, including malformed JSON, wrong run + mode, wrong port, and timeout. +- Test config loading and saving with pre-18a files and `background_port`. +- Test unit, plist, and task XML renderers with executable paths containing + spaces, quotes, ampersands, systemd `%` specifiers, backslashes, control + characters, and non-ASCII characters. Unsafe control characters must be + escaped correctly or rejected before any native definition is replaced. +- Test manager command sequences through an injected runner. Unit tests must not + install, start, stop, or delete real operating-system entries. +- Test failed manager registration, start, stop, and health verification. A + failure must preserve the prior config and return an actionable error. +- The Ubuntu, macOS, and Windows CI matrix must compile and run the applicable + platform tests. No platform is allowed to land as a stub. +- Live verification is required on each operating system because CI cannot prove + sign-in startup or terminal detachment. Brad runs the foreground and manager + commands; the AI inspects output and health evidence without starting a dev + server itself. +- Verify stop versus disable separately, close the launching terminal after + background start, and sign out and back in before calling the feature + complete. + +## Notes for the AI + +- Treat OS managers as the only process owners. Never reconstruct an argv from a + probed service and never call `Command::spawn` to replace PortDoc. +- Manager commands must execute directly with an argv vector, never through + `sh`, `cmd /C`, or PowerShell string evaluation. +- Do not target a PID returned by the probe. The existing `/api/stop` and + `taskkill` support are for dashboard services, not PortDoc lifecycle. +- Keep platform-specific code behind `src/background/` cfg boundaries. Public + CLI routing must not accumulate operating-system branches. +- Use atomic writes for generated unit, plist, task XML, and config files. +- Avoid locale-dependent parsing of manager output. Prefer exit status, stable + machine-readable output, and the PortDoc health contract. +- Background start must refresh the absolute executable path so a later + installer can move or replace the binary without leaving a stale definition. +- Keep native names stable: `portdoc.service`, + `com.traversymedia.portdoc`, and `PortDoc`. +- Linux uses `WantedBy=default.target` and a restart-on-failure policy. macOS + uses `RunAtLoad`, `KeepAlive`, and the per-user GUI domain. Windows uses an + ONLOGON trigger, limited privilege, ignore-new multiple-instance policy, and + a bounded restart-on-failure policy. +- Task Scheduler XML execution actions expose only command, arguments, and + working directory. Do not invent an environment element. The current-user + task uses Task Scheduler's cached user environment. +- Relevant platform contracts: + - systemd documentation: https://www.freedesktop.org/software/systemd/man/latest/ + - Apple launchd job guidance: + https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html + - Microsoft schtasks reference: + https://learn.microsoft.com/windows-server/administration/windows-commands/schtasks diff --git a/src/atomic_file.rs b/src/atomic_file.rs new file mode 100644 index 0000000..dabdcbb --- /dev/null +++ b/src/atomic_file.rs @@ -0,0 +1,79 @@ +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn replace(path: &Path, contents: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; + fs::create_dir_all(parent)?; + + let temporary = temporary_path(path); + let result = write_and_replace(&temporary, path, contents); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn write_and_replace(temporary: &Path, destination: &Path, contents: &[u8]) -> io::Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(temporary)?; + file.write_all(contents)?; + file.sync_all()?; + replace_file(temporary, destination) +} + +#[cfg(not(windows))] +fn replace_file(temporary: &Path, destination: &Path) -> io::Result<()> { + fs::rename(temporary, destination) +} + +#[cfg(windows)] +fn replace_file(temporary: &Path, destination: &Path) -> io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let temporary = temporary + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH; + let replaced = unsafe { MoveFileExW(temporary.as_ptr(), destination.as_ptr(), flags) }; + if replaced == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn temporary_path(path: &Path) -> PathBuf { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let mut name = OsString::from("."); + name.push(path.file_name().unwrap_or_default()); + name.push(format!(".{}.{}.tmp", std::process::id(), id)); + path.with_file_name(name) +} + +pub(crate) fn remove(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(source), + } +} diff --git a/src/background/command.rs b/src/background/command.rs new file mode 100644 index 0000000..7b04246 --- /dev/null +++ b/src/background/command.rs @@ -0,0 +1,310 @@ +use std::ffi::OsString; +use std::io::{self, Read}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +const POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandRequest { + pub program: OsString, + pub args: Vec, + pub timeout: Duration, +} + +impl CommandRequest { + pub fn new(program: impl Into, args: I, timeout: Duration) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + timeout, + } + } + + fn program_display(&self) -> String { + self.program.to_string_lossy().into_owned() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandOutput { + pub success: bool, + pub code: Option, + pub stdout: String, + pub stderr: String, +} + +pub(crate) trait CommandRunner { + fn run(&self, request: &CommandRequest) -> Result; +} + +#[derive(Debug, Default)] +pub(crate) struct SystemCommandRunner; + +impl CommandRunner for SystemCommandRunner { + fn run(&self, request: &CommandRequest) -> Result { + let program = request.program_display(); + let mut child = Command::new(&request.program) + .args(&request.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| CommandError::Spawn { + program: program.clone(), + source, + })?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| missing_pipe(&mut child, &program, "stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| missing_pipe(&mut child, &program, "stderr"))?; + + let stdout_reader = spawn_reader(stdout, program.clone(), "stdout"); + let stderr_reader = spawn_reader(stderr, program.clone(), "stderr"); + let deadline = Instant::now() + request.timeout; + + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = join_reader(stdout_reader, &program, "stdout")?; + let stderr = join_reader(stderr_reader, &program, "stderr")?; + return Ok(CommandOutput { + success: status.success(), + code: status.code(), + stdout, + stderr, + }); + } + Ok(None) if Instant::now() >= deadline => { + child.kill().map_err(|source| CommandError::Terminate { + program: program.clone(), + source, + })?; + child.wait().map_err(|source| CommandError::Wait { + program: program.clone(), + source, + })?; + let stdout = join_reader(stdout_reader, &program, "stdout")?; + let stderr = join_reader(stderr_reader, &program, "stderr")?; + return Err(CommandError::Timeout { + program, + timeout: request.timeout, + stdout, + stderr, + }); + } + Ok(None) => thread::sleep(POLL_INTERVAL), + Err(source) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(CommandError::Wait { program, source }); + } + } + } + } +} + +fn missing_pipe( + child: &mut std::process::Child, + program: &str, + stream: &'static str, +) -> CommandError { + let _ = child.kill(); + let _ = child.wait(); + CommandError::MissingPipe { + program: program.to_owned(), + stream, + } +} + +fn spawn_reader( + reader: R, + program: String, + stream: &'static str, +) -> thread::JoinHandle> +where + R: Read + Send + 'static, +{ + thread::spawn(move || read_stream(reader, program, stream)) +} + +fn read_stream( + mut reader: impl Read, + program: String, + stream: &'static str, +) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|source| CommandError::Read { + program, + stream, + source, + })?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn join_reader( + reader: thread::JoinHandle>, + program: &str, + stream: &'static str, +) -> Result { + reader.join().map_err(|_| CommandError::ReaderPanicked { + program: program.to_owned(), + stream, + })? +} + +#[derive(Debug, Error)] +pub(crate) enum CommandError { + #[error("could not start {program}: {source}")] + Spawn { + program: String, + #[source] + source: io::Error, + }, + #[error("{program} did not expose its {stream} pipe")] + MissingPipe { + program: String, + stream: &'static str, + }, + #[error("could not read {stream} from {program}: {source}")] + Read { + program: String, + stream: &'static str, + #[source] + source: io::Error, + }, + #[error("could not wait for {program}: {source}")] + Wait { + program: String, + #[source] + source: io::Error, + }, + #[error("could not stop timed-out command {program}: {source}")] + Terminate { + program: String, + #[source] + source: io::Error, + }, + #[error("{program} exceeded its {timeout:?} deadline")] + Timeout { + program: String, + timeout: Duration, + stdout: String, + stderr: String, + }, + #[error("{stream} reader for {program} panicked")] + ReaderPanicked { + program: String, + stream: &'static str, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + const PRINT_STDOUT: (&str, &[&str]) = ("printf", &["fixture stdout"]); + #[cfg(windows)] + const PRINT_STDOUT: (&str, &[&str]) = ("cmd", &["/C", "echo fixture stdout"]); + + #[cfg(unix)] + const EXIT_NONZERO: (&str, &[&str]) = ("sh", &["-c", "printf 'fixture stderr' >&2; exit 7"]); + #[cfg(windows)] + const EXIT_NONZERO: (&str, &[&str]) = ("cmd", &["/C", "echo fixture stderr 1>&2 & exit /b 7"]); + + #[cfg(unix)] + const HANG: (&str, &[&str]) = ("sleep", &["30"]); + #[cfg(windows)] + const HANG: (&str, &[&str]) = ("ping", &["-n", "31", "127.0.0.1"]); + + fn request(program: &str, args: &[&str], timeout: Duration) -> CommandRequest { + CommandRequest::new(program, args.iter().copied(), timeout) + } + + #[test] + fn captures_successful_stdout() { + let (program, args) = PRINT_STDOUT; + let output = SystemCommandRunner + .run(&request(program, args, Duration::from_secs(5))) + .expect("fixture command runs"); + + assert!(output.success); + assert_eq!(output.code, Some(0)); + assert_eq!(output.stdout.trim(), "fixture stdout"); + assert!(output.stderr.is_empty()); + } + + #[test] + fn captures_nonzero_exit_and_stderr() { + let (program, args) = EXIT_NONZERO; + let output = SystemCommandRunner + .run(&request(program, args, Duration::from_secs(5))) + .expect("nonzero exit is command output"); + + assert!(!output.success); + assert_ne!(output.code, Some(0)); + assert!(output.stderr.contains("fixture stderr")); + } + + #[test] + fn reports_a_missing_program() { + let error = SystemCommandRunner + .run(&request( + "portdoc-command-that-does-not-exist", + &[], + Duration::from_secs(5), + )) + .expect_err("missing program must fail"); + + assert!(matches!(error, CommandError::Spawn { .. })); + } + + #[test] + fn kills_a_command_at_its_deadline() { + let (program, args) = HANG; + let started = Instant::now(); + let error = SystemCommandRunner + .run(&request(program, args, Duration::from_millis(200))) + .expect_err("long command must time out"); + + assert!(matches!(error, CommandError::Timeout { .. })); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("fixture read failure")) + } + } + + #[test] + fn reports_stream_read_failures() { + let error = read_stream(FailingReader, "fixture".into(), "stdout") + .expect_err("read failure must surface"); + + assert!(matches!( + error, + CommandError::Read { + stream: "stdout", + .. + } + )); + } +} diff --git a/src/background/lifecycle.rs b/src/background/lifecycle.rs new file mode 100644 index 0000000..6335cf0 --- /dev/null +++ b/src/background/lifecycle.rs @@ -0,0 +1,934 @@ +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use serde::Deserialize; + +use super::command::{CommandRunner, SystemCommandRunner}; +use super::{Action, BackgroundError, BackgroundSpec, BackgroundStatus, ManagerState, Status}; +use crate::config; +use crate::server::RunMode; + +const MANAGER_TIMEOUT: Duration = Duration::from_secs(5); +const HEALTH_TIMEOUT: Duration = Duration::from_millis(300); +const POLL_INTERVAL: Duration = Duration::from_millis(200); +const POLL_ATTEMPTS: usize = 30; + +pub(crate) fn run(action: Action, port: u16) -> Result { + if !platform_supported() { + let detail = unsupported_detail(); + return if action == Action::Status { + Ok(BackgroundStatus { + status: Status::Unsupported, + manager_state: ManagerState::NotConfigured, + port: None, + detail: Some(detail), + }) + } else { + Err(BackgroundError::Unsupported(detail)) + }; + } + + let config_path = config::config_path().ok_or(BackgroundError::ConfigDirectory)?; + let executable = if action == Action::Start { + std::env::current_exe().map_err(BackgroundError::CurrentExecutable)? + } else { + PathBuf::new() + }; + let spec = BackgroundSpec::new( + executable, + port, + std::env::var_os("PATH"), + definition_path().ok_or(BackgroundError::ConfigDirectory)?, + )?; + let definition = if action == Action::Start { + Some(render(&spec)?) + } else { + None + }; + let runner = SystemCommandRunner; + let manager = platform_manager(&runner, &spec); + let probe = TcpHealthProbe; + let lifecycle = Lifecycle { + manager: manager.as_ref(), + probe: &probe, + spec: &spec, + config_path: &config_path, + health_timeout: HEALTH_TIMEOUT, + poll_interval: POLL_INTERVAL, + poll_attempts: POLL_ATTEMPTS, + }; + + lifecycle.execute(action, definition.as_deref()) +} + +trait NativeManager { + fn install(&self, definition: &str) -> Result<(), BackgroundError>; + fn start(&self) -> Result<(), BackgroundError>; + fn state(&self) -> Result; + fn stop(&self) -> Result<(), BackgroundError>; + fn disable(&self) -> Result<(), BackgroundError>; +} + +#[cfg(target_os = "linux")] +impl NativeManager for super::linux::management::Manager<'_> { + fn install(&self, definition: &str) -> Result<(), BackgroundError> { + super::linux::management::Manager::install(self, definition) + } + + fn start(&self) -> Result<(), BackgroundError> { + super::linux::management::Manager::start(self) + } + + fn state(&self) -> Result { + super::linux::management::Manager::state(self) + } + + fn stop(&self) -> Result<(), BackgroundError> { + super::linux::management::Manager::stop(self) + } + + fn disable(&self) -> Result<(), BackgroundError> { + super::linux::management::Manager::disable(self) + } +} + +#[cfg(target_os = "macos")] +impl NativeManager for super::macos::management::Manager<'_> { + fn install(&self, definition: &str) -> Result<(), BackgroundError> { + super::macos::management::Manager::install(self, definition) + } + + fn start(&self) -> Result<(), BackgroundError> { + super::macos::management::Manager::start(self) + } + + fn state(&self) -> Result { + super::macos::management::Manager::state(self) + } + + fn stop(&self) -> Result<(), BackgroundError> { + super::macos::management::Manager::stop(self) + } + + fn disable(&self) -> Result<(), BackgroundError> { + super::macos::management::Manager::disable(self) + } +} + +#[cfg(target_os = "windows")] +impl NativeManager for super::windows::management::Manager<'_> { + fn install(&self, definition: &str) -> Result<(), BackgroundError> { + super::windows::management::Manager::install(self, definition) + } + + fn start(&self) -> Result<(), BackgroundError> { + super::windows::management::Manager::start(self) + } + + fn state(&self) -> Result { + super::windows::management::Manager::state(self) + } + + fn stop(&self) -> Result<(), BackgroundError> { + super::windows::management::Manager::stop(self) + } + + fn disable(&self) -> Result<(), BackgroundError> { + super::windows::management::Manager::disable(self) + } +} + +#[cfg(target_os = "linux")] +fn platform_manager<'a>( + runner: &'a dyn CommandRunner, + spec: &BackgroundSpec, +) -> Box { + Box::new(super::linux::management::Manager::new( + runner, + spec.definition_path.clone(), + MANAGER_TIMEOUT, + )) +} + +#[cfg(target_os = "macos")] +fn platform_manager<'a>( + runner: &'a dyn CommandRunner, + spec: &BackgroundSpec, +) -> Box { + Box::new(super::macos::management::Manager::current( + runner, + spec.definition_path.clone(), + MANAGER_TIMEOUT, + )) +} + +#[cfg(target_os = "windows")] +fn platform_manager<'a>( + runner: &'a dyn CommandRunner, + spec: &BackgroundSpec, +) -> Box { + Box::new(super::windows::management::Manager::new( + runner, + spec.definition_path.clone(), + MANAGER_TIMEOUT, + )) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn platform_manager<'a>( + _runner: &'a dyn CommandRunner, + _spec: &BackgroundSpec, +) -> Box { + unreachable!("unsupported platforms return before manager construction") +} + +#[cfg(target_os = "linux")] +fn platform_supported() -> bool { + Path::new("/run/systemd/system").is_dir() +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn platform_supported() -> bool { + true +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn platform_supported() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn unsupported_detail() -> String { + "this Linux session is not managed by systemd".into() +} + +#[cfg(target_os = "macos")] +fn unsupported_detail() -> String { + "launchd is unavailable".into() +} + +#[cfg(target_os = "windows")] +fn unsupported_detail() -> String { + "Task Scheduler is unavailable".into() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn unsupported_detail() -> String { + "the current operating system has no supported per-user manager".into() +} + +#[cfg(target_os = "linux")] +fn definition_path() -> Option { + dirs::config_dir().map(|path| path.join("systemd/user/portdoc.service")) +} + +#[cfg(target_os = "macos")] +fn definition_path() -> Option { + dirs::home_dir().map(|path| { + path.join("Library") + .join("LaunchAgents") + .join("com.traversymedia.portdoc.plist") + }) +} + +#[cfg(target_os = "windows")] +fn definition_path() -> Option { + dirs::config_dir().map(|path| path.join("portdoc").join("PortDoc.xml")) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn definition_path() -> Option { + None +} + +#[cfg(target_os = "linux")] +fn render(spec: &BackgroundSpec) -> Result { + Ok(super::linux::render(spec)?) +} + +#[cfg(target_os = "macos")] +fn render(spec: &BackgroundSpec) -> Result { + Ok(super::macos::render(spec)?) +} + +#[cfg(target_os = "windows")] +fn render(spec: &BackgroundSpec) -> Result { + Ok(super::windows::render(spec)?) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn render(_spec: &BackgroundSpec) -> Result { + unreachable!("unsupported platforms return before definition rendering") +} + +struct Lifecycle<'a> { + manager: &'a dyn NativeManager, + probe: &'a dyn HealthProbe, + spec: &'a BackgroundSpec, + config_path: &'a Path, + health_timeout: Duration, + poll_interval: Duration, + poll_attempts: usize, +} + +impl Lifecycle<'_> { + fn execute( + &self, + action: Action, + definition: Option<&str>, + ) -> Result { + match action { + Action::Start => self.start(definition.expect("start always renders a definition")), + Action::Stop => self.stop(), + Action::Status => self.status(), + Action::Disable => self.disable(), + } + } + + fn start(&self, definition: &str) -> Result { + let previous_config = config::load(self.config_path); + let manager_state = self.manager.state()?; + let endpoint = self.probe.probe(self.spec.port, self.health_timeout); + self.reject_conflict(self.spec.port, manager_state, &endpoint)?; + + if manager_state != ManagerState::NotConfigured { + self.manager.stop()?; + self.wait_for_stop(previous_config.background_port.or(Some(self.spec.port)))?; + } + + self.manager.install(definition)?; + self.manager.start()?; + self.wait_for_background()?; + if self.manager.state()? == ManagerState::NotConfigured { + return Err(BackgroundError::UnexpectedStatus { + manager: "native manager", + status: "not configured after start".into(), + }); + } + + let mut updated_config = previous_config; + updated_config.background_port = Some(self.spec.port); + config::save(self.config_path, &updated_config).map_err(|source| { + BackgroundError::SaveConfig { + path: self.config_path.to_path_buf(), + source, + } + })?; + + Ok(BackgroundStatus { + status: Status::Running, + manager_state: ManagerState::Active, + port: Some(self.spec.port), + detail: None, + }) + } + + fn stop(&self) -> Result { + let saved_port = config::load(self.config_path).background_port; + let manager_state = self.manager.state()?; + let endpoint = saved_port.map(|port| self.probe.probe(port, self.health_timeout)); + + if manager_state == ManagerState::NotConfigured { + if let (Some(port), Some(endpoint)) = (saved_port, endpoint.as_ref()) { + self.reject_conflict(port, manager_state, endpoint)?; + return Ok(status_for(manager_state, Some(port), endpoint.clone())); + } + return Ok(status_for( + manager_state, + saved_port, + EndpointState::Unavailable, + )); + } + + self.manager.stop()?; + self.wait_for_stop(saved_port)?; + + Ok(BackgroundStatus { + status: Status::Stopped, + manager_state: ManagerState::Stopped, + port: saved_port, + detail: None, + }) + } + + fn status(&self) -> Result { + let saved_port = config::load(self.config_path).background_port; + let manager_state = self.manager.state()?; + let endpoint = saved_port + .map(|port| self.probe.probe(port, self.health_timeout)) + .unwrap_or(EndpointState::Unavailable); + Ok(status_for(manager_state, saved_port, endpoint)) + } + + fn disable(&self) -> Result { + let mut saved_config = config::load(self.config_path); + let manager_state = self.manager.state()?; + let saved_port = saved_config.background_port; + + if manager_state != ManagerState::NotConfigured { + self.manager.stop()?; + self.wait_for_stop(saved_port)?; + self.manager.disable()?; + self.wait_for_disable()?; + } + + if saved_config.background_port.take().is_some() { + config::save(self.config_path, &saved_config).map_err(|source| { + BackgroundError::SaveConfig { + path: self.config_path.to_path_buf(), + source, + } + })?; + } + + Ok(BackgroundStatus { + status: Status::NotConfigured, + manager_state: ManagerState::NotConfigured, + port: None, + detail: None, + }) + } + + fn reject_conflict( + &self, + port: u16, + manager_state: ManagerState, + endpoint: &EndpointState, + ) -> Result<(), BackgroundError> { + let owned_background = manager_state != ManagerState::NotConfigured + && matches!(endpoint, EndpointState::PortDoc(RunMode::Background, _)); + if matches!(endpoint, EndpointState::Unavailable) || owned_background { + return Ok(()); + } + + Err(BackgroundError::PortConflict { + port, + detail: endpoint.detail(), + }) + } + + fn wait_for_background(&self) -> Result<(), BackgroundError> { + for attempt in 0..self.poll_attempts { + match self.probe.probe(self.spec.port, self.health_timeout) { + EndpointState::PortDoc(RunMode::Background, _) => return Ok(()), + EndpointState::Unavailable => self.pause(attempt), + endpoint => { + return Err(BackgroundError::PortConflict { + port: self.spec.port, + detail: endpoint.detail(), + }); + } + } + } + Err(BackgroundError::StartTimeout { + port: self.spec.port, + }) + } + + fn wait_for_stop(&self, port: Option) -> Result<(), BackgroundError> { + for attempt in 0..self.poll_attempts { + let manager_stopped = !manager_state_matches_running(self.manager.state()?); + let endpoint_stopped = port.is_none_or(|port| { + !matches!( + self.probe.probe(port, self.health_timeout), + EndpointState::PortDoc(RunMode::Background, _) + ) + }); + if manager_stopped && endpoint_stopped { + return Ok(()); + } + self.pause(attempt); + } + Err(BackgroundError::StopTimeout) + } + + fn wait_for_disable(&self) -> Result<(), BackgroundError> { + for attempt in 0..self.poll_attempts { + if self.manager.state()? == ManagerState::NotConfigured { + return Ok(()); + } + self.pause(attempt); + } + Err(BackgroundError::DisableTimeout) + } + + fn pause(&self, attempt: usize) { + if attempt + 1 < self.poll_attempts && !self.poll_interval.is_zero() { + thread::sleep(self.poll_interval); + } + } +} + +fn manager_state_matches_running(state: ManagerState) -> bool { + matches!(state, ManagerState::Active | ManagerState::Failed) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EndpointState { + Unavailable, + PortDoc(RunMode, u32), + Other(String), +} + +impl EndpointState { + fn detail(&self) -> String { + match self { + Self::Unavailable => "the endpoint is unavailable".into(), + Self::PortDoc(RunMode::Foreground, pid) => { + format!("foreground PortDoc process {pid} is using the port") + } + Self::PortDoc(RunMode::Background, pid) => { + format!("unmanaged background PortDoc process {pid} is using the port") + } + Self::Other(detail) => detail.clone(), + } + } +} + +trait HealthProbe { + fn probe(&self, port: u16, timeout: Duration) -> EndpointState; +} + +struct TcpHealthProbe; + +impl HealthProbe for TcpHealthProbe { + fn probe(&self, port: u16, timeout: Duration) -> EndpointState { + let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); + let mut stream = match TcpStream::connect_timeout(&address.into(), timeout) { + Ok(stream) => stream, + Err(_) => return EndpointState::Unavailable, + }; + if stream.set_read_timeout(Some(timeout)).is_err() + || stream.set_write_timeout(Some(timeout)).is_err() + { + return EndpointState::Other("a listener accepted the connection but timed out".into()); + } + let request = format!( + "GET /api/health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(request.as_bytes()).is_err() { + return EndpointState::Other( + "a listener accepted the connection but did not accept an HTTP request".into(), + ); + } + + let mut response = Vec::new(); + if stream.take(16 * 1024).read_to_end(&mut response).is_err() { + return EndpointState::Other( + "a listener accepted the connection but did not return PortDoc health".into(), + ); + } + parse_health_response(&response) + } +} + +#[derive(Deserialize)] +struct HealthPayload { + status: String, + pid: u32, + run_mode: RunMode, +} + +fn parse_health_response(response: &[u8]) -> EndpointState { + let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else { + return EndpointState::Other("the listener returned a malformed HTTP response".into()); + }; + let headers = String::from_utf8_lossy(&response[..header_end]); + if !headers + .lines() + .next() + .is_some_and(|line| line.starts_with("HTTP/1.0 200") || line.starts_with("HTTP/1.1 200")) + { + return EndpointState::Other("the listener did not return PortDoc health".into()); + } + let body = &response[header_end + 4..]; + match serde_json::from_slice::(body) { + Ok(health) if health.status == "ok" && health.pid > 0 => { + EndpointState::PortDoc(health.run_mode, health.pid) + } + _ => EndpointState::Other("the listener returned invalid PortDoc health".into()), + } +} + +fn status_for( + manager_state: ManagerState, + port: Option, + endpoint: EndpointState, +) -> BackgroundStatus { + let (status, detail) = match (&endpoint, manager_state) { + (EndpointState::Unavailable, ManagerState::NotConfigured) => (Status::NotConfigured, None), + (EndpointState::Unavailable, ManagerState::Stopped) => ( + Status::Stopped, + port.is_none() + .then(|| "the configured health port is unknown".into()), + ), + (EndpointState::Unavailable, ManagerState::Active | ManagerState::Failed) => ( + Status::Unhealthy, + Some("the native manager entry exists but PortDoc health is unavailable".into()), + ), + (EndpointState::PortDoc(RunMode::Background, _), ManagerState::NotConfigured) => { + (Status::PortConflict, Some(endpoint.detail())) + } + (EndpointState::PortDoc(RunMode::Background, _), _) => (Status::Running, None), + (EndpointState::PortDoc(RunMode::Foreground, _) | EndpointState::Other(_), _) => { + (Status::PortConflict, Some(endpoint.detail())) + } + }; + BackgroundStatus { + status, + manager_state, + port, + detail, + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::fs; + use std::sync::Mutex; + + use super::*; + + struct FakeManager { + states: Mutex>, + calls: Mutex>, + } + + impl FakeManager { + fn new(states: impl IntoIterator) -> Self { + Self { + states: Mutex::new(states.into_iter().collect()), + calls: Mutex::new(Vec::new()), + } + } + + fn calls(&self) -> Vec<&'static str> { + self.calls.lock().expect("calls lock").clone() + } + } + + impl NativeManager for FakeManager { + fn install(&self, _definition: &str) -> Result<(), BackgroundError> { + self.calls.lock().expect("calls lock").push("install"); + Ok(()) + } + + fn start(&self) -> Result<(), BackgroundError> { + self.calls.lock().expect("calls lock").push("start"); + Ok(()) + } + + fn state(&self) -> Result { + self.calls.lock().expect("calls lock").push("state"); + let mut states = self.states.lock().expect("states lock"); + let state = states.front().copied().unwrap_or(ManagerState::Stopped); + if states.len() > 1 { + states.pop_front(); + } + Ok(state) + } + + fn stop(&self) -> Result<(), BackgroundError> { + self.calls.lock().expect("calls lock").push("stop"); + Ok(()) + } + + fn disable(&self) -> Result<(), BackgroundError> { + self.calls.lock().expect("calls lock").push("disable"); + Ok(()) + } + } + + struct FakeProbe { + states: Mutex>, + ports: Mutex>, + } + + impl FakeProbe { + fn new(states: impl IntoIterator) -> Self { + Self { + states: Mutex::new(states.into_iter().collect()), + ports: Mutex::new(Vec::new()), + } + } + + fn ports(&self) -> Vec { + self.ports.lock().expect("ports lock").clone() + } + } + + impl HealthProbe for FakeProbe { + fn probe(&self, port: u16, _timeout: Duration) -> EndpointState { + self.ports.lock().expect("ports lock").push(port); + let mut states = self.states.lock().expect("states lock"); + let state = states + .front() + .cloned() + .unwrap_or(EndpointState::Unavailable); + if states.len() > 1 { + states.pop_front(); + } + state + } + } + + fn temp_base(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "portdoc-lifecycle-test-{tag}-{}", + std::process::id() + )) + } + + fn spec(base: &Path) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from("/opt/PortDoc/portdoc"), + 7799, + Some("/usr/bin".into()), + base.join("portdoc.service"), + ) + .expect("spec") + } + + fn lifecycle<'a>( + manager: &'a dyn NativeManager, + probe: &'a dyn HealthProbe, + spec: &'a BackgroundSpec, + config_path: &'a Path, + ) -> Lifecycle<'a> { + Lifecycle { + manager, + probe, + spec, + config_path, + health_timeout: Duration::ZERO, + poll_interval: Duration::ZERO, + poll_attempts: 3, + } + } + + #[test] + fn status_reports_every_stable_state() { + let cases = [ + ( + ManagerState::NotConfigured, + None, + EndpointState::Unavailable, + Status::NotConfigured, + ), + ( + ManagerState::Stopped, + Some(7799), + EndpointState::Unavailable, + Status::Stopped, + ), + ( + ManagerState::Active, + Some(7799), + EndpointState::PortDoc(RunMode::Background, 42), + Status::Running, + ), + ( + ManagerState::Active, + Some(7799), + EndpointState::Unavailable, + Status::Unhealthy, + ), + ( + ManagerState::Stopped, + Some(7799), + EndpointState::PortDoc(RunMode::Foreground, 43), + Status::PortConflict, + ), + ( + ManagerState::Stopped, + Some(7799), + EndpointState::Other("another server is using the port".into()), + Status::PortConflict, + ), + ]; + + for (manager_state, port, endpoint, expected) in cases { + assert_eq!(status_for(manager_state, port, endpoint).status, expected); + } + } + + #[test] + fn start_persists_the_port_only_after_verified_background_health() { + let base = temp_base("start"); + let config_path = base.join("config.json"); + let spec = spec(&base); + let manager = FakeManager::new([ManagerState::NotConfigured, ManagerState::Stopped]); + let probe = FakeProbe::new([ + EndpointState::Unavailable, + EndpointState::Unavailable, + EndpointState::PortDoc(RunMode::Background, 42), + ]); + + let status = lifecycle(&manager, &probe, &spec, &config_path) + .execute(Action::Start, Some("definition")) + .expect("start"); + + assert_eq!(status.status, Status::Running); + assert_eq!(manager.calls(), vec!["state", "install", "start", "state"]); + assert_eq!(config::load(&config_path).background_port, Some(7799)); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn failed_start_preserves_the_previous_config() { + let base = temp_base("failed-start"); + let config_path = base.join("config.json"); + let mut saved = config::Config::default(); + saved.background_port = Some(7788); + config::save(&config_path, &saved).expect("save prior config"); + let spec = spec(&base); + let manager = FakeManager::new([ManagerState::NotConfigured]); + let probe = FakeProbe::new([EndpointState::Unavailable]); + + let result = lifecycle(&manager, &probe, &spec, &config_path) + .execute(Action::Start, Some("definition")); + + assert!(matches!( + result, + Err(BackgroundError::StartTimeout { port: 7799 }) + )); + assert_eq!(config::load(&config_path), saved); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn changing_ports_waits_for_the_previous_managed_endpoint_to_stop() { + let base = temp_base("changed-port"); + let config_path = base.join("config.json"); + let mut saved = config::Config::default(); + saved.background_port = Some(7788); + config::save(&config_path, &saved).expect("save prior config"); + let spec = spec(&base); + let manager = FakeManager::new([ + ManagerState::Active, + ManagerState::Stopped, + ManagerState::Stopped, + ]); + let probe = FakeProbe::new([ + EndpointState::Unavailable, + EndpointState::Unavailable, + EndpointState::PortDoc(RunMode::Background, 42), + ]); + + lifecycle(&manager, &probe, &spec, &config_path) + .execute(Action::Start, Some("definition")) + .expect("start on changed port"); + + assert_eq!(probe.ports(), vec![7799, 7788, 7799]); + assert_eq!(config::load(&config_path).background_port, Some(7799)); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn start_never_stops_a_foreground_portdoc() { + let base = temp_base("foreground-conflict"); + let spec = spec(&base); + let manager = FakeManager::new([ManagerState::NotConfigured]); + let probe = FakeProbe::new([EndpointState::PortDoc(RunMode::Foreground, 88)]); + + let result = lifecycle(&manager, &probe, &spec, &base.join("config.json")) + .execute(Action::Start, Some("definition")); + + assert!(matches!(result, Err(BackgroundError::PortConflict { .. }))); + assert_eq!(manager.calls(), vec!["state"]); + } + + #[test] + fn start_never_stops_an_unrelated_listener() { + let base = temp_base("listener-conflict"); + let spec = spec(&base); + let manager = FakeManager::new([ManagerState::NotConfigured]); + let probe = FakeProbe::new([EndpointState::Other("not PortDoc".into())]); + + let result = lifecycle(&manager, &probe, &spec, &base.join("config.json")) + .execute(Action::Start, Some("definition")); + + assert!(matches!(result, Err(BackgroundError::PortConflict { .. }))); + assert_eq!(manager.calls(), vec!["state"]); + } + + #[test] + fn stop_addresses_only_a_registered_manager_entry() { + let base = temp_base("safe-stop"); + let config_path = base.join("config.json"); + let mut saved = config::Config::default(); + saved.background_port = Some(7799); + config::save(&config_path, &saved).expect("save config"); + let spec = spec(&base); + let manager = FakeManager::new([ManagerState::NotConfigured]); + let probe = FakeProbe::new([EndpointState::PortDoc(RunMode::Foreground, 91)]); + + let result = lifecycle(&manager, &probe, &spec, &config_path).stop(); + + assert!(matches!(result, Err(BackgroundError::PortConflict { .. }))); + assert_eq!(manager.calls(), vec!["state"]); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn disable_unregisters_the_manager_and_clears_only_the_saved_port() { + let base = temp_base("disable"); + let config_path = base.join("config.json"); + let mut saved = config::Config::default(); + saved.background_port = Some(7799); + saved.editor = "cursor".into(); + saved.ignored_services.push("svc-3000-node".into()); + config::save(&config_path, &saved).expect("save config"); + let spec = spec(&base); + let manager = FakeManager::new([ + ManagerState::Active, + ManagerState::Stopped, + ManagerState::NotConfigured, + ]); + let probe = FakeProbe::new([ + EndpointState::PortDoc(RunMode::Background, 99), + EndpointState::Unavailable, + ]); + + let result = lifecycle(&manager, &probe, &spec, &config_path) + .disable() + .expect("disable"); + + assert_eq!(result.status, Status::NotConfigured); + assert_eq!( + manager.calls(), + vec!["state", "stop", "state", "state", "disable", "state"] + ); + let updated = config::load(&config_path); + assert_eq!(updated.background_port, None); + assert_eq!(updated.editor, "cursor"); + assert_eq!(updated.ignored_services, vec!["svc-3000-node"]); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn health_parser_rejects_wrong_modes_and_malformed_payloads_safely() { + let response = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n{\"status\":\"ok\",\"pid\":42,\"run_mode\":\"background\"}"; + assert_eq!( + parse_health_response(response), + EndpointState::PortDoc(RunMode::Background, 42) + ); + + let malformed = + b"HTTP/1.1 200 OK\r\n\r\n{\"status\":\"ok\",\"pid\":0,\"run_mode\":\"background\"}"; + assert!(matches!( + parse_health_response(malformed), + EndpointState::Other(_) + )); + + let wrong_path = b"HTTP/1.1 404 Not Found\r\n\r\n"; + assert!(matches!( + parse_health_response(wrong_path), + EndpointState::Other(_) + )); + } +} diff --git a/src/background/linux.rs b/src/background/linux.rs new file mode 100644 index 0000000..7758fc0 --- /dev/null +++ b/src/background/linux.rs @@ -0,0 +1,399 @@ +use super::{BackgroundSpec, DefinitionError, definition_text, required_path}; + +pub(crate) fn render(spec: &BackgroundSpec) -> Result { + let executable = definition_text(spec.executable.as_os_str(), "executable")?; + let path = required_path(spec)?; + let executable = quote(executable, true); + let environment = quote(&format!("PATH={path}"), false); + let port = spec.port; + + Ok(format!( + r#"[Unit] +Description=PortDoc local dev server control panel + +[Service] +Type=exec +ExecStart={executable} serve --port {port} --no-open +Environment={environment} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=default.target +"# + )) +} + +fn quote(value: &str, escape_dollars: bool) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + for character in value.chars() { + match character { + '\\' => quoted.push_str("\\\\"), + '"' => quoted.push_str("\\\""), + '%' => quoted.push_str("%%"), + '$' if escape_dollars => quoted.push_str("$$"), + _ => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +#[cfg(target_os = "linux")] +pub(super) mod management { + use std::path::PathBuf; + use std::time::Duration; + + use super::super::command::{CommandOutput, CommandRequest, CommandRunner}; + use super::super::{BackgroundError, ManagerState}; + use crate::atomic_file; + + const UNIT_NAME: &str = "portdoc.service"; + + pub(crate) struct Manager<'a> { + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + timeout: Duration, + } + + impl<'a> Manager<'a> { + pub fn new( + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + timeout: Duration, + ) -> Self { + Self { + runner, + definition_path, + timeout, + } + } + + pub fn install(&self, definition: &str) -> Result<(), BackgroundError> { + atomic_file::replace(&self.definition_path, definition.as_bytes()).map_err( + |source| BackgroundError::File { + action: "write definition", + path: self.definition_path.clone(), + source, + }, + )?; + self.run_checked(&["daemon-reload"])?; + self.run_checked(&["enable", UNIT_NAME])?; + Ok(()) + } + + pub fn start(&self) -> Result<(), BackgroundError> { + self.run_checked(&["start", UNIT_NAME])?; + Ok(()) + } + + pub fn state(&self) -> Result { + if !self.definition_path.is_file() { + return Ok(ManagerState::NotConfigured); + } + let output = self.run(&["is-active", UNIT_NAME])?; + match output.stdout.trim() { + "active" | "activating" | "reloading" | "refreshing" => Ok(ManagerState::Active), + "inactive" | "deactivating" => Ok(ManagerState::Stopped), + "failed" | "maintenance" => Ok(ManagerState::Failed), + "unknown" => Ok(ManagerState::NotConfigured), + status if output.success => Err(BackgroundError::UnexpectedStatus { + manager: "systemd", + status: status.to_owned(), + }), + _ => Err(command_failure(&["is-active", UNIT_NAME], output)), + } + } + + pub fn stop(&self) -> Result<(), BackgroundError> { + self.run_checked(&["stop", UNIT_NAME])?; + Ok(()) + } + + pub fn disable(&self) -> Result<(), BackgroundError> { + self.run_checked(&["disable", UNIT_NAME])?; + atomic_file::remove(&self.definition_path).map_err(|source| BackgroundError::File { + action: "remove definition", + path: self.definition_path.clone(), + source, + })?; + self.run_checked(&["daemon-reload"])?; + Ok(()) + } + + fn run(&self, args: &[&str]) -> Result { + let request = CommandRequest::new( + "systemctl", + std::iter::once("--user").chain(args.iter().copied()), + self.timeout, + ); + Ok(self.runner.run(&request)?) + } + + fn run_checked(&self, args: &[&str]) -> Result { + let output = self.run(args)?; + if output.success { + Ok(output) + } else { + Err(command_failure(args, output)) + } + } + } + + fn command_failure(args: &[&str], output: CommandOutput) -> BackgroundError { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + BackgroundError::CommandFailed { + program: format!("systemctl --user {}", args.join(" ")), + code: output + .code + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".into()), + stderr: if detail.is_empty() { + "no error output".into() + } else { + detail.to_owned() + }, + } + } + + #[cfg(test)] + mod tests { + use std::collections::VecDeque; + use std::ffi::OsString; + use std::fs; + use std::path::PathBuf; + use std::sync::Mutex; + + use super::super::super::BackgroundSpec; + use super::super::super::command::CommandError; + use super::super::render; + use super::*; + + #[derive(Default)] + struct FakeRunner { + calls: Mutex>, + outputs: Mutex>>, + } + + impl FakeRunner { + fn with_outputs(outputs: Vec>) -> Self { + Self { + calls: Mutex::new(Vec::new()), + outputs: Mutex::new(outputs.into()), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("calls lock").clone() + } + } + + impl CommandRunner for FakeRunner { + fn run(&self, request: &CommandRequest) -> Result { + self.calls.lock().expect("calls lock").push(request.clone()); + self.outputs + .lock() + .expect("outputs lock") + .pop_front() + .unwrap_or_else(|| Ok(output(true, Some(0), "", ""))) + } + } + + fn output(success: bool, code: Option, stdout: &str, stderr: &str) -> CommandOutput { + CommandOutput { + success, + code, + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + fn temp_base(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("portdoc-systemd-test-{tag}-{}", std::process::id())) + } + + fn request(args: &[&str]) -> CommandRequest { + CommandRequest::new( + "systemctl", + std::iter::once("--user").chain(args.iter().copied()), + Duration::from_secs(2), + ) + } + + fn spec(path: PathBuf) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from("/home/dev/.cargo/bin/portdoc"), + 7788, + Some(OsString::from("/usr/local/bin:/usr/bin:/bin")), + path, + ) + .expect("valid spec") + } + + #[test] + fn install_atomically_replaces_the_unit_then_enables_it() { + let base = temp_base("install"); + let path = base.join("portdoc.service"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "old unit").expect("write old unit"); + let spec = spec(path.clone()); + let definition = render(&spec).expect("render unit"); + let runner = FakeRunner::default(); + let manager = Manager::new(&runner, path.clone(), Duration::from_secs(2)); + + manager.install(&definition).expect("install unit"); + + assert_eq!(fs::read_to_string(&path).expect("read unit"), definition); + assert_eq!( + runner.calls(), + vec![request(&["daemon-reload"]), request(&["enable", UNIT_NAME])] + ); + assert_eq!(fs::read_dir(&base).expect("read directory").count(), 1); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn stop_and_disable_remain_separate_operations() { + let base = temp_base("lifecycle"); + let path = base.join("portdoc.service"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "unit").expect("write unit"); + let runner = FakeRunner::default(); + let manager = Manager::new(&runner, path.clone(), Duration::from_secs(2)); + + manager.start().expect("start unit"); + manager.stop().expect("stop unit"); + assert!(path.exists(), "stop must keep sign-in startup configured"); + manager.disable().expect("disable unit"); + + assert!(!path.exists(), "disable must remove the unit"); + assert_eq!( + runner.calls(), + vec![ + request(&["start", UNIT_NAME]), + request(&["stop", UNIT_NAME]), + request(&["disable", UNIT_NAME]), + request(&["daemon-reload"]), + ] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn status_maps_stable_systemd_states() { + let base = temp_base("status"); + let path = base.join("portdoc.service"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "unit").expect("write unit"); + let runner = FakeRunner::with_outputs(vec![ + Ok(output(true, Some(0), "active\n", "")), + Ok(output(false, Some(3), "inactive\n", "")), + Ok(output(false, Some(3), "failed\n", "")), + ]); + let manager = Manager::new(&runner, path.clone(), Duration::from_secs(2)); + + assert_eq!(manager.state().expect("active state"), ManagerState::Active); + assert_eq!( + manager.state().expect("stopped state"), + ManagerState::Stopped + ); + assert_eq!(manager.state().expect("failed state"), ManagerState::Failed); + + fs::remove_file(&path).expect("remove unit"); + assert_eq!( + manager.state().expect("missing state"), + ManagerState::NotConfigured + ); + assert_eq!( + runner.calls(), + vec![ + request(&["is-active", UNIT_NAME]), + request(&["is-active", UNIT_NAME]), + request(&["is-active", UNIT_NAME]), + ] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn command_failure_stops_the_sequence_and_preserves_the_definition() { + let base = temp_base("failure"); + let path = base.join("portdoc.service"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "unit").expect("write unit"); + let runner = FakeRunner::with_outputs(vec![Ok(output( + false, + Some(1), + "", + "Failed to connect to bus", + ))]); + let manager = Manager::new(&runner, path.clone(), Duration::from_secs(2)); + + let error = manager.disable().expect_err("disable must fail"); + + assert!(matches!( + error, + BackgroundError::CommandFailed { + code, + stderr, + .. + } if code == "1" && stderr == "Failed to connect to bus" + )); + assert!(path.exists(), "failed disable must preserve the unit"); + assert_eq!(runner.calls(), vec![request(&["disable", UNIT_NAME])]); + fs::remove_dir_all(base).expect("clean test directory"); + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::path::PathBuf; + + use super::*; + + fn spec(executable: &str, path: Option<&str>) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from(executable), + 7799, + path.map(OsString::from), + PathBuf::from("/home/dev/.config/systemd/user/portdoc.service"), + ) + .expect("non-zero port") + } + + #[test] + fn renders_direct_managed_command_and_captured_path() { + let unit = render(&spec( + r#"/home/Dev Tools/port"doc%25$\résumé"#, + Some(r#"/usr/bin:/opt/Dev Tools/%bin\tools"#), + )) + .expect("unit renders"); + + assert!(unit.contains( + r#"ExecStart="/home/Dev Tools/port\"doc%%25$$\\résumé" serve --port 7799 --no-open"# + )); + assert!(unit.contains(r#"Environment="PATH=/usr/bin:/opt/Dev Tools/%%bin\\tools""#)); + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=default.target")); + assert!(!unit.contains("/bin/sh")); + } + + #[test] + fn rejects_a_missing_or_empty_path() { + assert_eq!( + render(&spec("/usr/local/bin/portdoc", None)), + Err(DefinitionError::MissingValue { field: "PATH" }) + ); + assert_eq!( + render(&spec("/usr/local/bin/portdoc", Some(""))), + Err(DefinitionError::MissingValue { field: "PATH" }) + ); + } +} diff --git a/src/background/macos.rs b/src/background/macos.rs new file mode 100644 index 0000000..6ec57b8 --- /dev/null +++ b/src/background/macos.rs @@ -0,0 +1,461 @@ +#[cfg(any(test, target_os = "macos"))] +use super::{BackgroundSpec, DefinitionError, definition_text, required_path, xml_escape}; + +#[cfg(any(test, target_os = "macos"))] +const LABEL: &str = "com.traversymedia.portdoc"; + +#[cfg(any(test, target_os = "macos"))] +pub(crate) fn render(spec: &BackgroundSpec) -> Result { + let executable = xml_escape(definition_text(spec.executable.as_os_str(), "executable")?); + let path = xml_escape(required_path(spec)?); + let port = spec.port; + + Ok(format!( + r#" + + + + Label + {LABEL} + ProgramArguments + + {executable} + serve + --port + {port} + --no-open + + EnvironmentVariables + + PATH + {path} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 10 + + +"# + )) +} + +#[cfg(any(target_os = "macos", all(test, unix)))] +pub(super) mod management { + use std::ffi::OsString; + use std::path::PathBuf; + use std::time::Duration; + + use super::super::command::{CommandOutput, CommandRequest, CommandRunner}; + use super::super::{BackgroundError, ManagerState}; + use super::LABEL; + use crate::atomic_file; + + pub(crate) struct Manager<'a> { + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + domain: OsString, + service_target: OsString, + timeout: Duration, + } + + impl<'a> Manager<'a> { + #[cfg(target_os = "macos")] + pub fn current( + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + timeout: Duration, + ) -> Self { + let uid = unsafe { libc::geteuid() }; + Self::new(runner, definition_path, uid, timeout) + } + + fn new( + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + uid: u32, + timeout: Duration, + ) -> Self { + Self { + runner, + definition_path, + domain: format!("gui/{uid}").into(), + service_target: format!("gui/{uid}/{LABEL}").into(), + timeout, + } + } + + pub fn install(&self, definition: &str) -> Result<(), BackgroundError> { + atomic_file::replace(&self.definition_path, definition.as_bytes()).map_err( + |source| BackgroundError::File { + action: "write definition", + path: self.definition_path.clone(), + source, + }, + )?; + self.run_checked(vec![ + "bootstrap".into(), + self.domain.clone(), + self.definition_path.as_os_str().to_owned(), + ])?; + Ok(()) + } + + pub fn start(&self) -> Result<(), BackgroundError> { + self.run_checked(self.service_args("kickstart"))?; + Ok(()) + } + + pub fn state(&self) -> Result { + if !self.definition_path.is_file() { + return Ok(ManagerState::NotConfigured); + } + let output = self.run(self.service_args("print"))?; + if !output.success { + return Ok(ManagerState::Stopped); + } + match printed_state(&output.stdout) { + Some("running" | "spawn scheduled") => Ok(ManagerState::Active), + Some("waiting" | "exited" | "stopped") | None => Ok(ManagerState::Active), + Some(status) => Err(BackgroundError::UnexpectedStatus { + manager: "launchd", + status: status.to_owned(), + }), + } + } + + pub fn stop(&self) -> Result<(), BackgroundError> { + let args = self.service_args("bootout"); + let output = self.run(args.clone())?; + if output.success || self.state()? == ManagerState::Stopped { + Ok(()) + } else { + Err(command_failure(&args, output)) + } + } + + pub fn disable(&self) -> Result<(), BackgroundError> { + atomic_file::remove(&self.definition_path).map_err(|source| BackgroundError::File { + action: "remove definition", + path: self.definition_path.clone(), + source, + }) + } + + fn service_args(&self, command: &str) -> Vec { + vec![command.into(), self.service_target.clone()] + } + + fn run(&self, args: Vec) -> Result { + let request = CommandRequest::new("launchctl", args, self.timeout); + Ok(self.runner.run(&request)?) + } + + fn run_checked(&self, args: Vec) -> Result { + let output = self.run(args.clone())?; + if output.success { + Ok(output) + } else { + Err(command_failure(&args, output)) + } + } + } + + fn printed_state(output: &str) -> Option<&str> { + output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix("state = ")) + } + + fn command_failure(args: &[OsString], output: CommandOutput) -> BackgroundError { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + let args = args + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "); + BackgroundError::CommandFailed { + program: format!("launchctl {args}"), + code: output + .code + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".into()), + stderr: if detail.is_empty() { + "no error output".into() + } else { + detail.to_owned() + }, + } + } + + #[cfg(test)] + mod tests { + use std::collections::VecDeque; + use std::ffi::OsString; + use std::fs; + use std::path::PathBuf; + use std::sync::Mutex; + + use super::super::super::BackgroundSpec; + use super::super::super::command::CommandError; + use super::super::render; + use super::*; + + const UID: u32 = 501; + const DOMAIN: &str = "gui/501"; + const TARGET: &str = "gui/501/com.traversymedia.portdoc"; + + #[derive(Default)] + struct FakeRunner { + calls: Mutex>, + outputs: Mutex>>, + } + + impl FakeRunner { + fn with_outputs(outputs: Vec>) -> Self { + Self { + calls: Mutex::new(Vec::new()), + outputs: Mutex::new(outputs.into()), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("calls lock").clone() + } + } + + impl CommandRunner for FakeRunner { + fn run(&self, request: &CommandRequest) -> Result { + self.calls.lock().expect("calls lock").push(request.clone()); + self.outputs + .lock() + .expect("outputs lock") + .pop_front() + .unwrap_or_else(|| Ok(output(true, Some(0), "", ""))) + } + } + + fn output(success: bool, code: Option, stdout: &str, stderr: &str) -> CommandOutput { + CommandOutput { + success, + code, + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + fn temp_base(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("portdoc-launchd-test-{tag}-{}", std::process::id())) + } + + fn manager<'a>(runner: &'a FakeRunner, path: PathBuf) -> Manager<'a> { + Manager::new(runner, path, UID, Duration::from_secs(2)) + } + + fn request(args: Vec) -> CommandRequest { + CommandRequest::new("launchctl", args, Duration::from_secs(2)) + } + + fn spec(path: PathBuf) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from("/Applications/PortDoc/portdoc"), + 7788, + Some(OsString::from("/usr/local/bin:/usr/bin:/bin")), + path, + ) + .expect("valid spec") + } + + #[test] + fn install_atomically_replaces_the_plist_and_bootstraps_the_gui_domain() { + let base = temp_base("install"); + let path = base + .join("Launch Agents") + .join("com.traversymedia.portdoc.plist"); + fs::create_dir_all(path.parent().expect("definition parent")) + .expect("create test directory"); + fs::write(&path, "old plist").expect("write old plist"); + let definition = render(&spec(path.clone())).expect("render plist"); + let runner = FakeRunner::default(); + let manager = manager(&runner, path.clone()); + + manager.install(&definition).expect("install agent"); + + assert_eq!(fs::read_to_string(&path).expect("read plist"), definition); + assert_eq!( + runner.calls(), + vec![request(vec![ + "bootstrap".into(), + DOMAIN.into(), + path.as_os_str().to_owned(), + ])] + ); + assert_eq!( + fs::read_dir(path.parent().expect("definition parent")) + .expect("read directory") + .count(), + 1 + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn stop_keeps_the_plist_while_disable_removes_it() { + let base = temp_base("lifecycle"); + let path = base.join("com.traversymedia.portdoc.plist"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "plist").expect("write plist"); + let runner = FakeRunner::default(); + let manager = manager(&runner, path.clone()); + + manager.start().expect("start agent"); + manager.stop().expect("stop agent"); + assert!(path.exists(), "stop must preserve sign-in startup"); + manager.disable().expect("disable agent"); + + assert!(!path.exists(), "disable must remove the plist"); + assert_eq!( + runner.calls(), + vec![ + request(vec!["kickstart".into(), TARGET.into()]), + request(vec!["bootout".into(), TARGET.into()]), + ] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn status_maps_registered_and_unregistered_agents() { + let base = temp_base("status"); + let path = base.join("com.traversymedia.portdoc.plist"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "plist").expect("write plist"); + let runner = FakeRunner::with_outputs(vec![ + Ok(output(true, Some(0), "state = running\n", "")), + Ok(output(true, Some(0), "state = exited\n", "")), + Ok(output(false, Some(113), "", "Could not find service")), + ]); + let manager = manager(&runner, path.clone()); + + assert_eq!( + manager.state().expect("running state"), + ManagerState::Active + ); + assert_eq!(manager.state().expect("exited state"), ManagerState::Active); + assert_eq!( + manager.state().expect("unregistered state"), + ManagerState::Stopped + ); + + fs::remove_file(&path).expect("remove plist"); + assert_eq!( + manager.state().expect("missing state"), + ManagerState::NotConfigured + ); + assert_eq!( + runner.calls(), + vec![ + request(vec!["print".into(), TARGET.into()]), + request(vec!["print".into(), TARGET.into()]), + request(vec!["print".into(), TARGET.into()]), + ] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn bootstrap_failure_surfaces_stderr_and_keeps_the_definition() { + let base = temp_base("failure"); + let path = base.join("com.traversymedia.portdoc.plist"); + let runner = + FakeRunner::with_outputs(vec![Ok(output(false, Some(5), "", "Bootstrap failed"))]); + let manager = manager(&runner, path.clone()); + + let error = manager.install("").expect_err("install must fail"); + + assert!(matches!( + error, + BackgroundError::CommandFailed { + code, + stderr, + .. + } if code == "5" && stderr == "Bootstrap failed" + )); + assert_eq!( + fs::read_to_string(&path).expect("definition remains"), + "" + ); + assert_eq!( + runner.calls(), + vec![request(vec![ + "bootstrap".into(), + DOMAIN.into(), + path.as_os_str().to_owned(), + ])] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::path::PathBuf; + + use super::*; + + fn spec(executable: &str, path: Option<&str>) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from(executable), + 7799, + path.map(OsString::from), + PathBuf::from("/Users/dev/Library/LaunchAgents/com.traversymedia.portdoc.plist"), + ) + .expect("non-zero port") + } + + #[test] + fn renders_tokenized_arguments_and_escaped_xml() { + let plist = render(&spec( + r#"/Applications/Port & "Doc" <β>/portdoc"#, + Some(r#"/opt/A&B:/Users/"Dev" /bin"#), + )) + .expect("plist renders"); + + assert!(plist.contains( + "/Applications/Port & "Doc" <β>/portdoc" + )); + assert!( + plist + .contains("/opt/A&B:/Users/"Dev" <tools>/bin") + ); + assert!(plist.contains("serve")); + assert!(plist.contains("7799")); + assert!(plist.contains("RunAtLoad")); + assert!(plist.contains("SuccessfulExit")); + assert!(!plist.contains("/bin/sh")); + } + + #[test] + fn rejects_a_missing_or_empty_path() { + assert_eq!( + render(&spec("/Applications/PortDoc/portdoc", None)), + Err(DefinitionError::MissingValue { field: "PATH" }) + ); + assert_eq!( + render(&spec("/Applications/PortDoc/portdoc", Some(""))), + Err(DefinitionError::MissingValue { field: "PATH" }) + ); + } +} diff --git a/src/background/mod.rs b/src/background/mod.rs new file mode 100644 index 0000000..35b4104 --- /dev/null +++ b/src/background/mod.rs @@ -0,0 +1,272 @@ +mod command; +mod lifecycle; +mod linux; +mod macos; +mod windows; + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BackgroundSpec { + pub executable: PathBuf, + pub port: u16, + pub path_env: Option, + pub definition_path: PathBuf, +} + +impl BackgroundSpec { + pub fn new( + executable: PathBuf, + port: u16, + path_env: Option, + definition_path: PathBuf, + ) -> Result { + if port == 0 { + return Err(DefinitionError::InvalidPort); + } + Ok(Self { + executable, + port, + path_env, + definition_path, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ManagerState { + NotConfigured, + Stopped, + Active, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Action { + Start, + Stop, + Status, + Disable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Status { + NotConfigured, + Stopped, + Running, + Unhealthy, + Unsupported, + PortConflict, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BackgroundStatus { + pub status: Status, + pub manager_state: ManagerState, + pub port: Option, + pub detail: Option, +} + +impl BackgroundStatus { + pub fn is_running(&self) -> bool { + self.status == Status::Running + } +} + +impl std::fmt::Display for BackgroundStatus { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let label = match self.status { + Status::NotConfigured => "not configured", + Status::Stopped => "stopped", + Status::Running => "running", + Status::Unhealthy => "unhealthy", + Status::Unsupported => "unsupported", + Status::PortConflict => "port conflict", + }; + write!(formatter, "PortDoc background service: {label}")?; + if let Some(port) = self.port { + write!(formatter, " on http://127.0.0.1:{port}")?; + } + if let Some(detail) = &self.detail { + write!(formatter, "\n{detail}")?; + } + Ok(()) + } +} + +pub(crate) use lifecycle::run; + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum DefinitionError { + #[error("background mode requires a fixed non-zero port")] + InvalidPort, + #[error("background definition requires {field}")] + MissingValue { field: &'static str }, + #[error("{field} is not valid Unicode")] + NonUnicode { field: &'static str }, + #[error("{field} contains a character that cannot be stored safely")] + InvalidCharacter { field: &'static str }, +} + +#[derive(Debug, Error)] +pub(crate) enum BackgroundError { + #[error("background mode is not supported: {0}")] + Unsupported(String), + #[error(transparent)] + Definition(#[from] DefinitionError), + #[error(transparent)] + Command(#[from] command::CommandError), + #[error("{program} exited with {code}: {stderr}")] + CommandFailed { + program: String, + code: String, + stderr: String, + }, + #[error("{manager} returned an unexpected status: {status}")] + UnexpectedStatus { + manager: &'static str, + status: String, + }, + #[error("could not {action} {path}: {source}")] + File { + action: &'static str, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("could not determine the PortDoc configuration directory")] + ConfigDirectory, + #[error("could not determine the current PortDoc executable: {0}")] + CurrentExecutable(#[source] io::Error), + #[error("could not save background configuration to {path}: {source}")] + SaveConfig { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("port {port} is already in use: {detail}")] + PortConflict { port: u16, detail: String }, + #[error("PortDoc did not become healthy in background mode on port {port} in time")] + StartTimeout { port: u16 }, + #[error("the PortDoc background service did not stop in time")] + StopTimeout, + #[error("the PortDoc background service was not removed in time")] + DisableTimeout, +} + +fn definition_text<'a>(value: &'a OsStr, field: &'static str) -> Result<&'a str, DefinitionError> { + let value = value + .to_str() + .ok_or(DefinitionError::NonUnicode { field })?; + if value.is_empty() { + return Err(DefinitionError::MissingValue { field }); + } + if value.chars().any(invalid_definition_character) { + return Err(DefinitionError::InvalidCharacter { field }); + } + Ok(value) +} + +fn required_path(spec: &BackgroundSpec) -> Result<&str, DefinitionError> { + let path = spec + .path_env + .as_deref() + .ok_or(DefinitionError::MissingValue { field: "PATH" })?; + definition_text(path, "PATH") +} + +fn invalid_definition_character(character: char) -> bool { + let code = character as u32; + character.is_control() || code & 0xffff == 0xfffe || code & 0xffff == 0xffff +} + +#[cfg(any(test, target_os = "macos", target_os = "windows"))] +fn xml_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn background_spec_requires_a_fixed_port() { + let result = BackgroundSpec::new( + PathBuf::from("/usr/bin/portdoc"), + 0, + Some(OsString::from("/usr/bin")), + PathBuf::from("/tmp/portdoc.service"), + ); + + assert_eq!(result, Err(DefinitionError::InvalidPort)); + } + + #[test] + fn every_renderer_rejects_control_characters() { + let spec = BackgroundSpec::new( + PathBuf::from("/opt/port\ndoc"), + 7788, + Some(OsString::from("/usr/bin")), + PathBuf::from("/tmp/portdoc"), + ) + .expect("non-zero port"); + let renderers = [ + linux::render as fn(&BackgroundSpec) -> Result, + macos::render, + windows::render, + ]; + + for render in renderers { + assert_eq!( + render(&spec), + Err(DefinitionError::InvalidCharacter { + field: "executable" + }) + ); + } + } + + #[cfg(unix)] + #[test] + fn every_renderer_rejects_non_unicode_paths() { + use std::os::unix::ffi::OsStringExt; + + let spec = BackgroundSpec::new( + PathBuf::from(OsString::from_vec(vec![b'/', 0xff])), + 7788, + Some(OsString::from("/usr/bin")), + PathBuf::from("/tmp/portdoc"), + ) + .expect("non-zero port"); + let renderers = [ + linux::render as fn(&BackgroundSpec) -> Result, + macos::render, + windows::render, + ]; + + for render in renderers { + assert_eq!( + render(&spec), + Err(DefinitionError::NonUnicode { + field: "executable" + }) + ); + } + } +} diff --git a/src/background/windows.rs b/src/background/windows.rs new file mode 100644 index 0000000..1a38b3b --- /dev/null +++ b/src/background/windows.rs @@ -0,0 +1,417 @@ +#[cfg(any(test, target_os = "windows"))] +use super::{BackgroundSpec, DefinitionError, definition_text, xml_escape}; + +#[cfg(any(test, target_os = "windows"))] +pub(crate) fn render(spec: &BackgroundSpec) -> Result { + let executable = xml_escape(definition_text(spec.executable.as_os_str(), "executable")?); + let port = spec.port; + + Ok(format!( + r#" + + + PortDoc local dev server control panel + + + + true + + + + + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + true + true + PT0S + + PT1M + 3 + + + + + {executable} + serve --port {port} --no-open + + + +"# + )) +} + +#[cfg(any(target_os = "windows", test))] +pub(super) mod management { + use std::ffi::OsString; + use std::path::PathBuf; + use std::time::Duration; + + use super::super::command::{CommandOutput, CommandRequest, CommandRunner}; + use super::super::{BackgroundError, ManagerState}; + use crate::atomic_file; + + const TASK_NAME: &str = "PortDoc"; + + pub(crate) struct Manager<'a> { + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + timeout: Duration, + } + + impl<'a> Manager<'a> { + pub fn new( + runner: &'a dyn CommandRunner, + definition_path: PathBuf, + timeout: Duration, + ) -> Self { + Self { + runner, + definition_path, + timeout, + } + } + + pub fn install(&self, definition: &str) -> Result<(), BackgroundError> { + atomic_file::replace(&self.definition_path, definition.as_bytes()).map_err( + |source| BackgroundError::File { + action: "write definition", + path: self.definition_path.clone(), + source, + }, + )?; + self.run_checked(vec![ + "/Create".into(), + "/TN".into(), + TASK_NAME.into(), + "/XML".into(), + self.definition_path.as_os_str().to_owned(), + "/F".into(), + ])?; + Ok(()) + } + + pub fn start(&self) -> Result<(), BackgroundError> { + self.run_checked(task_args("/Run"))?; + Ok(()) + } + + pub fn state(&self) -> Result { + let output = self.run({ + let mut args = task_args("/Query"); + args.push("/XML".into()); + args + })?; + if output.success { + Ok(ManagerState::Stopped) + } else { + Ok(ManagerState::NotConfigured) + } + } + + pub fn stop(&self) -> Result<(), BackgroundError> { + self.run_checked(task_args("/End"))?; + Ok(()) + } + + pub fn disable(&self) -> Result<(), BackgroundError> { + let mut args = task_args("/Delete"); + args.push("/F".into()); + self.run_checked(args)?; + atomic_file::remove(&self.definition_path).map_err(|source| BackgroundError::File { + action: "remove definition", + path: self.definition_path.clone(), + source, + }) + } + + fn run(&self, args: Vec) -> Result { + let request = CommandRequest::new("schtasks", args, self.timeout); + Ok(self.runner.run(&request)?) + } + + fn run_checked(&self, args: Vec) -> Result { + let output = self.run(args.clone())?; + if output.success { + Ok(output) + } else { + Err(command_failure(&args, output)) + } + } + } + + fn task_args(command: &str) -> Vec { + vec![command.into(), "/TN".into(), TASK_NAME.into()] + } + + fn command_failure(args: &[OsString], output: CommandOutput) -> BackgroundError { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + let args = args + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "); + BackgroundError::CommandFailed { + program: format!("schtasks {args}"), + code: output + .code + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".into()), + stderr: if detail.is_empty() { + "no error output".into() + } else { + detail.to_owned() + }, + } + } + + #[cfg(test)] + mod tests { + use std::collections::VecDeque; + use std::ffi::OsString; + use std::fs; + use std::path::PathBuf; + use std::sync::Mutex; + + use super::super::super::command::CommandError; + use super::*; + + #[derive(Default)] + struct FakeRunner { + calls: Mutex>, + outputs: Mutex>>, + } + + impl FakeRunner { + fn with_outputs(outputs: Vec>) -> Self { + Self { + calls: Mutex::new(Vec::new()), + outputs: Mutex::new(outputs.into()), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("calls lock").clone() + } + } + + impl CommandRunner for FakeRunner { + fn run(&self, request: &CommandRequest) -> Result { + self.calls.lock().expect("calls lock").push(request.clone()); + self.outputs + .lock() + .expect("outputs lock") + .pop_front() + .unwrap_or_else(|| Ok(output(true, Some(0), "", ""))) + } + } + + fn output(success: bool, code: Option, stdout: &str, stderr: &str) -> CommandOutput { + CommandOutput { + success, + code, + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + fn temp_base(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "portdoc-schtasks-test-{tag}-{}", + std::process::id() + )) + } + + fn manager<'a>(runner: &'a FakeRunner, path: PathBuf) -> Manager<'a> { + Manager::new(runner, path, Duration::from_secs(2)) + } + + fn request(args: Vec) -> CommandRequest { + CommandRequest::new("schtasks", args, Duration::from_secs(2)) + } + + #[test] + fn install_atomically_replaces_xml_and_registers_only_portdoc() { + let base = temp_base("install"); + let path = base.join("Task Files").join("PortDoc.xml"); + fs::create_dir_all(path.parent().expect("definition parent")) + .expect("create test directory"); + fs::write(&path, "old task").expect("write old task"); + let runner = FakeRunner::default(); + let manager = manager(&runner, path.clone()); + + manager.install("").expect("install task"); + + assert_eq!(fs::read_to_string(&path).expect("read task"), ""); + assert_eq!( + runner.calls(), + vec![request(vec![ + "/Create".into(), + "/TN".into(), + TASK_NAME.into(), + "/XML".into(), + path.as_os_str().to_owned(), + "/F".into(), + ])] + ); + assert_eq!( + fs::read_dir(path.parent().expect("definition parent")) + .expect("read directory") + .count(), + 1 + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn stop_keeps_the_xml_while_disable_deletes_only_portdoc() { + let base = temp_base("lifecycle"); + let path = base.join("PortDoc.xml"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "task").expect("write task"); + let runner = FakeRunner::default(); + let manager = manager(&runner, path.clone()); + + manager.start().expect("start task"); + manager.stop().expect("stop task"); + assert!(path.exists(), "stop must preserve sign-in startup"); + manager.disable().expect("disable task"); + + assert!(!path.exists(), "disable must remove the task XML"); + assert_eq!( + runner.calls(), + vec![ + request(vec!["/Run".into(), "/TN".into(), TASK_NAME.into()]), + request(vec!["/End".into(), "/TN".into(), TASK_NAME.into()]), + request(vec![ + "/Delete".into(), + "/TN".into(), + TASK_NAME.into(), + "/F".into(), + ]), + ] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + + #[test] + fn status_uses_query_exit_status_without_parsing_localized_output() { + let runner = FakeRunner::with_outputs(vec![ + Ok(output(true, Some(0), "localized task XML", "")), + Ok(output(false, Some(1), "", "localized not found")), + ]); + let manager = manager(&runner, temp_base("status").join("PortDoc.xml")); + + assert_eq!( + manager.state().expect("registered state"), + ManagerState::Stopped + ); + assert_eq!( + manager.state().expect("missing state"), + ManagerState::NotConfigured + ); + assert_eq!( + runner.calls(), + vec![ + request(vec![ + "/Query".into(), + "/TN".into(), + TASK_NAME.into(), + "/XML".into(), + ]), + request(vec![ + "/Query".into(), + "/TN".into(), + TASK_NAME.into(), + "/XML".into(), + ]), + ] + ); + } + + #[test] + fn failed_delete_preserves_the_definition_and_surfaces_stderr() { + let base = temp_base("failure"); + let path = base.join("PortDoc.xml"); + fs::create_dir_all(&base).expect("create test directory"); + fs::write(&path, "task").expect("write task"); + let runner = + FakeRunner::with_outputs(vec![Ok(output(false, Some(1), "", "Access denied"))]); + let manager = manager(&runner, path.clone()); + + let error = manager.disable().expect_err("disable must fail"); + + assert!(matches!( + error, + BackgroundError::CommandFailed { + code, + stderr, + .. + } if code == "1" && stderr == "Access denied" + )); + assert!(path.exists(), "failed disable must preserve the XML"); + assert_eq!( + runner.calls(), + vec![request(vec![ + "/Delete".into(), + "/TN".into(), + TASK_NAME.into(), + "/F".into(), + ])] + ); + fs::remove_dir_all(base).expect("clean test directory"); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn spec(executable: &str) -> BackgroundSpec { + BackgroundSpec::new( + PathBuf::from(executable), + 7799, + None, + PathBuf::from(r"C:\Users\Dev\AppData\Local\PortDoc\task.xml"), + ) + .expect("non-zero port") + } + + #[test] + fn renders_a_limited_current_user_task_without_a_shell() { + let task = render(&spec(r#"C:\Program Files\PortDoc & Tools\port"doc-β.exe"#)) + .expect("task renders"); + + assert!(task.contains( + r#"C:\Program Files\PortDoc & Tools\port"doc-β.exe"# + )); + assert!(task.contains("serve --port 7799 --no-open")); + assert!(task.contains("InteractiveToken")); + assert!(task.contains("LeastPrivilege")); + assert!(task.contains("IgnoreNew")); + assert!(task.contains("PT1M")); + assert!(!task.contains("Environment")); + assert!(!task.contains("cmd.exe")); + assert!(!task.contains("powershell")); + } + + #[test] + fn does_not_require_a_path_environment() { + assert!(render(&spec(r"C:\PortDoc\portdoc.exe")).is_ok()); + } +} diff --git a/src/config.rs b/src/config.rs index 4cb9277..2068815 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub ignored_services: Vec, #[serde(default = "default_editor")] pub editor: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_port: Option, } impl Default for Config { @@ -21,6 +23,7 @@ impl Default for Config { Self { ignored_services: Vec::new(), editor: default_editor(), + background_port: None, } } } @@ -75,14 +78,8 @@ pub fn load(path: &Path) -> Config { /// Write-then-rename so a crash mid-save never leaves a truncated config. pub fn save(path: &Path, config: &Config) -> io::Result<()> { - let dir = path - .parent() - .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; - fs::create_dir_all(dir)?; let body = serde_json::to_string_pretty(config).map_err(io::Error::other)?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, body)?; - fs::rename(&tmp, path) + crate::atomic_file::replace(path, body.as_bytes()) } #[cfg(test)] @@ -120,6 +117,7 @@ mod tests { vec!["svc-1"], "unknown keys are tolerated" ); + assert_eq!(load(&path).background_port, None); std::fs::remove_dir_all(&base).expect("cleanup"); } @@ -131,9 +129,14 @@ mod tests { let mut config = Config::default(); config.set_ignored("svc-3000-node", true); + config.background_port = Some(7799); save(&path, &config).expect("save"); assert_eq!(load(&path), config); + config.background_port = Some(7800); + save(&path, &config).expect("replace"); + assert_eq!(load(&path), config); + std::fs::remove_dir_all(&base).expect("cleanup"); } @@ -149,6 +152,7 @@ mod tests { let cfg = load(&path); assert_eq!(cfg.editor, "code", "missing key gets the default"); assert_eq!(cfg.ignored_services, vec!["svc-1"]); + assert_eq!(cfg.background_port, None); std::fs::write(&path, r#"{ "editor": "cursor" }"#).expect("write"); assert_eq!(load(&path).editor, "cursor"); diff --git a/src/main.rs b/src/main.rs index c562ba6..84c9339 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod action; mod adapter; mod advanced; +mod atomic_file; +mod background; mod config; mod docker; mod exec; @@ -9,9 +11,10 @@ mod hint; mod label; mod probe; mod project; +mod server; mod snapshot; -use std::net::SocketAddr; +use std::io::{self, BufRead, IsTerminal, Write}; use std::time::Duration; use axum::http::{StatusCode, Uri, header}; @@ -20,9 +23,9 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use clap::Parser; use rust_embed::RustEmbed; -use serde_json::{Value, json}; +use serde_json::json; -#[derive(Parser)] +#[derive(Debug, Parser)] #[command(name = "portdoc", version, about = "Local dev server control panel")] struct Cli { #[command(subcommand)] @@ -41,15 +44,130 @@ struct Cli { json: bool, } -#[derive(clap::Subcommand)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::Subcommand)] enum Command { /// Launch the local dashboard (same as the default command) Ui, + + /// Run the dashboard attached to this terminal + Foreground, + + /// Manage PortDoc with the current user's operating-system service manager + Background { + #[command(subcommand)] + action: BackgroundCommand, + }, + + #[command(hide = true)] + Serve, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::Subcommand)] +enum BackgroundCommand { + /// Install or refresh the service and start it now + Start, + + /// Stop the service but keep sign-in startup enabled + Stop, + + /// Show native-manager and health status + Status, + + /// Stop the service and remove sign-in startup + Disable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LaunchPlan { + run_mode: server::RunMode, + open_browser: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LaunchChoice { + Foreground, + Background, +} + +fn should_prompt(cli: &Cli, stdin_terminal: bool, stdout_terminal: bool) -> bool { + cli.command.is_none() && !cli.json && stdin_terminal && stdout_terminal +} + +fn prompt_launch_choice( + input: &mut impl BufRead, + output: &mut impl Write, +) -> io::Result> { + loop { + writeln!(output, "How should PortDoc run?")?; + writeln!(output, " 1. Foreground (attached to this terminal)")?; + writeln!( + output, + " 2. Background (managed for your user account and started at sign-in)" + )?; + write!(output, "Choose 1 or 2 (Enter to cancel): ")?; + output.flush()?; + + let mut answer = String::new(); + if input.read_line(&mut answer)? == 0 || answer.trim().is_empty() { + return Ok(None); + } + match answer.trim().to_ascii_lowercase().as_str() { + "1" | "f" | "foreground" => return Ok(Some(LaunchChoice::Foreground)), + "2" | "b" | "background" => return Ok(Some(LaunchChoice::Background)), + _ => writeln!(output, "Please choose 1 or 2.")?, + } + } +} + +fn apply_launch_choice(cli: &mut Cli, choice: LaunchChoice) { + cli.command = Some(match choice { + LaunchChoice::Foreground => Command::Foreground, + LaunchChoice::Background => Command::Background { + action: BackgroundCommand::Start, + }, + }); +} + +fn launch_plan(cli: &Cli) -> Option { + if cli.json { + return None; + } + + let run_mode = match cli.command { + Some(Command::Serve) => server::RunMode::Background, + Some(Command::Ui | Command::Foreground) | None => server::RunMode::Foreground, + Some(Command::Background { .. }) => return None, + }; + Some(LaunchPlan { + run_mode, + open_browser: run_mode == server::RunMode::Foreground && !cli.no_open, + }) } #[tokio::main] async fn main() { - let cli = Cli::parse(); + let mut cli = Cli::parse(); + + if should_prompt(&cli, io::stdin().is_terminal(), io::stdout().is_terminal()) { + let choice = { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut input = stdin.lock(); + let mut output = stdout.lock(); + prompt_launch_choice(&mut input, &mut output) + }; + match choice { + Ok(Some(choice)) => apply_launch_choice(&mut cli, choice), + Ok(None) => { + println!("PortDoc launch cancelled."); + return; + } + Err(err) => { + eprintln!("could not read launch choice: {err}"); + std::process::exit(1); + } + } + } if cli.json { let snapshot = adapter::live_snapshot().unwrap_or_else(|err| { @@ -66,13 +184,15 @@ async fn main() { return; } - // `portdoc ui` is an explicit alias of the default launch behavior - match cli.command { - Some(Command::Ui) | None => {} + if let Some(Command::Background { action }) = cli.command { + run_background(action, cli.port, cli.no_open); + return; } + let plan = launch_plan(&cli).expect("server commands always have a launch plan"); + let app = Router::new() - .route("/api/health", get(health)) + .route("/api/health", get(move || server::health(plan.run_mode))) .route("/api/snapshot", get(api_snapshot)) .route("/api/sockets", get(api_sockets)) .route("/api/config", get(api_config)) @@ -82,26 +202,40 @@ async fn main() { .route("/api/open", post(api_open)) .fallback(static_handler); - let addr = SocketAddr::from(([127, 0, 0, 1], cli.port)); - let listener = tokio::net::TcpListener::bind(addr) - .await - .unwrap_or_else(|err| panic!("failed to bind {addr}: {err}")); - - let url = format!("http://{addr}"); - println!("PortDoc listening on {url}"); - - if !cli.no_open - && let Err(err) = open::that_detached(&url) - { - eprintln!("warning: could not open browser: {err}"); + let options = server::Options { + port: cli.port, + open_browser: plan.open_browser, + }; + if let Err(err) = server::run(app, options).await { + eprintln!("{err}"); + std::process::exit(1); } +} - if let Err(err) = axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - { - eprintln!("server error: {err}"); - std::process::exit(1); +fn run_background(action: BackgroundCommand, port: u16, no_open: bool) { + let action = match action { + BackgroundCommand::Start => background::Action::Start, + BackgroundCommand::Stop => background::Action::Stop, + BackgroundCommand::Status => background::Action::Status, + BackgroundCommand::Disable => background::Action::Disable, + }; + match background::run(action, port) { + Ok(status) => { + println!("{status}"); + if action == background::Action::Start && !no_open { + let url = format!("http://127.0.0.1:{port}"); + if let Err(err) = open::that_detached(&url) { + eprintln!("warning: could not open browser: {err}"); + } + } + if action == background::Action::Status && !status.is_running() { + std::process::exit(1); + } + } + Err(err) => { + eprintln!("background command failed: {err}"); + std::process::exit(1); + } } } @@ -153,10 +287,6 @@ fn content_type(path: &str) -> &'static str { } } -async fn health() -> Json { - Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) -} - /// Every request re-probes; the blocking /proc walk stays off the async /// runtime so concurrent refreshes don't stall other requests. async fn api_snapshot() -> Response { @@ -407,9 +537,148 @@ fn validate_reveal_path(path: &std::path::Path, is_dir: Option) -> Result< #[cfg(test)] mod tests { - use super::validate_reveal_path; + use super::{ + BackgroundCommand, Cli, Command, LaunchChoice, apply_launch_choice, launch_plan, + prompt_launch_choice, should_prompt, validate_reveal_path, + }; + use clap::{CommandFactory, Parser}; use std::path::Path; + #[test] + fn default_ui_and_foreground_share_the_interactive_plan() { + for cli in [ + Cli::try_parse_from(["portdoc"]).expect("default command"), + Cli::try_parse_from(["portdoc", "ui"]).expect("ui command"), + Cli::try_parse_from(["portdoc", "foreground"]).expect("foreground command"), + ] { + let plan = launch_plan(&cli).expect("server plan"); + assert_eq!(plan.run_mode, crate::server::RunMode::Foreground); + assert!(plan.open_browser); + } + } + + #[test] + fn managed_entrypoint_never_opens_the_browser() { + let cli = Cli::try_parse_from(["portdoc", "serve"]).expect("managed command"); + assert_eq!(cli.command, Some(Command::Serve)); + + let plan = launch_plan(&cli).expect("server plan"); + assert_eq!(plan.run_mode, crate::server::RunMode::Background); + assert!(!plan.open_browser); + } + + #[test] + fn global_server_flags_keep_their_behavior() { + let cli = Cli::try_parse_from(["portdoc", "foreground", "--port", "7799", "--no-open"]) + .expect("foreground flags"); + + assert_eq!(cli.port, 7799); + assert!(!launch_plan(&cli).expect("server plan").open_browser); + } + + #[test] + fn public_background_commands_accept_global_flags() { + let cases = [ + ("start", BackgroundCommand::Start), + ("stop", BackgroundCommand::Stop), + ("status", BackgroundCommand::Status), + ("disable", BackgroundCommand::Disable), + ]; + for (name, expected) in cases { + let cli = + Cli::try_parse_from(["portdoc", "background", name, "--port", "7799", "--no-open"]) + .expect("background command"); + assert_eq!(cli.command, Some(Command::Background { action: expected })); + assert_eq!(cli.port, 7799); + assert!(cli.no_open); + assert!(launch_plan(&cli).is_none()); + } + } + + #[test] + fn plain_interactive_launch_prompts_and_explicit_commands_do_not() { + let plain = Cli::try_parse_from(["portdoc"]).expect("plain command"); + assert!(should_prompt(&plain, true, true)); + assert!(!should_prompt(&plain, false, true)); + assert!(!should_prompt(&plain, true, false)); + + for args in [ + vec!["portdoc", "ui"], + vec!["portdoc", "foreground"], + vec!["portdoc", "background", "status"], + vec!["portdoc", "--json"], + ] { + let cli = Cli::try_parse_from(args).expect("explicit command"); + assert!(!should_prompt(&cli, true, true)); + } + } + + #[test] + fn launch_prompt_routes_both_choices_through_explicit_commands() { + let cases = [ + ( + b"1\n".as_slice(), + LaunchChoice::Foreground, + Command::Foreground, + ), + ( + b"background\n".as_slice(), + LaunchChoice::Background, + Command::Background { + action: BackgroundCommand::Start, + }, + ), + ]; + + for (mut input, expected_choice, expected_command) in cases { + let mut output = Vec::new(); + let choice = prompt_launch_choice(&mut input, &mut output) + .expect("prompt") + .expect("choice"); + assert_eq!(choice, expected_choice); + let mut cli = Cli::try_parse_from(["portdoc"]).expect("plain command"); + apply_launch_choice(&mut cli, choice); + assert_eq!(cli.command, Some(expected_command)); + } + } + + #[test] + fn launch_prompt_handles_invalid_cancelled_and_eof_input() { + let mut corrected = b"later\n2\n".as_slice(); + let mut output = Vec::new(); + assert_eq!( + prompt_launch_choice(&mut corrected, &mut output).expect("prompt"), + Some(LaunchChoice::Background) + ); + assert!( + String::from_utf8(output) + .expect("prompt output") + .contains("Please choose 1 or 2.") + ); + + for mut input in [b"\n".as_slice(), b"".as_slice()] { + assert_eq!( + prompt_launch_choice(&mut input, &mut Vec::new()).expect("cancel"), + None + ); + } + } + + #[test] + fn json_bypasses_server_launch_and_managed_command_stays_hidden() { + let cli = Cli::try_parse_from(["portdoc", "--json"]).expect("json flag"); + assert!(launch_plan(&cli).is_none()); + + let help = Cli::command().render_help().to_string(); + assert!(help.contains("foreground")); + assert!(help.contains("background")); + assert!( + !help + .lines() + .any(|line| line.split_whitespace().next() == Some("serve")) + ); + } + #[test] fn reveal_path_accepts_only_existing_directories() { assert!(validate_reveal_path(Path::new("/home/brad/Code"), Some(true)).is_ok()); @@ -442,9 +711,3 @@ fn still_listening(port: u16, pid: u32) -> bool { .any(|s| s.port == port && s.pid == Some(pid)) }) } - -async fn shutdown_signal() { - tokio::signal::ctrl_c() - .await - .expect("failed to install ctrl-c handler"); -} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..99c9d81 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,121 @@ +use std::io; +use std::net::SocketAddr; + +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum RunMode { + Foreground, + Background, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Options { + pub port: u16, + pub open_browser: bool, +} + +#[derive(Debug, Error)] +pub(crate) enum ServerError { + #[error("failed to bind {addr}: {source}")] + Bind { + addr: SocketAddr, + #[source] + source: io::Error, + }, + #[error("server error: {0}")] + Serve(#[source] io::Error), +} + +pub(crate) async fn run(app: Router, options: Options) -> Result<(), ServerError> { + let addr = SocketAddr::from(([127, 0, 0, 1], options.port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|source| ServerError::Bind { addr, source })?; + + let url = format!("http://{addr}"); + println!("PortDoc listening on {url}"); + + if options.open_browser + && let Err(err) = open::that_detached(&url) + { + eprintln!("warning: could not open browser: {err}"); + } + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(ServerError::Serve) +} + +#[derive(Debug, Serialize)] +pub(crate) struct HealthResponse { + status: &'static str, + version: &'static str, + pid: u32, + run_mode: RunMode, +} + +pub(crate) async fn health(run_mode: RunMode) -> Json { + Json(HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + pid: std::process::id(), + run_mode, + }) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { + _ = wait_for_ctrl_c() => {} + _ = terminate.recv() => {} + } + } + Err(err) => { + eprintln!("warning: could not install termination handler: {err}"); + wait_for_ctrl_c().await; + } + } + } + + #[cfg(not(unix))] + wait_for_ctrl_c().await; +} + +async fn wait_for_ctrl_c() { + if let Err(err) = tokio::signal::ctrl_c().await { + eprintln!("warning: could not install Ctrl+C handler: {err}"); + std::future::pending::<()>().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn health_reports_process_and_foreground_mode() { + let Json(response) = health(RunMode::Foreground).await; + let value = serde_json::to_value(response).expect("serialize health"); + + assert_eq!(value["status"], "ok"); + assert_eq!(value["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(value["pid"], std::process::id()); + assert_eq!(value["run_mode"], "foreground"); + } + + #[tokio::test] + async fn health_serializes_background_mode() { + let Json(response) = health(RunMode::Background).await; + let value = serde_json::to_value(response).expect("serialize health"); + + assert_eq!(value["run_mode"], "background"); + } +} From 099392a054dbd8a56c10d537083913efdc8ce506 Mon Sep 17 00:00:00 2001 From: bradtraversy Date: Mon, 27 Jul 2026 10:21:18 -0400 Subject: [PATCH 2/3] fix: scope native renderer code by platform --- src/background/lifecycle.rs | 27 +++++++++++++++++---------- src/background/linux.rs | 3 +++ src/main.rs | 22 +++++++++++----------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/background/lifecycle.rs b/src/background/lifecycle.rs index 6335cf0..cf069e4 100644 --- a/src/background/lifecycle.rs +++ b/src/background/lifecycle.rs @@ -782,8 +782,10 @@ mod tests { fn failed_start_preserves_the_previous_config() { let base = temp_base("failed-start"); let config_path = base.join("config.json"); - let mut saved = config::Config::default(); - saved.background_port = Some(7788); + let saved = config::Config { + background_port: Some(7788), + ..config::Config::default() + }; config::save(&config_path, &saved).expect("save prior config"); let spec = spec(&base); let manager = FakeManager::new([ManagerState::NotConfigured]); @@ -804,8 +806,10 @@ mod tests { fn changing_ports_waits_for_the_previous_managed_endpoint_to_stop() { let base = temp_base("changed-port"); let config_path = base.join("config.json"); - let mut saved = config::Config::default(); - saved.background_port = Some(7788); + let saved = config::Config { + background_port: Some(7788), + ..config::Config::default() + }; config::save(&config_path, &saved).expect("save prior config"); let spec = spec(&base); let manager = FakeManager::new([ @@ -860,8 +864,10 @@ mod tests { fn stop_addresses_only_a_registered_manager_entry() { let base = temp_base("safe-stop"); let config_path = base.join("config.json"); - let mut saved = config::Config::default(); - saved.background_port = Some(7799); + let saved = config::Config { + background_port: Some(7799), + ..config::Config::default() + }; config::save(&config_path, &saved).expect("save config"); let spec = spec(&base); let manager = FakeManager::new([ManagerState::NotConfigured]); @@ -878,10 +884,11 @@ mod tests { fn disable_unregisters_the_manager_and_clears_only_the_saved_port() { let base = temp_base("disable"); let config_path = base.join("config.json"); - let mut saved = config::Config::default(); - saved.background_port = Some(7799); - saved.editor = "cursor".into(); - saved.ignored_services.push("svc-3000-node".into()); + let saved = config::Config { + ignored_services: vec!["svc-3000-node".into()], + editor: "cursor".into(), + background_port: Some(7799), + }; config::save(&config_path, &saved).expect("save config"); let spec = spec(&base); let manager = FakeManager::new([ diff --git a/src/background/linux.rs b/src/background/linux.rs index 7758fc0..9ec434c 100644 --- a/src/background/linux.rs +++ b/src/background/linux.rs @@ -1,5 +1,7 @@ +#[cfg(any(test, target_os = "linux"))] use super::{BackgroundSpec, DefinitionError, definition_text, required_path}; +#[cfg(any(test, target_os = "linux"))] pub(crate) fn render(spec: &BackgroundSpec) -> Result { let executable = definition_text(spec.executable.as_os_str(), "executable")?; let path = required_path(spec)?; @@ -24,6 +26,7 @@ WantedBy=default.target )) } +#[cfg(any(test, target_os = "linux"))] fn quote(value: &str, escape_dollars: bool) -> String { let mut quoted = String::with_capacity(value.len() + 2); quoted.push('"'); diff --git a/src/main.rs b/src/main.rs index 84c9339..e1adc4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -535,6 +535,17 @@ fn validate_reveal_path(path: &std::path::Path, is_dir: Option) -> Result< } } +fn still_listening(port: u16, pid: u32) -> bool { + probe::platform_probe() + .and_then(|probe| probe.probe().ok()) + .is_some_and(|output| { + output + .sockets + .iter() + .any(|s| s.port == port && s.pid == Some(pid)) + }) +} + #[cfg(test)] mod tests { use super::{ @@ -700,14 +711,3 @@ mod tests { ); } } - -fn still_listening(port: u16, pid: u32) -> bool { - probe::platform_probe() - .and_then(|probe| probe.probe().ok()) - .is_some_and(|output| { - output - .sockets - .iter() - .any(|s| s.port == port && s.pid == Some(pid)) - }) -} From 3185dd9129c1e9ce737835e32cccbf4bcc8cabdf Mon Sep 17 00:00:00 2001 From: bradtraversy Date: Mon, 27 Jul 2026 10:27:24 -0400 Subject: [PATCH 3/3] fix: scope path helper to unix platforms --- src/background/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/background/mod.rs b/src/background/mod.rs index 35b4104..611f573 100644 --- a/src/background/mod.rs +++ b/src/background/mod.rs @@ -172,6 +172,7 @@ fn definition_text<'a>(value: &'a OsStr, field: &'static str) -> Result<&'a str, Ok(value) } +#[cfg(any(test, target_os = "linux", target_os = "macos"))] fn required_path(spec: &BackgroundSpec) -> Result<&str, DefinitionError> { let path = spec .path_env