diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19945c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + decdn_ref: + description: decdn/decdn branch, tag or SHA to build against + default: main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +# This workspace path-depends on its sibling `decdn` checkout +# (`../decdn/crates/*`), so every job checks both repos out side by side: +# `sponsord/` and `decdn/` under the workspace root. `decdn` is `main` unless a +# manual run names another ref. +jobs: + linux: + name: linux (workspace) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: sponsord + - uses: actions/checkout@v4 + with: + repository: decdn/decdn + ref: ${{ inputs.decdn_ref || 'main' }} + path: decdn + - uses: dtolnay/rust-toolchain@1.95 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: sponsord + - working-directory: sponsord + run: cargo fmt --all --check + - working-directory: sponsord + run: cargo clippy --workspace --all-targets -- -D warnings + - working-directory: sponsord + run: cargo test --workspace + + # `decdn-sponsored` is the end-user CLI, installed by `decdn.sh` on macOS and + # Linux and by `decdn.ps1` on Windows x64 and ARM64. The server (`sponsord`) + # runs only on Linux, so these legs cover the client crate alone. + client: + name: client (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-latest + target: aarch64-apple-darwin + test: true + - runner: windows-latest + target: x86_64-pc-windows-msvc + test: true + # Cross-compiled on the x64 runner, so it is linted, not run. + - runner: windows-latest + target: aarch64-pc-windows-msvc + test: false + steps: + - uses: actions/checkout@v4 + with: + path: sponsord + - uses: actions/checkout@v4 + with: + repository: decdn/decdn + ref: ${{ inputs.decdn_ref || 'main' }} + path: decdn + - uses: dtolnay/rust-toolchain@1.95 + with: + components: clippy + # From inside the checkout, so the target lands on the toolchain that + # `rust-toolchain.toml` pins rather than on the action's own. + - working-directory: sponsord + run: rustup target add ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: sponsord + key: ${{ matrix.target }} + - working-directory: sponsord + run: cargo clippy -p decdn-sponsored --all-targets --target ${{ matrix.target }} -- -D warnings + - if: matrix.test + working-directory: sponsord + run: cargo test -p decdn-sponsored --target ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 1a6a41d..f6dfebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2832,9 +2832,9 @@ dependencies = [ "decdn-config-types", "decdn-protocol", "dirs", + "fs4", "iroh", "jsonrpsee", - "nix", "rand 0.10.2", "serde", "tokio", @@ -2987,7 +2987,6 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "serial_test", "tempfile", "tokio", "toml", @@ -3611,6 +3610,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -5572,18 +5581,6 @@ dependencies = [ "wmi", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" diff --git a/README.md b/README.md index 29120e0..d14150c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ pool's own balance — there's no per-signer monthly accumulator. ## Crates - `crates/server` (binary `sponsord`) — the HTTP gateway: `/healthz`, - `/decdn.sh` (templated installer), `/fund` (captcha page + capability + `/decdn.sh` and `/decdn.ps1` (templated installers for macOS/Linux and + Windows), `/fund` (captcha page + capability issuance), `/capability` (poll for an issued capability). - `crates/wrapper` (binary `decdn-sponsored`) — the end-user CLI: reads `~/.decdn/sponsor.toml` (written by the installer), obtains a capability @@ -62,7 +63,7 @@ pool up from the treasury whenever its remaining balance falls below | Variable | Required | Default | Purpose | |---|---|---|---| | `SPONSOR_BIND` | no | `127.0.0.1:8080` | Address the HTTP server listens on | -| `SPONSOR_PUBLIC_URL` | no | `https://up.decdn.org` | This gateway's own public base URL; baked into the `/decdn.sh` installer as `{{GATEWAY_BASE}}` | +| `SPONSOR_PUBLIC_URL` | no | `https://up.decdn.org` | This gateway's own public base URL; baked into the `/decdn.sh` and `/decdn.ps1` installers as `{{GATEWAY_BASE}}` | | `SPONSOR_RPC_URL` | **yes** | — | Arbitrum Sepolia RPC endpoint | | `SPONSOR_CHAIN_ID` | no | `421614` | Chain id (Arbitrum Sepolia) | | `SPONSOR_PAYMENT_POOL_ADDR` | **yes** | — | `PaymentPool` contract address | @@ -82,13 +83,20 @@ pool up from the treasury whenever its remaining balance falls below ## The `decdn-sponsored` flow The website shows one command per model, with the model's BLAKE3 hash from -`models.json`: +`models.json`. On macOS and Linux: ```bash curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull b3: ``` -1. The installer served at `GET /decdn.sh` (`assets/decdn.sh`) installs the +On Windows (x64 and ARM64), in PowerShell: + +```powershell +irm https://up.decdn.org/decdn.ps1 | iex; decdn-sponsored pull b3: +``` + +1. The installer served at `GET /decdn.sh` (`assets/decdn.sh`), or its + PowerShell twin at `GET /decdn.ps1` (`assets/decdn.ps1`), installs the `decdn` and `decdn-sponsored` binaries and writes `~/.decdn/sponsor.toml` with the gateway's contract addresses and RPC URL filled in. Any arguments are passed on to `decdn-sponsored`. Running it again is @@ -112,7 +120,8 @@ curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull b3: a new captcha. The `~/.decdn/sponsor.toml` schema is a hard contract between the installer -(`assets/decdn.sh`) and the wrapper (`crates/wrapper/src/config.rs`): field +(`assets/decdn.sh`, `assets/decdn.ps1`) and the wrapper +(`crates/wrapper/src/config.rs`): field names must match exactly. Current fields: `gateway_base`, `decdn_bin`, `data_dir`, `rpc_url`, `payment_pool`, `capacity_bond` (optional), `slash_judge` (optional), `chain_id`. Unknown fields are ignored. diff --git a/crates/server/assets/decdn.ps1 b/crates/server/assets/decdn.ps1 new file mode 100644 index 0000000..f005a11 --- /dev/null +++ b/crates/server/assets/decdn.ps1 @@ -0,0 +1,101 @@ +# decdn-sponsored installer for Windows - served by sponsord at GET /decdn.ps1, +# with the placeholders below substituted server-side (see +# crates/server/src/http/installer.rs) from ServerConfig. The POSIX twin is +# assets/decdn.sh; the two write the same profile. +# +# Install, then download: +# irm /decdn.ps1 | iex; decdn-sponsored pull b3: +# Or pass the arguments through in one call: +# & ([scriptblock]::Create((irm /decdn.ps1))) pull b3: +# +# Everything runs inside one script block, so `iex` leaves no variables or +# preference changes behind in the caller's session, and nothing here calls +# `exit` (which would close the caller's window). The one process-wide +# setting it touches, the TLS protocol list, is restored on the way out. +& { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest's progress bar slows downloads sharply in Windows + # PowerShell 5.1. + $ProgressPreference = 'SilentlyContinue' + $PriorProtocol = [Net.ServicePointManager]::SecurityProtocol + [Net.ServicePointManager]::SecurityProtocol = + $PriorProtocol -bor [Net.SecurityProtocolType]::Tls12 + try { + + $Gateway = '{{GATEWAY_BASE}}' + $RpcUrl = '{{RPC_URL}}' + $PaymentPool = '{{PAYMENT_POOL}}' + $CapacityBond = '{{CAPACITY_BOND}}' + $ChainId = '{{CHAIN_ID}}' + + $BinDir = Join-Path $env:LOCALAPPDATA 'decdn\bin' + $DecdnDir = Join-Path $HOME '.decdn' + New-Item -ItemType Directory -Force -Path $BinDir, $DecdnDir | Out-Null + + # The OS architecture, not the process's: x64 PowerShell under emulation on + # an ARM64 machine reports AMD64 in PROCESSOR_ARCHITECTURE. + $OsArch = try { + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + $env:PROCESSOR_ARCHITECTURE + } + $Arch = switch ($OsArch) { + { $_ -in 'X64', 'AMD64' } { 'x86_64' } + { $_ -in 'Arm64', 'ARM64' } { 'aarch64' } + default { throw "decdn: unsupported Windows architecture: $OsArch" } + } + + # 1. Install the decdn and decdn-sponsored binaries. + # + # NOTE: release hosting (GET /dl/--) is not wired up on the + # gateway yet; until it is, this step fails with a 404. + foreach ($bin in 'decdn', 'decdn-sponsored') { + Write-Host "Installing $bin..." + Invoke-WebRequest -UseBasicParsing -Uri "$Gateway/dl/$bin-windows-$Arch.exe" ` + -OutFile (Join-Path $BinDir "$bin.exe") + } + + # 2. Put the binaries on PATH: permanently for the user, and right away for + # this session so the next command on the same line finds them. + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not (($UserPath -split ';') -contains $BinDir)) { + $NewPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir } + [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') + } + if (-not (($env:Path -split ';') -contains $BinDir)) { + $env:Path = "$BinDir;$env:Path" + } + + # 3. Write the wrapper's profile. Field names and shape MUST match + # crates/wrapper/src/config.rs's `Profile` struct exactly. Paths use + # forward slashes, which Windows accepts and TOML strings need no + # escaping for. Each download gets its own throwaway key under data_dir. + $Fwd = { param($p) $p.Replace('\', '/') } + $DecdnBin = & $Fwd (Join-Path $BinDir 'decdn.exe') + $DataDir = & $Fwd (Join-Path $DecdnDir 'sponsored') + $ProfileToml = @" +gateway_base = "$Gateway" +decdn_bin = "$DecdnBin" +data_dir = "$DataDir" +rpc_url = "$RpcUrl" +payment_pool = "$PaymentPool" +capacity_bond = "$CapacityBond" +chain_id = $ChainId +"@ + # UTF-8 without a byte-order mark: Windows PowerShell 5.1's `-Encoding UTF8` + # writes one, and a BOM is not valid TOML. + [IO.File]::WriteAllText((Join-Path $DecdnDir 'sponsor.toml'), $ProfileToml, + (New-Object System.Text.UTF8Encoding $false)) + + if ($args.Count -gt 0) { + & (Join-Path $BinDir 'decdn-sponsored.exe') @args + return + } + + Write-Host '' + Write-Host 'decdn-sponsored is ready. Download with:' + Write-Host ' decdn-sponsored pull b3: [-o ]' + } finally { + [Net.ServicePointManager]::SecurityProtocol = $PriorProtocol + } +} @args diff --git a/crates/server/assets/decdn.sh b/crates/server/assets/decdn.sh index 361a248..3e8faed 100644 --- a/crates/server/assets/decdn.sh +++ b/crates/server/assets/decdn.sh @@ -1,8 +1,10 @@ #!/bin/sh -# decdn-sponsored installer - served by sponsord at GET /decdn.sh, with the +# decdn-sponsored installer for macOS/Linux - served by sponsord at +# GET /decdn.sh, with the # placeholders below substituted server-side (see # crates/server/src/http/installer.rs) from ServerConfig, so nothing here -# needs an environment variable to run. +# needs an environment variable to run. The Windows twin is assets/decdn.ps1; +# the two write the same profile. # # Arguments, when given, are passed to decdn-sponsored after installing, so # one line installs and downloads: @@ -21,7 +23,11 @@ DECDN_DIR="${HOME}/.decdn" mkdir -p "$BINDIR" "$DECDN_DIR" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" -ARCH="$(uname -m)" +case "$(uname -m)" in + x86_64 | amd64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "decdn: unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac # 1. Install the decdn and decdn-sponsored binaries. # diff --git a/crates/server/src/http/installer.rs b/crates/server/src/http/installer.rs index 62b8621..65c6107 100644 --- a/crates/server/src/http/installer.rs +++ b/crates/server/src/http/installer.rs @@ -1,33 +1,50 @@ -//! `GET /decdn.sh`: the templated POSIX installer script. Embeds -//! `assets/decdn.sh` at compile time (`include_str!`) and substitutes the -//! `{{...}}` placeholders with values from `ServerConfig`, so end users never -//! set an env var themselves — the contract addresses and RPC URL are baked -//! in server-side. +//! `GET /decdn.sh` and `GET /decdn.ps1`: the templated installer scripts for +//! macOS/Linux and Windows. Each embeds its `assets/` file at compile time +//! (`include_str!`) and substitutes the `{{...}}` placeholders with values +//! from `ServerConfig`, so end users never set an env var themselves — the +//! contract addresses and RPC URL are baked in server-side. use axum::extract::State; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; +use crate::config::ServerConfig; use crate::state::AppState; -/// The raw installer script, embedded at compile time. +/// The POSIX installer script, embedded at compile time. const DECDN_SH_TEMPLATE: &str = include_str!("../../assets/decdn.sh"); -/// Renders the installer script with `state.cfg`'s values substituted for -/// the `{{GATEWAY_BASE}}`, `{{RPC_URL}}`, `{{PAYMENT_POOL}}`, -/// `{{CAPACITY_BOND}}`, and `{{CHAIN_ID}}` placeholders. -pub async fn get(State(state): State) -> Response { - let script = DECDN_SH_TEMPLATE - .replace("{{GATEWAY_BASE}}", &state.cfg.public_url) - .replace("{{RPC_URL}}", &state.cfg.rpc_url) - .replace("{{PAYMENT_POOL}}", &state.cfg.payment_pool.to_string()) - .replace("{{CAPACITY_BOND}}", &state.cfg.capacity_bond.to_string()) - .replace("{{CHAIN_ID}}", &state.cfg.chain_id.to_string()); - - let mut resp = (StatusCode::OK, script).into_response(); - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/x-shellscript; charset=utf-8"), - ); +/// The PowerShell installer script, embedded at compile time. +const DECDN_PS1_TEMPLATE: &str = include_str!("../../assets/decdn.ps1"); + +/// Substitute `cfg`'s values for the `{{GATEWAY_BASE}}`, `{{RPC_URL}}`, +/// `{{PAYMENT_POOL}}`, `{{CAPACITY_BOND}}`, and `{{CHAIN_ID}}` placeholders. +fn render(template: &str, cfg: &ServerConfig) -> String { + template + .replace("{{GATEWAY_BASE}}", &cfg.public_url) + .replace("{{RPC_URL}}", &cfg.rpc_url) + .replace("{{PAYMENT_POOL}}", &cfg.payment_pool.to_string()) + .replace("{{CAPACITY_BOND}}", &cfg.capacity_bond.to_string()) + .replace("{{CHAIN_ID}}", &cfg.chain_id.to_string()) +} + +fn script(body: String, content_type: &'static str) -> Response { + let mut resp = (StatusCode::OK, body).into_response(); + resp.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); resp } + +pub async fn sh(State(state): State) -> Response { + script( + render(DECDN_SH_TEMPLATE, &state.cfg), + "text/x-shellscript; charset=utf-8", + ) +} + +pub async fn ps1(State(state): State) -> Response { + script( + render(DECDN_PS1_TEMPLATE, &state.cfg), + "text/plain; charset=utf-8", + ) +} diff --git a/crates/server/src/http/mod.rs b/crates/server/src/http/mod.rs index 3bd81ac..fc4d41b 100644 --- a/crates/server/src/http/mod.rs +++ b/crates/server/src/http/mod.rs @@ -1,4 +1,4 @@ -//! HTTP surface: `/healthz`, `/decdn.sh`, `/fund` (captcha page + issue), +//! HTTP surface: `/healthz`, `/decdn.sh` + `/decdn.ps1`, `/fund` (captcha page + issue), //! `/capability` (poll for the issued token). Handlers live in the sibling //! `fund`/`capability`/`installer` modules and share the helpers below. @@ -20,7 +20,8 @@ use crate::state::AppState; pub fn router(state: AppState) -> Router { Router::new() .route("/healthz", get(healthz)) - .route("/decdn.sh", get(installer::get)) + .route("/decdn.sh", get(installer::sh)) + .route("/decdn.ps1", get(installer::ps1)) .route("/fund", get(fund::page).post(fund::submit)) .route("/capability", get(capability::get)) .with_state(state) diff --git a/crates/server/tests/http_contract.rs b/crates/server/tests/http_contract.rs index 7facba1..31da712 100644 --- a/crates/server/tests/http_contract.rs +++ b/crates/server/tests/http_contract.rs @@ -237,3 +237,27 @@ async fn decdn_sh_templated_with_payment_pool() { "installer writes payment_pool" ); } + +#[tokio::test] +async fn decdn_ps1_templated_with_payment_pool() { + let state = test_support::app_state_with_fakes(); + let app = sponsord::http::router(state); + let resp = app + .oneshot(Request::get("/decdn.ps1").body(Body::empty()).expect("req")) + .await + .expect("resp"); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(bytes.to_vec()).expect("utf8"); + assert!(!body.contains("{{"), "no placeholder should remain"); + assert!( + body.contains("payment_pool ="), + "installer writes payment_pool" + ); + assert!( + body.contains("-windows-$Arch.exe"), + "installer downloads the Windows binaries" + ); +} diff --git a/crates/wrapper/Cargo.toml b/crates/wrapper/Cargo.toml index 3c9ac35..9b09b90 100644 --- a/crates/wrapper/Cargo.toml +++ b/crates/wrapper/Cargo.toml @@ -38,4 +38,3 @@ clap = { version = "4", features = ["derive"] } [dev-dependencies] tempfile = "3" wiremock = "0.6" -serial_test = "3" diff --git a/crates/wrapper/src/config.rs b/crates/wrapper/src/config.rs index 391bd00..c7a1085 100644 --- a/crates/wrapper/src/config.rs +++ b/crates/wrapper/src/config.rs @@ -39,22 +39,32 @@ pub struct WrapperConfig { pub chain_id: u64, } -/// Expand a leading `~` (or `~/...`) to `$HOME`. Any other path (including -/// one with no leading `~`) is returned unchanged. +/// The user's home directory: `$HOME` on Unix, the profile folder +/// (`%USERPROFILE%`) on Windows. /// /// # Errors /// -/// Returns an error if the path starts with `~` but `$HOME` isn't set. +/// Returns an error if the platform reports no home directory. +fn home() -> anyhow::Result { + std::env::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine the home directory")) +} + +/// Expand a leading `~` (or `~/...`) to the home directory. Any other path +/// (including one with no leading `~`) is returned unchanged. +/// +/// # Errors +/// +/// Returns an error if the path starts with `~` but there is no home +/// directory. fn expand_home(path: &Path) -> anyhow::Result { let Some(s) = path.to_str() else { return Ok(path.to_path_buf()); }; if s == "~" || s.starts_with("~/") { - let home = std::env::var("HOME") - .map_err(|_| anyhow::anyhow!("path {s} starts with ~ but $HOME is not set"))?; + let home = home()?; let rest = s.strip_prefix('~').unwrap_or(s); let rest = rest.strip_prefix('/').unwrap_or(rest); - return Ok(PathBuf::from(home).join(rest)); + return Ok(home.join(rest)); } Ok(path.to_path_buf()) } @@ -65,16 +75,12 @@ impl WrapperConfig { /// /// # Errors /// - /// Returns an error if `$HOME` can't be resolved or the profile file + /// Returns an error if the home directory can't be resolved or the profile file /// can't be read or parsed. pub fn load() -> anyhow::Result { let profile_path = match std::env::var("DECDN_SPONSOR_PROFILE") { Ok(p) => PathBuf::from(p), - Err(_) => { - let home = std::env::var("HOME") - .map_err(|_| anyhow::anyhow!("$HOME is not set; cannot locate sponsor.toml"))?; - PathBuf::from(home).join(DEFAULT_PROFILE_REL) - } + Err(_) => home()?.join(DEFAULT_PROFILE_REL), }; let text = std::fs::read_to_string(&profile_path).map_err(|e| { anyhow::anyhow!("failed to read profile {}: {e}", profile_path.display()) @@ -107,7 +113,6 @@ impl WrapperConfig { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use serial_test::serial; const SAMPLE: &str = r#" gateway_base = "https://gateway.example.com" @@ -119,24 +124,19 @@ mod tests { chain_id = 421614 "#; + /// Compared against the platform's own home directory (`$HOME` on Unix, + /// the profile folder on Windows) rather than a faked `HOME`, which + /// Windows does not consult. #[test] - #[serial] fn parses_profile_and_expands_home() { - unsafe { - std::env::set_var("HOME", "/home/testuser"); - } let cfg = WrapperConfig::from_toml_str(SAMPLE).unwrap(); assert_eq!(cfg.gateway_base, "https://gateway.example.com"); - assert_eq!( - cfg.data_dir, - PathBuf::from("/home/testuser/.decdn/sponsored") - ); + assert_eq!(cfg.data_dir, home().unwrap().join(".decdn/sponsored")); assert_eq!(cfg.chain_id, 421_614); assert!(cfg.slash_judge.is_none()); } #[test] - #[serial] fn ignores_unknown_fields() { let with_extra = format!("{SAMPLE}\nkeystore_path = \"~/.decdn/client/keystore.json\"\n"); assert!(WrapperConfig::from_toml_str(&with_extra).is_ok()); diff --git a/crates/wrapper/src/flow.rs b/crates/wrapper/src/flow.rs index 5ba4e41..a8270f3 100644 --- a/crates/wrapper/src/flow.rs +++ b/crates/wrapper/src/flow.rs @@ -81,13 +81,22 @@ fn open_in_browser(url: &str) { if !std::io::stdout().is_terminal() { return; } - let opener = if cfg!(target_os = "macos") { - "open" + // `rundll32 url.dll,FileProtocolHandler` hands the URL to the default + // browser without passing it through `cmd`'s metacharacter parsing. + let mut command = if cfg!(windows) { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c } else { - "xdg-open" + let mut c = std::process::Command::new(if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }); + c.arg(url); + c }; - let _ = std::process::Command::new(opener) - .arg(url) + let _ = command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) diff --git a/crates/wrapper/tests/pull_flow.rs b/crates/wrapper/tests/pull_flow.rs index fbfbfa1..23d6629 100644 --- a/crates/wrapper/tests/pull_flow.rs +++ b/crates/wrapper/tests/pull_flow.rs @@ -1,6 +1,6 @@ //! End-to-end `flow::pull` against a mock gateway and a stub `decdn` script -//! that records its arguments and exits with a chosen status. -#![cfg(unix)] +//! that records its arguments and exits with a chosen status: a shell script +//! on Unix, a `.cmd` batch file on Windows. #![allow( clippy::unwrap_used, clippy::expect_used, @@ -8,7 +8,6 @@ clippy::indexing_slicing )] -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -40,7 +39,9 @@ fn now() -> u64 { } /// A stub `decdn` that appends its argv to `/calls` and exits `code`. +#[cfg(unix)] fn stub_decdn(dir: &Path, code: i32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; let bin = dir.join(format!("decdn-{code}")); let log = dir.join("calls"); std::fs::write( @@ -55,6 +56,22 @@ fn stub_decdn(dir: &Path, code: i32) -> PathBuf { bin } +/// A stub `decdn` that appends its argv to `/calls` and exits `code`. +#[cfg(windows)] +fn stub_decdn(dir: &Path, code: i32) -> PathBuf { + let bin = dir.join(format!("decdn-{code}.cmd")); + let log = dir.join("calls"); + std::fs::write( + &bin, + format!( + "@echo off\r\necho %*>> \"{}\"\r\nexit /b {code}\r\n", + log.display() + ), + ) + .unwrap(); + bin +} + fn config(gateway: &str, decdn_bin: &Path, data_dir: &Path) -> WrapperConfig { WrapperConfig { gateway_base: gateway.to_string(),