diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51dbfb7d..5c4081d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,18 +3,19 @@ name: ci on: pull_request: paths: - - 'crates/**' - - 'experiments/**' - - 'runtime/**' - - 'scripts/**' - - 'tests/**' - - 'wp-plugin/**' - - 'vendor/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.github/workflows/ci.yml' + - "crates/**" + - "experiments/**" + - "runtime/**" + - "scripts/**" + - "installer/**" + - "tests/**" + - "wp-plugin/**" + - "vendor/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ci.yml" push: - branches: ['trunk'] + branches: ["trunk"] jobs: linux-cow-e2e: @@ -116,3 +117,38 @@ jobs: run: tests/cow/e2e.sh target/${{ matrix.target }}/release/forkpress env: FORKPRESS_FORCE_MACOS_APFS_SPARSEBUNDLE: "1" + + windows-cow-check: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Rust unit tests (workspace crates) + shell: pwsh + run: | + cargo test --target x86_64-pc-windows-msvc --workspace --exclude forkpress-cli + + - name: Rust unit tests (CLI) + shell: pwsh + run: | + $bundle = Join-Path $pwd 'empty-runtime.tar.gz' + Set-Content -Path $bundle -Value '' -NoNewline + $env:FORKPRESS_RUNTIME_BUNDLE = $bundle + cargo test --target x86_64-pc-windows-msvc -p forkpress-cli --bin forkpress + + - name: Windows script syntax + shell: pwsh + run: | + foreach ($script in @( + 'scripts/windows/build-dist.ps1', + 'scripts/windows/install.ps1', + 'scripts/windows/setup-dev-drive.ps1', + 'scripts/windows/package.ps1', + 'scripts/windows/sign.ps1' + )) { + [scriptblock]::Create((Get-Content -Raw $script)) | Out-Null + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1e0522d..a1588c42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,22 +2,24 @@ name: release on: push: - tags: ['v*'] + tags: ["v*"] workflow_dispatch: pull_request: paths: - - 'crates/**' - - 'runtime/cow/**' - - 'runtime/wp.zip' - - 'scripts/build-dist.sh' - - 'scripts/cow/**' - - 'scripts/git/**' - - 'scripts/shared/**' - - 'wp-plugin/**' - - 'vendor/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.github/workflows/release.yml' + - "crates/**" + - "runtime/cow/**" + - "runtime/wp.zip" + - "scripts/build-dist.sh" + - "scripts/windows/**" + - "scripts/cow/**" + - "scripts/git/**" + - "scripts/shared/**" + - "installer/windows/**" + - "wp-plugin/**" + - "vendor/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/release.yml" jobs: build: @@ -38,7 +40,13 @@ jobs: - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl os: linux + - runner: windows-latest + target: x86_64-pc-windows-msvc + os: windows runs-on: ${{ matrix.runner }} + env: + WINDOWS_CODESIGN_CERT_BASE64: ${{ secrets.WINDOWS_CODESIGN_CERT_BASE64 }} + WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }} steps: - uses: actions/checkout@v4 @@ -57,6 +65,12 @@ jobs: build-essential clang curl git pkg-config unzip \ musl-tools php-cli composer re2c bison + - name: Install toolchain (windows) + if: matrix.os == 'windows' + shell: pwsh + run: | + choco install innosetup --no-progress -y + # Static PHP builds are slow (3-5 min of compilation). Cache the whole # .build/ directory so we skip the clone, composer install, downloads, # and the PHP/lib compilation when inputs haven't changed. Cache key @@ -68,6 +82,7 @@ jobs: key: >- build-${{ matrix.target }}-${{ hashFiles( 'scripts/build-dist.sh', + 'scripts/windows/build-dist.ps1', 'crates/forkpress-cli/build.rs' ) }} @@ -76,23 +91,126 @@ jobs: targets: ${{ matrix.target }} - name: Build production dist bundle + if: matrix.os != 'windows' run: scripts/build-dist.sh env: FORKPRESS_TARGET: ${{ matrix.target }} GITHUB_TOKEN: ${{ github.token }} + - name: Build production dist bundle (windows) + if: matrix.os == 'windows' + shell: pwsh + run: scripts/windows/build-dist.ps1 + env: + FORKPRESS_TARGET: ${{ matrix.target }} + - name: Build forkpress run: cargo build --release --target ${{ matrix.target }} -p forkpress-cli --bin forkpress + - name: Require signing for tagged Windows releases + if: matrix.os == 'windows' && startsWith(github.ref, 'refs/tags/v') && env.WINDOWS_CODESIGN_CERT_BASE64 == '' + shell: pwsh + run: | + throw 'Tagged Windows releases require WINDOWS_CODESIGN_CERT_BASE64 and WINDOWS_CODESIGN_PASSWORD secrets.' + + - name: Sign forkpress.exe (windows) + if: matrix.os == 'windows' && env.WINDOWS_CODESIGN_CERT_BASE64 != '' + shell: pwsh + run: scripts/windows/sign.ps1 -Files "target/${{ matrix.target }}/release/forkpress.exe" + - name: Package + if: matrix.os != 'windows' run: | cd target/${{ matrix.target }}/release tar -czf ${{ github.workspace }}/forkpress-${{ matrix.target }}.tar.gz forkpress + - name: Package (windows) + if: matrix.os == 'windows' + shell: pwsh + run: | + $stage = Join-Path $env:RUNNER_TEMP 'forkpress-windows-package' + scripts/windows/package.ps1 ` + -ForkPressExe "target/${{ matrix.target }}/release/forkpress.exe" ` + -Output "forkpress-${{ matrix.target }}.zip" ` + -StageDir $stage ` + -KeepStage + $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + & $iscc installer/windows/ForkPress.iss ` + /DSourceDir="$stage" ` + /DAppVersion="${{ github.ref_name }}" ` + /O"${{ github.workspace }}" + + - name: Sign installer (windows) + if: matrix.os == 'windows' && env.WINDOWS_CODESIGN_CERT_BASE64 != '' + shell: pwsh + run: scripts/windows/sign.ps1 -Files ForkPressSetup.exe + + - name: Smoke packaged Windows artifact + if: matrix.os == 'windows' + shell: pwsh + run: | + $zip = "forkpress-${{ matrix.target }}.zip" + if (-not (Test-Path -LiteralPath $zip)) { + throw "Missing $zip" + } + if (-not (Test-Path -LiteralPath 'ForkPressSetup.exe')) { + throw 'Missing ForkPressSetup.exe' + } + $extract = Join-Path $env:RUNNER_TEMP 'forkpress-zip-smoke' + Remove-Item -Recurse -Force -LiteralPath $extract -ErrorAction SilentlyContinue + Expand-Archive -LiteralPath $zip -DestinationPath $extract + foreach ($required in @( + 'forkpress.exe', + 'scripts/windows/install.ps1', + 'scripts/windows/setup-dev-drive.ps1', + 'vendor/vc_redist.x64.exe' + )) { + if (-not (Test-Path -LiteralPath (Join-Path $extract $required))) { + throw "Packaged artifact is missing $required" + } + } + & (Join-Path $extract 'forkpress.exe') --version + if ($LASTEXITCODE -ne 0) { + throw "Packaged forkpress.exe failed with exit code $LASTEXITCODE" + } + $installRoot = Join-Path $env:RUNNER_TEMP 'forkpress-install-smoke' + $mountPath = Join-Path $env:RUNNER_TEMP 'ForkPressDevDriveSmoke' + $vhdPath = Join-Path $env:RUNNER_TEMP 'forkpress-smoke.vhdx' + Remove-Item -Recurse -Force -LiteralPath $installRoot, $mountPath -ErrorAction SilentlyContinue + Remove-Item -Force -LiteralPath $vhdPath -ErrorAction SilentlyContinue + try { + & (Join-Path $extract 'scripts/windows/install.ps1') ` + -SourceRoot $extract ` + -InstallRoot $installRoot ` + -VhdPath $vhdPath ` + -MountPath $mountPath ` + -SiteName 'CI Smoke Site' ` + -SizeGB 50 ` + -AllowPlainReFS ` + -FailOnRebootRequired ` + -SkipAutoMount + if ($LASTEXITCODE -ne 0) { + throw "Packaged install.ps1 failed with exit code $LASTEXITCODE" + } + $siteWorkDir = Join-Path $mountPath 'Sites/CI Smoke Site/.forkpress' + if (-not (Test-Path -LiteralPath (Join-Path $siteWorkDir 'site.toml'))) { + throw 'Packaged install did not create an initialized ForkPress site.' + } + & (Join-Path $installRoot 'bin/forkpress.exe') storage status --work-dir $siteWorkDir + if ($LASTEXITCODE -ne 0) { + throw "Installed forkpress.exe storage status failed with exit code $LASTEXITCODE" + } + } finally { + Dismount-DiskImage -ImagePath $vhdPath -ErrorAction SilentlyContinue | Out-Null + } + - uses: actions/upload-artifact@v4 with: name: forkpress-${{ matrix.target }} - path: forkpress-${{ matrix.target }}.tar.gz + path: | + forkpress-${{ matrix.target }}.tar.gz + forkpress-${{ matrix.target }}.zip + ForkPressSetup.exe retention-days: 14 release: @@ -109,5 +227,8 @@ jobs: merge-multiple: true - uses: softprops/action-gh-release@v2 with: - files: forkpress-*.tar.gz + files: | + forkpress-*.tar.gz + forkpress-*.zip + ForkPressSetup.exe generate_release_notes: true diff --git a/AGENTS.md b/AGENTS.md index 6cf882cc..be07abfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,8 @@ macOS release targets link only against `libSystem`: - `crates/forkpress-core/` contains shared layout, manifest, path, and storage strategy types. - `crates/forkpress-storage/` contains production COW branch storage: - APFS clonefile, APFS sparsebundle, Linux `FICLONE`, and file-copy fallback. + APFS clonefile, APFS sparsebundle, Linux `FICLONE`, Windows ReFS block clone, + and file-copy fallback. - `crates/forkpress-runtime/` contains embedded PHP/WordPress runtime preparation and PHP script execution. - `crates/forkpress-server/` contains the server process registry, stop/list @@ -41,6 +42,8 @@ macOS release targets link only against `libSystem`: - `runtime/` contains production COW runtime files and the WordPress archive embedded into the binary. - `scripts/` contains production/shared build, SQLite, Git, and COW helpers. +- `scripts/windows/` and `installer/windows/` contain the Windows runtime bundle + and click-through setup packaging. - `tests/` contains production COW PHP tests. - `experiments/branchfs/` contains the experimental BranchFS schema, PHP extension, runtime files, scripts, Git adapter, and tests. diff --git a/README.md b/README.md index 59d6e142..fc7a99e1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ database file, and branch creation uses filesystem copy-on-write when the machine can provide it. No Docker, no system PHP, no MySQL daemon, no FUSE service, and no helper -daemon. The release artifact is one `forkpress` binary per target. +daemon. The release artifact is one `forkpress` binary on macOS/Linux and a +click-through installer on Windows. ## Quick Start @@ -45,6 +46,22 @@ Stop the site server and detach any mount-backed COW storage: Download the archive for your machine from a release, unpack it, and run the binary. +Windows: + +1. Download `ForkPressSetup.exe` from a release. +2. Open it and follow the prompts. +3. Accept the Windows permission prompt. +4. Reboot only if Windows asks. +5. Open **Start ForkPress Site** from the desktop or Start Menu. + +The Windows installer installs protected program files under +`%ProgramFiles%\ForkPress`, creates a ReFS Dev Drive VHDX at +`%ProgramData%\ForkPress\Storage\forkpress-dev-drive.vhdx`, mounts it at +`%USERPROFILE%\ForkPressDevDrive`, adds `forkpress.exe` to the user PATH, creates +`%USERPROFILE%\ForkPressDevDrive\Sites\My ForkPress Site`, runs `forkpress init` +there, and creates shortcuts. It does not require WSL, Docker, FUSE, WinFsp, or +manual Windows feature setup. + macOS: ```bash @@ -64,6 +81,7 @@ chmod +x forkpress Release targets: +- `x86_64-pc-windows-msvc` - `aarch64-apple-darwin` - `x86_64-apple-darwin` - `aarch64-unknown-linux-musl` @@ -312,14 +330,18 @@ writes and do not participate in that lock. ForkPress tries the cheapest ordinary-file view first: 1. **Native filesystem cloning in the project directory.** On macOS this uses - APFS `clonefile`; on Linux this uses `FICLONE` reflinks. New branches share - unchanged file blocks with the source branch. Writes to a branch path do not - mutate the source path. + APFS `clonefile`; on Linux this uses `FICLONE` reflinks; on Windows this + uses ReFS block cloning when the project lives on a ReFS/Dev Drive volume. + New branches share unchanged file blocks with the source branch. Writes to a + branch path do not mutate the source path. 2. **Rootless APFS sparsebundle on macOS.** If the project volume cannot clone files, ForkPress creates `.forkpress/macos-cow/branches.sparsebundle`, mounts it at `.forkpress/macos-cow/mount`, stores the physical branch trees there, and exposes public branch directories like `./main` and `./marketing`. -3. **Full file copy.** This is the final fallback when COW storage is not +3. **Guided ReFS Dev Drive setup on Windows.** If a Windows project is not on + clone-capable storage, the Windows installer runs the Dev Drive setup flow + and creates ForkPress shortcuts into `%USERPROFILE%\ForkPressDevDrive`. +4. **Full file copy.** This is the final fallback when COW storage is not available. Inspect the selected file view: @@ -412,7 +434,8 @@ Production Rust packages live under `crates/`: - `forkpress-cli`: binaries and high-level command routing; - `forkpress-core`: shared layout, manifest, path, and strategy types; - `forkpress-storage`: production COW branch storage, including APFS - `clonefile`, APFS sparsebundle, Linux `FICLONE`, and file-copy fallback; + `clonefile`, APFS sparsebundle, Linux `FICLONE`, Windows ReFS block cloning, + and file-copy fallback; - `forkpress-runtime`: embedded PHP/WordPress runtime preparation and PHP script execution; - `forkpress-server`: server registry, stop/list, and TCP readiness helpers; diff --git a/crates/forkpress-cli/build.rs b/crates/forkpress-cli/build.rs index 4639f615..5edf4c1a 100644 --- a/crates/forkpress-cli/build.rs +++ b/crates/forkpress-cli/build.rs @@ -53,7 +53,11 @@ fn main() -> Result<()> { ); } - let required = "bin/php"; + let required = if target.contains("windows") { + "bin/php.exe" + } else { + "bin/php" + }; let path = dist_dir.join(required); if !path.is_file() { bail!("missing {} (required for runtime bundle)", path.display()); @@ -465,11 +469,7 @@ fn build_bundle( add_file(&mut tar, repo_root, "scripts/shared/sqlite_retry.php")?; } - add_file_as( - &mut tar, - &dist_dir.join("bin/php"), - "portable-runtime/bin/php", - )?; + add_tree_as(&mut tar, &dist_dir.join("bin"), "portable-runtime/bin")?; tar.finish()?; let encoder = tar.into_inner()?; @@ -495,13 +495,30 @@ fn add_tree(tar: &mut Builder>, repo_root: &Path, rel: &str) -> Ok(()) } -fn add_file(tar: &mut Builder>, repo_root: &Path, rel: &str) -> Result<()> { - let path = repo_root.join(rel); - tar.append_path_with_name(&path, rel)?; +fn add_tree_as( + tar: &mut Builder>, + source_root: &Path, + dest_root: &str, +) -> Result<()> { + for entry in WalkDir::new(source_root) { + let entry = entry?; + let path = entry.path(); + let rel_path = path.strip_prefix(source_root).with_context(|| { + format!("{} is not under {}", path.display(), source_root.display()) + })?; + let dest_path = Path::new(dest_root).join(rel_path); + + if entry.file_type().is_dir() { + tar.append_dir(&dest_path, path)?; + } else if entry.file_type().is_file() { + tar.append_path_with_name(path, &dest_path)?; + } + } Ok(()) } -fn add_file_as(tar: &mut Builder>, source: &Path, dest: &str) -> Result<()> { - tar.append_path_with_name(source, dest)?; +fn add_file(tar: &mut Builder>, repo_root: &Path, rel: &str) -> Result<()> { + let path = repo_root.join(rel); + tar.append_path_with_name(&path, rel)?; Ok(()) } diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index d587800f..b7c2209d 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -882,7 +882,16 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result { ); } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + println!(" Windows ReFS Dev Drive: recommended before file-copy fallback"); + println!(" setup: run ForkPressSetup.exe to create a ReFS Dev Drive VHDX"); + println!( + " recommendation: create/open a ForkPress project on that Dev Drive, then rerun forkpress init" + ); + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] { println!(" recommendation: file-copy materialization"); } diff --git a/crates/forkpress-runtime/src/lib.rs b/crates/forkpress-runtime/src/lib.rs index 597c29a2..65f9905f 100644 --- a/crates/forkpress-runtime/src/lib.rs +++ b/crates/forkpress-runtime/src/lib.rs @@ -19,7 +19,29 @@ impl PortableRuntime { pub fn from_layout(layout: &Layout) -> Self { let root = layout.runtime_dir.join("portable-runtime"); Self { - php: root.join("bin/php"), + php: root.join("bin").join(bundled_php_name()), + } + } +} + +fn bundled_php_name() -> &'static str { + if cfg!(target_os = "windows") { + "php.exe" + } else { + "php" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_php_name_matches_target_platform() { + if cfg!(target_os = "windows") { + assert_eq!(bundled_php_name(), "php.exe"); + } else { + assert_eq!(bundled_php_name(), "php"); } } } diff --git a/crates/forkpress-server/src/lib.rs b/crates/forkpress-server/src/lib.rs index 46625685..bf08814b 100644 --- a/crates/forkpress-server/src/lib.rs +++ b/crates/forkpress-server/src/lib.rs @@ -1,8 +1,12 @@ use anyhow::{Context, Result, bail}; use forkpress_core::Layout; -use std::fs::{self, File, OpenOptions}; +use std::fs; +#[cfg(unix)] +use std::fs::{File, OpenOptions}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command; use std::process::{Child, ExitStatus}; use std::thread; use std::time::{Duration, Instant}; @@ -36,6 +40,13 @@ pub struct ServerRegistrationGuard { layout: Layout, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ServerSignal { + Interrupt, + Terminate, + Kill, +} + #[cfg(unix)] struct ServerRegistryLock { file: File, @@ -170,11 +181,11 @@ pub fn stop_server_record(record: &ServerRecord, timeout: Duration) -> Result<() return Ok(()); } - signal_server_record(record, libc::SIGINT)?; + signal_server_record(record, ServerSignal::Interrupt)?; if !wait_for_record_exit(record, timeout) { - signal_server_record(record, libc::SIGTERM)?; + signal_server_record(record, ServerSignal::Terminate)?; if !wait_for_record_exit(record, Duration::from_secs(2)) { - signal_server_record(record, libc::SIGKILL)?; + signal_server_record(record, ServerSignal::Kill)?; let _ = wait_for_record_exit(record, Duration::from_secs(2)); } } @@ -367,6 +378,12 @@ fn server_registry_path() -> PathBuf { if let Some(dir) = std::env::var_os("FORKPRESS_STATE_DIR") { return PathBuf::from(dir).join(SERVER_REGISTRY_FILE); } + #[cfg(windows)] + if let Some(dir) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(dir) + .join("ForkPress") + .join(SERVER_REGISTRY_FILE); + } if let Some(dir) = std::env::var_os("XDG_STATE_HOME") { return PathBuf::from(dir) .join("forkpress") @@ -379,10 +396,13 @@ fn server_registry_path() -> PathBuf { } std::env::temp_dir().join(format!( "forkpress-{}-{SERVER_REGISTRY_FILE}", - std::env::var("USER").unwrap_or_else(|_| "user".to_string()) + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "user".to_string()) )) } +#[cfg(unix)] fn server_registry_lock_path() -> PathBuf { server_registry_path().with_extension("tsv.lock") } @@ -402,6 +422,7 @@ fn record_process_exists(record: &ServerRecord) -> bool { process_exists(record.pid) || record.child_pid.map(process_exists).unwrap_or(false) } +#[cfg(unix)] fn process_exists(pid: u32) -> bool { if pid == 0 { return false; @@ -416,7 +437,38 @@ fn process_exists(pid: u32) -> bool { ) } -fn signal_server_record(record: &ServerRecord, signal: i32) -> Result<()> { +#[cfg(windows)] +fn process_exists(pid: u32) -> bool { + if pid == 0 { + return false; + } + + use std::ffi::c_void; + + const ERROR_ACCESS_DENIED: u32 = 5; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess( + dw_desired_access: u32, + b_inherit_handle: i32, + dw_process_id: u32, + ) -> *mut c_void; + fn CloseHandle(h_object: *mut c_void) -> i32; + fn GetLastError() -> u32; + } + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return unsafe { GetLastError() } == ERROR_ACCESS_DENIED; + } + let _ = unsafe { CloseHandle(handle) }; + true +} + +#[cfg(unix)] +fn signal_server_record(record: &ServerRecord, signal: ServerSignal) -> Result<()> { let mut signaled_group = false; if process_exists(record.pid) { if process_group_id(record.pid) == Some(record.pid) { @@ -441,6 +493,20 @@ fn signal_server_record(record: &ServerRecord, signal: i32) -> Result<()> { Ok(()) } +#[cfg(windows)] +fn signal_server_record(record: &ServerRecord, signal: ServerSignal) -> Result<()> { + if let Some(child_pid) = record.child_pid + && process_exists(child_pid) + { + signal_process(child_pid, signal)?; + } + if process_exists(record.pid) { + signal_process(record.pid, signal)?; + } + Ok(()) +} + +#[cfg(unix)] fn process_group_id(pid: u32) -> Option { if pid == 0 { return None; @@ -449,11 +515,12 @@ fn process_group_id(pid: u32) -> Option { if pgid < 0 { None } else { Some(pgid as u32) } } -fn signal_process_group(pgid: u32, signal: i32) -> Result<()> { +#[cfg(unix)] +fn signal_process_group(pgid: u32, signal: ServerSignal) -> Result<()> { if pgid == 0 { return Ok(()); } - let rc = unsafe { libc::kill(-(pgid as libc::pid_t), signal) }; + let rc = unsafe { libc::kill(-(pgid as libc::pid_t), unix_signal(signal)) }; if rc == 0 { return Ok(()); } @@ -467,8 +534,9 @@ fn signal_process_group(pgid: u32, signal: i32) -> Result<()> { .with_context(|| format!("failed to signal process group {pgid}")) } -fn signal_process(pid: u32, signal: i32) -> Result<()> { - let rc = unsafe { libc::kill(pid as libc::pid_t, signal) }; +#[cfg(unix)] +fn signal_process(pid: u32, signal: ServerSignal) -> Result<()> { + let rc = unsafe { libc::kill(pid as libc::pid_t, unix_signal(signal)) }; if rc == 0 { return Ok(()); } @@ -481,6 +549,55 @@ fn signal_process(pid: u32, signal: i32) -> Result<()> { Err(std::io::Error::last_os_error()).with_context(|| format!("failed to signal process {pid}")) } +#[cfg(unix)] +fn unix_signal(signal: ServerSignal) -> i32 { + match signal { + ServerSignal::Interrupt => libc::SIGINT, + ServerSignal::Terminate => libc::SIGTERM, + ServerSignal::Kill => libc::SIGKILL, + } +} + +#[cfg(windows)] +fn signal_process(pid: u32, signal: ServerSignal) -> Result<()> { + let mut command = Command::new("taskkill"); + command.arg("/PID").arg(pid.to_string()).arg("/T"); + if signal == ServerSignal::Kill { + command.arg("/F"); + } + let output = command.output().context("failed to run taskkill")?; + if output.status.success() { + return Ok(()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase(); + if combined.contains("not found") + || combined.contains("not running") + || combined.contains("no running instance") + { + return Ok(()); + } + bail!( + "taskkill failed for pid {} with status {}{}{}{}{}", + pid, + output.status, + if stdout.trim().is_empty() { + "" + } else { + "\nstdout:\n" + }, + stdout.trim(), + if stderr.trim().is_empty() { + "" + } else { + "\nstderr:\n" + }, + stderr.trim() + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index 0cf054fc..d6d2fdf2 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -1,14 +1,21 @@ use anyhow::{Context, Result, anyhow, bail}; #[cfg(target_os = "macos")] use std::ffi::CString; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsStr; +#[cfg(target_os = "macos")] +use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; +#[cfg(target_os = "windows")] +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(target_os = "macos")] +use forkpress_core::absolutize; use forkpress_core::{ - FileViewStrategy, Layout, SharedPaths, StorageStrategy, absolutize, path_exists_no_follow, + FileViewStrategy, Layout, SharedPaths, StorageStrategy, path_exists_no_follow, read_site_manifest, validate_branch_name, write_site_manifest, }; use forkpress_runtime::{PortableRuntime, run_php_script}; @@ -34,11 +41,13 @@ streams, SQL COW views, tombstones, triggers, or per-branch table prefixes. Branch creation uses the file view recorded in `.forkpress/site.toml`. ForkPress first tries materialized COW branch directories with host filesystem -clone primitives (Linux `FICLONE`, macOS `clonefile`). On macOS, if the current -location cannot clone files, ForkPress creates a rootless APFS sparsebundle -under `.forkpress/macos-cow`, mounts it at `.forkpress/macos-cow/mount`, and -links each public branch directory, such as `./main`, into that APFS volume. A -regular full copy is only the last-resort file view. +clone primitives (Linux `FICLONE`, macOS `clonefile`, Windows ReFS block +clone). On macOS, if the current location cannot clone files, ForkPress creates +a rootless APFS sparsebundle under `.forkpress/macos-cow`, mounts it at +`.forkpress/macos-cow/mount`, and links each public branch directory, such as +`./main`, into that APFS volume. On Windows, put the site on a ReFS Dev Drive +created by `ForkPressSetup.exe`. A regular full copy is only the last-resort +file view on platforms where ForkPress can make that tradeoff explicit. APFS clone sharing is not visible to tools that add up path sizes. `du`, Finder, and many disk analyzers can count shared clone extents once for every branch, so @@ -167,6 +176,13 @@ pub fn prepare_cow_file_view(layout: &Layout) -> Result { return Ok(FileViewStrategy::Reflink); } + #[cfg(target_os = "windows")] + { + bail!( + "Windows COW storage requires ReFS block cloning. Run ForkPress Setup to create a Dev Drive, then create the site under %USERPROFILE%\\ForkPressDevDrive." + ); + } + #[cfg(target_os = "macos")] { match prepare_macos_apfs_sparsebundle_file_view(layout) { @@ -178,7 +194,10 @@ pub fn prepare_cow_file_view(layout: &Layout) -> Result { } } - Ok(FileViewStrategy::Copy) + #[cfg(not(target_os = "windows"))] + { + Ok(FileViewStrategy::Copy) + } } pub fn ensure_cow_file_view_ready(layout: &Layout) -> Result { @@ -715,7 +734,8 @@ pub fn probe_reflink_dir(dir: &Path) -> Result { let result = (|| -> Result { let source = probe_dir.join("source.txt"); let dest = probe_dir.join("dest.txt"); - fs::write(&source, b"canonical") + let source_bytes = reflink_probe_bytes(); + fs::write(&source, &source_bytes) .with_context(|| format!("failed to write {}", source.display()))?; if try_clone_file(&source, &dest).is_err() { @@ -726,13 +746,29 @@ pub fn probe_reflink_dir(dir: &Path) -> Result { .with_context(|| format!("failed to write {}", dest.display()))?; let canonical = fs::read(&source).with_context(|| format!("failed to read {}", source.display()))?; - Ok(canonical == b"canonical") + Ok(canonical == source_bytes) })(); let _ = fs::remove_dir_all(&probe_dir); result } +fn reflink_probe_bytes() -> Vec { + #[cfg(target_os = "windows")] + { + let mut bytes = vec![0; (WINDOWS_REFS_CLONE_ALIGNMENT * 2) as usize]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = (index % 251) as u8; + } + bytes + } + + #[cfg(not(target_os = "windows"))] + { + b"canonical".to_vec() + } +} + #[cfg(unix)] pub struct CowOperationLock { file: File, @@ -1404,6 +1440,10 @@ enum TreeCloneMode { RequireCow, } +const WINDOWS_REFS_CLONE_ALIGNMENT: u64 = 64 * 1024; +#[cfg(target_os = "windows")] +const WINDOWS_REFS_MAX_CLONE_CHUNK: u64 = 1024 * 1024 * 1024; + fn copy_tree_cow_mode(source: &Path, dest: &Path, mode: TreeCloneMode) -> Result<()> { if !source.is_dir() { bail!("source directory not found: {}", source.display()); @@ -1494,15 +1534,138 @@ fn try_clone_file(source: &Path, dest: &Path) -> Result<()> { } } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(target_os = "windows")] +fn try_clone_file(source: &Path, dest: &Path) -> Result<()> { + let mut src = File::open(source)?; + let mut dst = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(dest)?; + let source_len = src.metadata()?.len(); + dst.set_len(source_len)?; + + let (clone_len, tail_len) = windows_refs_clone_plan(source_len); + if clone_len > 0 + && let Err(err) = windows_refs_duplicate_extents(&src, &dst, clone_len) + { + let _ = fs::remove_file(dest); + return Err(err); + } + + if tail_len > 0 + && let Err(err) = windows_copy_uncloned_tail(&mut src, &mut dst, clone_len, tail_len) + { + let _ = fs::remove_file(dest); + return Err(err); + } + + Ok(()) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn windows_refs_clone_plan(file_len: u64) -> (u64, u64) { + let clone_len = file_len / WINDOWS_REFS_CLONE_ALIGNMENT * WINDOWS_REFS_CLONE_ALIGNMENT; + let tail_len = file_len - clone_len; + (clone_len, tail_len) +} + +#[cfg(target_os = "windows")] +fn windows_refs_duplicate_extents(source: &File, dest: &File, clone_len: u64) -> Result<()> { + use std::ffi::c_void; + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + + const FSCTL_DUPLICATE_EXTENTS_TO_FILE: u32 = 0x0009_8344; + + #[repr(C)] + struct DuplicateExtentsData { + file_handle: *mut c_void, + source_file_offset: i64, + target_file_offset: i64, + byte_count: i64, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn DeviceIoControl( + h_device: *mut c_void, + dw_io_control_code: u32, + lp_in_buffer: *mut c_void, + n_in_buffer_size: u32, + lp_out_buffer: *mut c_void, + n_out_buffer_size: u32, + lp_bytes_returned: *mut u32, + lp_overlapped: *mut c_void, + ) -> i32; + } + + let mut offset = 0; + while offset < clone_len { + let chunk_len = (clone_len - offset).min(WINDOWS_REFS_MAX_CLONE_CHUNK); + let mut request = DuplicateExtentsData { + file_handle: source.as_raw_handle(), + source_file_offset: i64::try_from(offset) + .context("source offset exceeds Windows LARGE_INTEGER range")?, + target_file_offset: i64::try_from(offset) + .context("target offset exceeds Windows LARGE_INTEGER range")?, + byte_count: i64::try_from(chunk_len) + .context("clone length exceeds Windows LARGE_INTEGER range")?, + }; + let mut bytes_returned = 0u32; + let ok = unsafe { + DeviceIoControl( + dest.as_raw_handle(), + FSCTL_DUPLICATE_EXTENTS_TO_FILE, + &mut request as *mut _ as *mut c_void, + size_of::() as u32, + std::ptr::null_mut(), + 0, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()) + .context("FSCTL_DUPLICATE_EXTENTS_TO_FILE failed"); + } + offset += chunk_len; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_copy_uncloned_tail( + source: &mut File, + dest: &mut File, + offset: u64, + tail_len: u64, +) -> Result<()> { + source.seek(SeekFrom::Start(offset))?; + dest.seek(SeekFrom::Start(offset))?; + + let mut remaining = tail_len; + let mut buffer = vec![0; WINDOWS_REFS_CLONE_ALIGNMENT as usize]; + while remaining > 0 { + let read_len = remaining.min(buffer.len() as u64) as usize; + source.read_exact(&mut buffer[..read_len])?; + dest.write_all(&buffer[..read_len])?; + remaining -= read_len as u64; + } + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn try_clone_file(_source: &Path, _dest: &Path) -> Result<()> { bail!("platform file clone unsupported") } +#[cfg(target_os = "macos")] fn shell_quote_path(path: &std::path::Path) -> String { shell_quote(&path.to_string_lossy()) } +#[cfg(target_os = "macos")] fn shell_quote(value: &str) -> String { if value .chars() @@ -1555,4 +1718,31 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + + #[test] + fn windows_refs_clone_plan_keeps_unaligned_tail_out_of_ioctl() { + assert_eq!(windows_refs_clone_plan(0), (0, 0)); + assert_eq!(windows_refs_clone_plan(1), (0, 1)); + assert_eq!( + windows_refs_clone_plan(WINDOWS_REFS_CLONE_ALIGNMENT - 1), + (0, WINDOWS_REFS_CLONE_ALIGNMENT - 1) + ); + assert_eq!( + windows_refs_clone_plan(WINDOWS_REFS_CLONE_ALIGNMENT), + (WINDOWS_REFS_CLONE_ALIGNMENT, 0) + ); + assert_eq!( + windows_refs_clone_plan(WINDOWS_REFS_CLONE_ALIGNMENT + 3), + (WINDOWS_REFS_CLONE_ALIGNMENT, 3) + ); + } + + #[test] + fn windows_refs_probe_bytes_are_large_enough_for_block_clone() { + let bytes = reflink_probe_bytes(); + #[cfg(target_os = "windows")] + assert_eq!(bytes.len() as u64, WINDOWS_REFS_CLONE_ALIGNMENT * 2); + #[cfg(not(target_os = "windows"))] + assert_eq!(bytes, b"canonical"); + } } diff --git a/docs/lazy-overlay-cow.md b/docs/lazy-overlay-cow.md index a1e2d656..ee7e9c00 100644 --- a/docs/lazy-overlay-cow.md +++ b/docs/lazy-overlay-cow.md @@ -1,6 +1,6 @@ # Lazy Overlay COW Options -Status: 2026-05-04 +Status: 2026-05-11 This note records the current storage decision. ForkPress is staying with the macOS APFS COW file view for now. Native lazy overlay filesystems are useful @@ -16,8 +16,9 @@ Use the current **materialized COW** strategy: - `forkpress init` creates `.forkpress/` and `./main`; - `forkpress branch create marketing` creates `./marketing`; -- file contents are cloned with APFS `clonefile` on macOS and `FICLONE` - reflinks on Linux when possible; +- file contents are cloned with APFS `clonefile` on macOS, `FICLONE` + reflinks on Linux, and ReFS block cloning on Windows Dev Drive/ReFS volumes + when possible; - branch-local SQLite databases are normal files; - the sparsebundle fallback is still allowed when the project directory is not on an APFS volume that supports file clones. @@ -87,7 +88,7 @@ be correct enough for PHP, Git, editors, and shell tools. | Approach | User flow | Requirements | Runtime footprint | Benefits | Downsides | Fit now | | --- | --- | --- | --- | --- | --- | --- | -| Materialized COW | Download one binary, run `./forkpress init`, `./forkpress serve`. | macOS APFS `clonefile`, Linux `FICLONE`, APFS sparsebundle fallback, or full file copy. | One ForkPress process while serving. Optional mounted sparsebundle managed by `serve`/`stop`. | Normal directories, normal files, no install prompts, good editor/PHP/Git compatibility, branch writes do not affect parents. | Branch creation walks every file. Every branch has a materialized namespace. `du` can over-count cloned files. | Product path. | +| Materialized COW | Download one binary on macOS/Linux and run `forkpress init`, or run `ForkPressSetup.exe` on Windows and open **Start ForkPress Site**. | macOS APFS `clonefile`, Linux `FICLONE`, Windows ReFS block clone, APFS sparsebundle fallback, Windows ReFS Dev Drive setup, or full file copy. | One ForkPress process while serving. Optional mounted sparsebundle or Dev Drive VHDX managed by the OS. | Normal directories, normal files, no WSL/Docker/FUSE dependency, good editor/PHP/Git compatibility, branch writes do not affect parents. | Branch creation walks every file. Every branch has a materialized namespace. `du`, Finder, and Explorer can over-count cloned files. | Product path. | | Embedded loopback NFS lazy overlay | `forkpress serve` starts an in-process local NFS server and mounts branch views. | macOS built-in NFS client. Mount may require `sudo` or a privileged helper depending mount location and options. | ForkPress process must stay alive as filesystem server. Mounted volume lifecycle must be managed. | Keeps one-binary story better than FUSE/FSKit. Can expose lazy branch directories to normal tools. | NFSv4 server implementation is substantial. File locking, cache invalidation, xattrs, permissions, rename semantics, and SQLite safety are high risk. | Best future experiment if lazy mounts become necessary. | | macFUSE lazy overlay | Install/approve macFUSE, then run ForkPress mount. | Third-party macFUSE install and system extension approval. | ForkPress or helper process implements the filesystem through FUSE. | Familiar userspace filesystem model. Faster to prototype than NFS or FSKit. | Breaks no-dependency product shape. User approval/install friction. Kernel/system extension issues vary by macOS version. | Prototype only, not product path. | | Apple FSKit lazy overlay | Install a signed app/extension, enable filesystem extension, then mount. | macOS FSKit support, app extension bundle, `Info.plist`, entitlements such as FSKit module entitlement, code signing, likely notarization. | App extension plus container app/helper. Mount managed through macOS filesystem extension infrastructure. | Native Apple-supported userspace filesystem route. No macFUSE dependency. Integrates with system mount tooling. | Not a single Rust binary. Requires Apple packaging, signing, entitlements, and extension approval flow. Still must implement overlay semantics ourselves. | Too much for this milestone. Do not pursue now. | @@ -114,7 +115,11 @@ architecture. Known gaps: from editors, shells, and other tools still bypass that advisory lock. - Sparsebundle detach can still be blocked by terminals, editors, or processes holding files open under the mounted path. -- Windows does not have COW parity yet. +- Windows supports the native materialized COW tier when the project lives on a + ReFS/Dev Drive volume. The release workflow builds a Windows installer and zip + package, smoke-tests the package, and signs artifacts when code-signing secrets + are configured. A native lazy branch namespace such as ProjFS remains a future + experiment. - There is no content-aware storage garbage collection beyond ordinary branch deletion. Sparsebundle-backed sites can reclaim detached free space with `forkpress storage compact`. diff --git a/docs/storage-drivers.md b/docs/storage-drivers.md index d226c1e2..fd7a2ca9 100644 --- a/docs/storage-drivers.md +++ b/docs/storage-drivers.md @@ -1,6 +1,6 @@ # Storage Driver Notes -Status: 2026-05-06 +Status: 2026-05-11 ForkPress production builds are focused on the **COW materialized driver**. Other drivers are compiled only into `forkpress-dev`. @@ -11,6 +11,8 @@ or installed database server. For the native-mount/lazy-overlay evaluation, see [`docs/lazy-overlay-cow.md`](lazy-overlay-cow.md). +For the Windows ReFS/Dev Drive setup flow, see +[`docs/windows-cow.md`](windows-cow.md). ## Driver Summary @@ -84,13 +86,20 @@ directory namespace for each branch. 1. **Native file clone in place.** The branch directories live directly in the project directory. Branch creation uses APFS `clonefile` on macOS and Linux - `FICLONE` reflinks on Linux, so unchanged blocks are shared until a branch - writes to them. + `FICLONE` reflinks on Linux. On Windows, ForkPress uses ReFS block cloning + when the project directory is on a ReFS/Dev Drive volume. Unchanged blocks + are shared until a branch writes to them. 2. **Rootless APFS sparsebundle on macOS.** If the project volume cannot clone files, ForkPress creates `.forkpress/macos-cow/branches.sparsebundle`, mounts it at `.forkpress/macos-cow/mount`, and symlinks public branch directories to the APFS-backed physical branch trees. -3. **Full file copy.** This is the last-resort fallback. +3. **Guided ReFS Dev Drive setup on Windows.** If a Windows project is not on + clone-capable storage, the Windows installer prompts for elevation, creates a + dynamic VHDX under `%ProgramData%`, formats it as ReFS/Dev Drive, registers + logon auto-mount, mounts it at `%USERPROFILE%\ForkPressDevDrive`, creates the + first site, and initializes ForkPress there. Projects created there can use + the native file clone tier. +4. **Full file copy.** This is the last-resort fallback. `du`, Finder, and many disk analyzers can over-count cloned files because they sum path sizes rather than unique allocated extents. On sparsebundle-backed @@ -214,7 +223,9 @@ Still future work: ordinary branch deletion, COW Git object pruning, and sparsebundle compaction; - filesystem-level coordination for direct external writes that bypass the ForkPress HTTP server; -- Windows support. +- real-machine validation for the signed Windows installer path; +- ProjFS or another native lazy branch namespace for Windows when materialized + ReFS COW is not enough. ## ZFS Status diff --git a/docs/windows-cow.md b/docs/windows-cow.md new file mode 100644 index 00000000..cc8220e2 --- /dev/null +++ b/docs/windows-cow.md @@ -0,0 +1,105 @@ +# Windows COW Setup + +Status: 2026-05-11 + +ForkPress should not ask Windows users to install WSL, Docker, FUSE, WinFsp, or +developer-only storage tools before they can create cheap COW branches. The +Windows path is native ReFS block cloning on a Dev Drive. + +## Intended User Flow + +For a fresh Windows laptop: + +1. Download `ForkPressSetup.exe`. +2. Open it. +3. Accept the Windows permission prompt. +4. Reboot only if Windows asks. +5. Open **Start ForkPress Site** from the desktop or Start Menu. + +The installer runs the same storage setup script a developer can run manually +from an elevated PowerShell session: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\windows\setup-dev-drive.ps1 +``` + +By default it creates: + +```text +%ProgramData%\ForkPress\Storage\forkpress-dev-drive.vhdx +%USERPROFILE%\ForkPressDevDrive\ +``` + +The VHDX is dynamic, so the file grows with real data rather than immediately +allocating the configured maximum size. The default maximum is 128 GB because +Windows Dev Drive volumes have a 50 GB minimum. + +The installer also: + +- installs `forkpress.exe` and setup scripts under `%ProgramFiles%\ForkPress`; +- adds that directory to the current user's `PATH`; +- installs the Microsoft Visual C++ Redistributable needed by the official PHP + for Windows runtime from the redistributable bundled in the ForkPress package; +- registers a scheduled task that reattaches the VHDX after logon; +- creates `%USERPROFILE%\ForkPressDevDrive\Sites\My ForkPress Site`; +- runs `forkpress init` in that site folder; +- creates **Start ForkPress Site**, **ForkPress Shell**, and **ForkPress Dev + Drive** shortcuts. + +The elevated Dev Drive path does not execute PowerShell scripts from +user-writable storage. The installer runs from an elevated Program Files install, +the VHDX backing file lives under admin-writable ProgramData storage, and the +logon auto-mount task stores a fixed command instead of pointing at a mutable +script file. + +## Storage Cascade + +`forkpress init` probes the actual project directory instead of trusting the OS +name: + +1. If the current directory supports file clones, use materialized COW branches + in place. +2. On Windows, the clone primitive is ReFS block cloning through + `FSCTL_DUPLICATE_EXTENTS_TO_FILE`. +3. If the current Windows directory cannot clone files, fail closed and tell the + user to run ForkPress Setup. Windows should not silently initialize a large + full-copy site on NTFS. +4. ProjFS remains the next Windows-native lazy namespace candidate, but it is + an optional Windows component and needs a separate provider implementation. +5. Full file copy is the terminal fallback, not the first fallback. + +## Why ReFS Dev Drive First + +ReFS block cloning gives ForkPress ordinary Win32 paths. Editors, PHP, Git, +WP-CLI, backup tools, and shell commands can read and write branch files without +knowing about ForkPress. Writes to a cloned branch file do not mutate the source +branch because ReFS performs allocate-on-write at the cluster level. + +That matches the current materialized COW model on macOS and Linux: + +```text +main\wp-load.php shares ReFS clusters with +marketing\wp-load.php until one side writes +``` + +## Current Boundaries + +- The Windows implementation supports materialized COW, not lazy + namespace COW. Branch creation still walks the source tree and creates a full + directory namespace. +- The installer path is designed for Windows 11 systems with Dev Drive support. + Older Windows builds fail with a clear update/reboot message instead of + falling back to a huge copy. +- Release builds can Authenticode-sign `forkpress.exe` and `ForkPressSetup.exe` + when `WINDOWS_CODESIGN_CERT_BASE64` and `WINDOWS_CODESIGN_PASSWORD` are set in + GitHub Actions secrets. Without those secrets, PR builds produce unsigned + smoke-tested artifacts. +- ProjFS support is not implemented yet. +- Semantic database merge is separate from the file storage strategy. + +## References + +- Windows Dev Drive setup: +- ReFS block cloning: +- `FSCTL_DUPLICATE_EXTENTS_TO_FILE`: +- Enabling ProjFS: diff --git a/installer/windows/ForkPress.iss b/installer/windows/ForkPress.iss new file mode 100644 index 00000000..d9921fee --- /dev/null +++ b/installer/windows/ForkPress.iss @@ -0,0 +1,59 @@ +#define AppName "ForkPress" +#ifndef SourceDir +#define SourceDir "." +#endif +#ifndef AppVersion +#define AppVersion "0.1.13" +#endif + +[Setup] +AppId={{7E38BFD2-1426-4C58-A541-9C76E4379E03} +AppName={#AppName} +AppVersion={#AppVersion} +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +DefaultDirName={autopf}\ForkPress +DefaultGroupName=ForkPress +DisableProgramGroupPage=yes +OutputBaseFilename=ForkPressSetup +PrivilegesRequired=admin +UninstallDisplayIcon={app}\bin\forkpress.exe +WizardStyle=modern + +[Files] +Source: "{#SourceDir}\forkpress.exe"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "{#SourceDir}\vendor\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion +Source: "{#SourceDir}\scripts\windows\install.ps1"; DestDir: "{app}\scripts\windows"; Flags: ignoreversion +Source: "{#SourceDir}\scripts\windows\setup-dev-drive.ps1"; DestDir: "{app}\scripts\windows"; Flags: ignoreversion +Source: "{#SourceDir}\README-WINDOWS.md"; DestDir: "{app}"; Flags: ignoreversion + +[Code] +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; + PowerShell: String; + Parameters: String; +begin + if CurStep <> ssPostInstall then + Exit; + + PowerShell := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'); + Parameters := + '-NoProfile -ExecutionPolicy Bypass -File "' + + ExpandConstant('{app}\scripts\windows\install.ps1') + + '" -SourceRoot "' + ExpandConstant('{app}') + + '" -InstallRoot "' + ExpandConstant('{app}') + '"'; + + WizardForm.StatusLabel.Caption := 'Preparing ForkPress Dev Drive storage...'; + if not Exec(PowerShell, Parameters, '', SW_SHOW, ewWaitUntilTerminated, ResultCode) then + begin + MsgBox('ForkPress setup could not start. Run ForkPressSetup.exe again, or see ' + ExpandConstant('{app}\Logs') + ' for details.', mbError, MB_OK); + Abort; + end; + + if ResultCode <> 0 then + begin + MsgBox('ForkPress setup failed with exit code ' + IntToStr(ResultCode) + '. See ' + ExpandConstant('{app}\Logs') + ' for details.', mbError, MB_OK); + Abort; + end; +end; diff --git a/scripts/windows/build-dist.ps1 b/scripts/windows/build-dist.ps1 new file mode 100644 index 00000000..c3c2f189 --- /dev/null +++ b/scripts/windows/build-dist.ps1 @@ -0,0 +1,104 @@ +<# +.SYNOPSIS +Builds the Windows runtime bundle input consumed by forkpress.exe. + +.DESCRIPTION +ForkPress embeds PHP into its Rust binary. Linux/macOS use static-php-cli; +Windows uses the official PHP for Windows NTS x64 zip and writes a php.ini that +enables the extensions needed by the WordPress/SQLite runtime. +#> + +[CmdletBinding()] +param( + [string] $Target = $env:FORKPRESS_TARGET, + [string] $PhpZipUrl = $env:FORKPRESS_WINDOWS_PHP_ZIP_URL, + [string] $DistDir = $env:FORKPRESS_DIST_DIR, + [string] $BuildDir = $env:FORKPRESS_BUILD_DIR +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($Target)) { + $Target = 'x86_64-pc-windows-msvc' +} +if ([string]::IsNullOrWhiteSpace($PhpZipUrl)) { + $PhpZipUrl = 'https://windows.php.net/downloads/releases/latest/php-8.3-nts-Win32-vs16-x64-latest.zip' +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +if ([string]::IsNullOrWhiteSpace($DistDir)) { + $DistDir = Join-Path $repoRoot "dist\$Target" +} +if ([string]::IsNullOrWhiteSpace($BuildDir)) { + $BuildDir = Join-Path $repoRoot ".build\$Target" +} + +$binDir = Join-Path $DistDir 'bin' +$phpZip = Join-Path $BuildDir 'php-windows.zip' +$phpExtract = Join-Path $BuildDir 'php' + +New-Item -ItemType Directory -Force -Path $BuildDir, $binDir | Out-Null + +if (-not (Test-Path -LiteralPath $phpZip)) { + Write-Host "==> Downloading PHP for Windows" + Invoke-WebRequest -Uri $PhpZipUrl -OutFile $phpZip +} + +if (-not (Test-Path -LiteralPath (Join-Path $phpExtract 'php.exe'))) { + Write-Host "==> Extracting PHP" + if (Test-Path -LiteralPath $phpExtract) { + Remove-Item -Recurse -Force -LiteralPath $phpExtract + } + New-Item -ItemType Directory -Force -Path $phpExtract | Out-Null + Expand-Archive -Path $phpZip -DestinationPath $phpExtract +} + +Write-Host "==> Copying PHP runtime" +Remove-Item -Recurse -Force -LiteralPath $binDir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +Copy-Item -Recurse -Force -Path (Join-Path $phpExtract '*') -Destination $binDir + +$extensions = @( + 'curl', + 'exif', + 'fileinfo', + 'mbstring', + 'openssl', + 'pdo_sqlite', + 'sqlite3', + 'zip' +) + +$phpIni = @( + 'memory_limit=512M', + 'max_execution_time=120', + 'extension_dir=ext' +) +foreach ($extension in $extensions) { + $dll = Join-Path $binDir "ext\php_$extension.dll" + if (Test-Path -LiteralPath $dll) { + $phpIni += "extension=$extension" + } +} +$phpIni += @( + 'variables_order=GPCS', + 'date.timezone=UTC' +) +Set-Content -Path (Join-Path $binDir 'php.ini') -Value $phpIni -Encoding ASCII + +$php = Join-Path $binDir 'php.exe' +$requiredModules = @('PDO', 'pdo_sqlite', 'sqlite3', 'curl', 'mbstring', 'openssl', 'zip') +$modules = & $php -m +if ($LASTEXITCODE -ne 0) { + throw "Bundled php.exe failed to run. Install the Microsoft Visual C++ Redistributable or use scripts\windows\install.ps1." +} +foreach ($module in $requiredModules) { + if ($modules -notcontains $module) { + throw "Bundled php.exe is missing required module: $module" + } +} + +Write-Host "dist/$Target/ ready:" +Get-Item $php | Format-List FullName,Length +Write-Host '' +Write-Host 'Next: cargo build --release --target x86_64-pc-windows-msvc -p forkpress-cli --bin forkpress' diff --git a/scripts/windows/install.ps1 b/scripts/windows/install.ps1 new file mode 100644 index 00000000..725d4acc --- /dev/null +++ b/scripts/windows/install.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS +Installs ForkPress for the current Windows user and prepares COW storage. +#> + +[CmdletBinding()] +param( + [string] $SourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')), + [string] $InstallRoot = "$env:LOCALAPPDATA\ForkPress", + [string] $VhdPath = "$env:ProgramData\ForkPress\Storage\forkpress-dev-drive.vhdx", + [string] $MountPath = "$env:USERPROFILE\ForkPressDevDrive", + [string] $SiteName = 'My ForkPress Site', + [UInt32] $SizeGB = 128, + [switch] $AllowPlainReFS, + [switch] $FailOnRebootRequired, + [switch] $SkipAutoMount, + [switch] $SkipDevDrive +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string] $Message) + Write-Host '' + Write-Host "==> $Message" +} + +function New-Shortcut { + param( + [string] $Path, + [string] $TargetPath, + [string] $Arguments = '', + [string] $WorkingDirectory = '' + ) + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($Path) + $shortcut.TargetPath = $TargetPath + $shortcut.Arguments = $Arguments + if ($WorkingDirectory) { + $shortcut.WorkingDirectory = $WorkingDirectory + } + $shortcut.Save() +} + +function Add-UserPath { + param([string] $PathToAdd) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @() + if ($current) { + $entries = $current -split ';' | Where-Object { $_ } + } + if ($entries -notcontains $PathToAdd) { + $next = ($entries + $PathToAdd) -join ';' + [Environment]::SetEnvironmentVariable('Path', $next, 'User') + } + $env:Path = "$PathToAdd;$env:Path" +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-VcRedistInstalled { + $keys = @( + 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\X64' + ) + + foreach ($key in $keys) { + $runtime = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue + if ($runtime -and $runtime.Installed -eq 1) { + return $true + } + } + + return $false +} + +function ConvertTo-PowerShellEncodedCommand { + param([string] $Command) + + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) +} + +function ConvertTo-PowerShellSingleQuotedString { + param([string] $Value) + + return "'$($Value -replace "'", "''")'" +} + +function Register-ForkPressSetupResume { + param( + [string] $InstallScript, + [string] $SourceRoot, + [string] $InstallRoot, + [string] $VhdPath, + [string] $MountPath, + [string] $SiteName, + [UInt32] $SizeGB, + [switch] $AllowPlainReFS, + [switch] $FailOnRebootRequired, + [switch] $SkipAutoMount + ) + + $args = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$InstallScript`"", + '-SourceRoot', "`"$SourceRoot`"", + '-InstallRoot', "`"$InstallRoot`"", + '-VhdPath', "`"$VhdPath`"", + '-MountPath', "`"$MountPath`"", + '-SiteName', "`"$SiteName`"", + '-SizeGB', "$SizeGB" + ) + if ($AllowPlainReFS) { + $args += '-AllowPlainReFS' + } + if ($FailOnRebootRequired) { + $args += '-FailOnRebootRequired' + } + if ($SkipAutoMount) { + $args += '-SkipAutoMount' + } + $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $resumeCommand = "Start-Process -FilePath $(ConvertTo-PowerShellSingleQuotedString $powerShellPath) -Verb RunAs -ArgumentList $(ConvertTo-PowerShellSingleQuotedString ($args -join ' '))" + $command = "`"$powerShellPath`" -NoProfile -ExecutionPolicy Bypass -EncodedCommand $(ConvertTo-PowerShellEncodedCommand $resumeCommand)" + $runOnce = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' + New-Item -Path $runOnce -Force | Out-Null + Set-ItemProperty -Path $runOnce -Name 'ForkPressSetupResume' -Value $command +} + +function Show-ForkPressRebootPrompt { + $message = 'Windows needs to restart before ForkPress setup can finish. Setup will resume automatically after you sign in again. Restart now?' + try { + $shell = New-Object -ComObject WScript.Shell + $choice = $shell.Popup($message, 0, 'ForkPress Setup', 0x4 + 0x40) + if ($choice -eq 6) { + & shutdown.exe /r /t 30 /c 'ForkPress setup will resume after restart.' + } + } catch { + Write-Host $message + } +} + +Write-Host 'ForkPress Windows Setup' +Write-Host 'This installs ForkPress and prepares native Windows COW storage.' + +if (-not $SkipDevDrive -and -not (Test-Administrator)) { + throw 'ForkPress setup must run elevated to create a Windows Dev Drive. Use ForkPressSetup.exe for the guided installer flow.' +} + +$SourceRoot = [System.IO.Path]::GetFullPath($SourceRoot) +$InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) +$VhdPath = [System.IO.Path]::GetFullPath($VhdPath) +$MountPath = [System.IO.Path]::GetFullPath($MountPath) +$currentUserId = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$binDir = Join-Path $InstallRoot 'bin' +$logDir = Join-Path $InstallRoot 'Logs' +$scriptsDir = Join-Path $InstallRoot 'scripts\windows' +$forkpressSource = Join-Path $SourceRoot 'forkpress.exe' +if (-not (Test-Path -LiteralPath $forkpressSource)) { + $forkpressSource = Join-Path $SourceRoot 'bin\forkpress.exe' +} +if (-not (Test-Path -LiteralPath $forkpressSource)) { + $forkpressSource = Join-Path $SourceRoot 'target\x86_64-pc-windows-msvc\release\forkpress.exe' +} +if (-not (Test-Path -LiteralPath $forkpressSource)) { + throw "Could not find forkpress.exe under $SourceRoot" +} + +Write-Step 'Installing files' +New-Item -ItemType Directory -Force -Path $binDir, $logDir, $scriptsDir | Out-Null +$forkpressDest = Join-Path $binDir 'forkpress.exe' +if ([System.IO.Path]::GetFullPath($forkpressSource) -ne [System.IO.Path]::GetFullPath($forkpressDest)) { + Copy-Item -Force -LiteralPath $forkpressSource -Destination $forkpressDest +} +$setupSource = Join-Path $PSScriptRoot 'setup-dev-drive.ps1' +$setupDest = Join-Path $scriptsDir 'setup-dev-drive.ps1' +if ([System.IO.Path]::GetFullPath($setupSource) -ne [System.IO.Path]::GetFullPath($setupDest)) { + Copy-Item -Force -LiteralPath $setupSource -Destination $setupDest +} + +Write-Step 'Adding ForkPress to your user PATH' +Add-UserPath $binDir + +Write-Step 'Installing Microsoft Visual C++ runtime' +if (Test-VcRedistInstalled) { + Write-Host 'Microsoft Visual C++ runtime is already installed.' +} else { + $redist = Join-Path $SourceRoot 'vendor\vc_redist.x64.exe' + if (-not (Test-Path -LiteralPath $redist)) { + throw "Microsoft Visual C++ runtime is not installed and the bundled installer is missing: $redist" + } + $redistProcess = Start-Process -FilePath $redist -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru + if ($redistProcess.ExitCode -notin @(0, 3010)) { + throw "VC++ Redistributable installer failed with exit code $($redistProcess.ExitCode)" + } + if ($redistProcess.ExitCode -eq 3010) { + Register-ForkPressSetupResume ` + -InstallScript (Join-Path $scriptsDir 'install.ps1') ` + -SourceRoot $InstallRoot ` + -InstallRoot $InstallRoot ` + -VhdPath $VhdPath ` + -MountPath $MountPath ` + -SiteName $SiteName ` + -SizeGB $SizeGB ` + -AllowPlainReFS:$AllowPlainReFS ` + -FailOnRebootRequired:$FailOnRebootRequired ` + -SkipAutoMount:$SkipAutoMount + Write-Host '' + Write-Host 'Windows needs a restart before ForkPress setup can finish.' + Write-Host 'Setup will resume automatically after you sign in again.' + if ($FailOnRebootRequired) { + throw 'Windows requested a restart before ForkPress setup could finish.' + } + Show-ForkPressRebootPrompt + exit 0 + } +} + +if (-not $SkipDevDrive) { + Write-Step 'Preparing ForkPress Dev Drive' + $setupScript = Join-Path $scriptsDir 'setup-dev-drive.ps1' + $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $setupArgs = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $setupScript, + '-VhdPath', $VhdPath, + '-MountPath', $MountPath, + '-SizeGB', "$SizeGB", + '-AutoMountUserId', $currentUserId, + '-LogPath', (Join-Path $logDir 'setup-dev-drive.log') + ) + if ($AllowPlainReFS) { + $setupArgs += '-AllowPlainReFS' + } + if ($SkipAutoMount) { + $setupArgs += '-SkipAutoMount' + } + & $powerShellPath @setupArgs + if ($LASTEXITCODE -ne 0) { + throw "Dev Drive setup failed with exit code $LASTEXITCODE" + } +} + +$sitesDir = Join-Path $MountPath 'Sites' +New-Item -ItemType Directory -Force -Path $sitesDir | Out-Null +$siteDir = Join-Path $sitesDir $SiteName +New-Item -ItemType Directory -Force -Path $siteDir | Out-Null +$siteManifest = Join-Path $siteDir '.forkpress\site.toml' +if (-not (Test-Path -LiteralPath $siteManifest)) { + Write-Step 'Creating your first ForkPress site' + Push-Location $siteDir + try { + & $forkpressDest init --work-dir (Join-Path $siteDir '.forkpress') --site-title $SiteName + if ($LASTEXITCODE -ne 0) { + throw "forkpress init failed with exit code $LASTEXITCODE" + } + } finally { + Pop-Location + } +} + +Write-Step 'Creating shortcuts' +$desktop = [Environment]::GetFolderPath('DesktopDirectory') +$startMenu = Join-Path ([Environment]::GetFolderPath('Programs')) 'ForkPress' +New-Item -ItemType Directory -Force -Path $startMenu | Out-Null + +$siteLiteral = ConvertTo-PowerShellSingleQuotedString $siteDir +$binPrefixLiteral = ConvertTo-PowerShellSingleQuotedString "$binDir;" +$forkpressLiteral = ConvertTo-PowerShellSingleQuotedString $forkpressDest +$siteUrlLiteral = ConvertTo-PowerShellSingleQuotedString 'http://127.0.0.1:18080/' +$shellCommand = "Set-Location -LiteralPath $siteLiteral; `$env:Path = $binPrefixLiteral + `$env:Path; Write-Host 'ForkPress is ready in this site folder.'" +$shellArgs = "-NoExit -ExecutionPolicy Bypass -EncodedCommand $(ConvertTo-PowerShellEncodedCommand $shellCommand)" +$startCommand = "Set-Location -LiteralPath $siteLiteral; `$env:Path = $binPrefixLiteral + `$env:Path; & $forkpressLiteral start --background; if (`$LASTEXITCODE -eq 0) { Start-Process $siteUrlLiteral } else { Read-Host 'ForkPress could not start. Press Enter to close' }" +$startArgs = "-NoExit -ExecutionPolicy Bypass -EncodedCommand $(ConvertTo-PowerShellEncodedCommand $startCommand)" +New-Shortcut -Path (Join-Path $desktop 'ForkPress Shell.lnk') -TargetPath 'powershell.exe' -Arguments $shellArgs -WorkingDirectory $siteDir +New-Shortcut -Path (Join-Path $startMenu 'ForkPress Shell.lnk') -TargetPath 'powershell.exe' -Arguments $shellArgs -WorkingDirectory $siteDir +New-Shortcut -Path (Join-Path $desktop 'Start ForkPress Site.lnk') -TargetPath 'powershell.exe' -Arguments $startArgs -WorkingDirectory $siteDir +New-Shortcut -Path (Join-Path $startMenu 'Start ForkPress Site.lnk') -TargetPath 'powershell.exe' -Arguments $startArgs -WorkingDirectory $siteDir +New-Shortcut -Path (Join-Path $desktop 'ForkPress Dev Drive.lnk') -TargetPath 'explorer.exe' -Arguments "`"$MountPath`"" +New-Shortcut -Path (Join-Path $startMenu 'ForkPress Dev Drive.lnk') -TargetPath 'explorer.exe' -Arguments "`"$MountPath`"" + +Write-Host '' +Write-Host 'ForkPress is installed.' +Write-Host " Command: $(Join-Path $binDir 'forkpress.exe')" +Write-Host " Dev Drive: $MountPath" +Write-Host " Site: $siteDir" +Write-Host '' +Write-Host 'Open "Start ForkPress Site" from your desktop or Start Menu.' diff --git a/scripts/windows/package.ps1 b/scripts/windows/package.ps1 new file mode 100644 index 00000000..829f72f5 --- /dev/null +++ b/scripts/windows/package.ps1 @@ -0,0 +1,55 @@ +<# +.SYNOPSIS +Creates the Windows zip package with a double-click setup entry point. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $ForkPressExe, + [string] $Output = 'forkpress-x86_64-pc-windows-msvc.zip', + [string] $StageDir = '', + [string] $VcRedistPath = $env:FORKPRESS_VC_REDIST, + [switch] $KeepStage +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +if ([string]::IsNullOrWhiteSpace($StageDir)) { + $stage = Join-Path $env:TEMP "forkpress-windows-package-$PID" +} else { + $stage = $StageDir +} +Remove-Item -Recurse -Force -LiteralPath $stage -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $stage | Out-Null + +Copy-Item -Force -LiteralPath $ForkPressExe -Destination (Join-Path $stage 'forkpress.exe') + +$vendorDest = Join-Path $stage 'vendor' +New-Item -ItemType Directory -Force -Path $vendorDest | Out-Null +if ([string]::IsNullOrWhiteSpace($VcRedistPath)) { + $cacheDir = Join-Path $repoRoot '.build\windows-prereqs' + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + $VcRedistPath = Join-Path $cacheDir 'vc_redist.x64.exe' + if (-not (Test-Path -LiteralPath $VcRedistPath)) { + Write-Host '==> Downloading Microsoft Visual C++ Redistributable' + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $VcRedistPath + } +} +Copy-Item -Force -LiteralPath $VcRedistPath -Destination (Join-Path $vendorDest 'vc_redist.x64.exe') + +$scriptDest = Join-Path $stage 'scripts\windows' +New-Item -ItemType Directory -Force -Path $scriptDest | Out-Null +Copy-Item -Force -LiteralPath (Join-Path $PSScriptRoot 'install.ps1') -Destination $scriptDest +Copy-Item -Force -LiteralPath (Join-Path $PSScriptRoot 'setup-dev-drive.ps1') -Destination $scriptDest + +Copy-Item -Force -LiteralPath (Join-Path $repoRoot 'docs\windows-cow.md') -Destination (Join-Path $stage 'README-WINDOWS.md') + +Remove-Item -Force -LiteralPath $Output -ErrorAction SilentlyContinue +Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $Output +if (-not $KeepStage) { + Remove-Item -Recurse -Force -LiteralPath $stage +} + +Write-Host "Created $Output" diff --git a/scripts/windows/setup-dev-drive.ps1 b/scripts/windows/setup-dev-drive.ps1 new file mode 100644 index 00000000..56056f8e --- /dev/null +++ b/scripts/windows/setup-dev-drive.ps1 @@ -0,0 +1,514 @@ +<# +.SYNOPSIS +Creates and attaches the ForkPress ReFS Dev Drive VHDX. + +.DESCRIPTION +This script is the elevated storage setup used by the Windows installer. It is +idempotent: an existing VHDX is reused only after the mounted volume is verified +as ReFS/Dev Drive storage. It also registers a logon scheduled task so the VHDX +is reattached after reboot. +#> + +[CmdletBinding()] +param( + [string] $VhdPath = "$env:ProgramData\ForkPress\Storage\forkpress-dev-drive.vhdx", + [string] $MountPath = "$env:USERPROFILE\ForkPressDevDrive", + [UInt32] $SizeGB = 128, + [switch] $AttachOnly, + [switch] $SkipAutoMount, + [switch] $AllowPlainReFS, + [string] $AutoMountUserId = '', + [string] $LogPath = "$env:LOCALAPPDATA\ForkPress\Logs\setup-dev-drive.log" +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string] $Message) + Write-Host "==> $Message" +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Invoke-DiskPartScript { + param([string] $Script) + + $scriptPath = Join-Path $env:TEMP "forkpress-diskpart-$PID.txt" + Set-Content -Path $scriptPath -Value $Script -Encoding ASCII + try { + $output = & diskpart.exe /s $scriptPath 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "diskpart failed with exit code $LASTEXITCODE`n$output" + } + } finally { + Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-CheckedNativeCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($output) { + $output | ForEach-Object { Write-Host $_ } + } + if ($exitCode -ne 0) { + throw "$FilePath $($Arguments -join ' ') failed with exit code $exitCode`n$($output -join "`n")" + } + return @($output | ForEach-Object { "$_" }) +} + +function ConvertTo-PowerShellEncodedCommand { + param([string] $Command) + + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) +} + +function ConvertTo-PowerShellSingleQuotedString { + param([string] $Value) + + return "'$($Value -replace "'", "''")'" +} + +function Test-TrustedDevDriveQuery { + param([string[]] $QueryOutput) + + $queryText = (($QueryOutput | ForEach-Object { "$_" }) -join "`n").ToLowerInvariant() + if ($queryText -match 'not\s+(a\s+)?(trusted\s+)?(dev drive|developer volume)' -or + $queryText -match 'untrusted') { + return $false + } + + if ($queryText -match 'this\s+is\s+a\s+trusted\s+(dev drive|developer volume)') { + return $true + } + + if ($queryText -match '(dev drive|developer volume)\s*:\s*(yes|true)' -and + $queryText -match 'trusted\s*:\s*(yes|true)') { + return $true + } + + return $false +} + +function Assert-ForkPressTrustedDevDrive { + param([string] $MountPath) + + $query = Invoke-CheckedNativeCommand -FilePath 'fsutil.exe' -Arguments @('devdrv', 'query', $MountPath) + if (-not (Test-TrustedDevDriveQuery -QueryOutput $query)) { + throw "Windows did not report $MountPath as a trusted Dev Drive. Remove the VHDX and run ForkPress Setup again, or rerun with -AllowPlainReFS only for local testing." + } +} + +function Protect-ForkPressVhdPath { + param([string] $VhdPath) + + $protectedRoot = [System.IO.Path]::GetFullPath((Join-Path $env:ProgramData 'ForkPress')) + $protectedRootPrefix = $protectedRoot.TrimEnd('\') + '\' + $storageDir = Split-Path -Parent $VhdPath + $storageDir = [System.IO.Path]::GetFullPath($storageDir) + $vhdExistsBeforeProtection = Test-Path -LiteralPath $VhdPath + if ($vhdExistsBeforeProtection) { + $item = Get-Item -LiteralPath $VhdPath -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to use reparse-point VHDX path: $VhdPath" + } + if (-not (Test-ForkPressProtectedAcl -Path $VhdPath)) { + throw "Refusing to reuse an existing ForkPress VHDX that was not already protected: $VhdPath. Remove it as an administrator and run setup again." + } + } + + $directoriesToProtect = @() + if ($storageDir.Equals($protectedRoot, [StringComparison]::OrdinalIgnoreCase) -or + $storageDir.StartsWith($protectedRootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $directoriesToProtect += $protectedRoot + } + $directoriesToProtect += $storageDir + + foreach ($directory in $directoriesToProtect) { + if (Test-Path -LiteralPath $directory) { + $item = Get-Item -LiteralPath $directory -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to use reparse-point directory for ForkPress VHDX storage: $directory" + } + } + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $item = Get-Item -LiteralPath $directory -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to use reparse-point directory for ForkPress VHDX storage: $directory" + } + Set-ForkPressProtectedAcl -Path $directory -Container + } + + if (Test-Path -LiteralPath $VhdPath) { + $item = Get-Item -LiteralPath $VhdPath -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to use reparse-point VHDX path: $VhdPath" + } + Set-ForkPressProtectedAcl -Path $VhdPath + } +} + +function Set-ForkPressProtectedAcl { + param( + [string] $Path, + [switch] $Container + ) + + $acl = Get-Acl -LiteralPath $Path + $acl.SetAccessRuleProtection($true, $false) + $administratorsSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $acl.SetOwner($administratorsSid) + foreach ($rule in @($acl.Access)) { + $acl.RemoveAccessRuleSpecific($rule) | Out-Null + } + + $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None + if ($Container) { + $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + } + $propagationFlags = [System.Security.AccessControl.PropagationFlags]::None + $rights = [System.Security.AccessControl.FileSystemRights]::FullControl + $allow = [System.Security.AccessControl.AccessControlType]::Allow + foreach ($sidValue in @('S-1-5-18', 'S-1-5-32-544')) { + $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + $rights, + $inheritanceFlags, + $propagationFlags, + $allow + ) + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl + Assert-ForkPressProtectedAcl -Path $Path +} + +function ConvertTo-SidValue { + param([System.Security.Principal.IdentityReference] $Identity) + + try { + return $Identity.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + return $Identity.Value + } +} + +function Convert-OwnerToSidValue { + param([string] $Owner) + + if ($Owner -match '^S-\d-') { + return $Owner + } + try { + return ([System.Security.Principal.NTAccount]::new($Owner)).Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + return $Owner + } +} + +function Assert-ForkPressProtectedAcl { + param([string] $Path) + + $allowed = @('S-1-5-18', 'S-1-5-32-544') + $acl = Get-Acl -LiteralPath $Path + $ownerSid = Convert-OwnerToSidValue -Owner $acl.Owner + if ($allowed -notcontains $ownerSid) { + throw "Protected ForkPress path has unexpected owner ${ownerSid}: $Path" + } + foreach ($rule in @($acl.Access)) { + $sid = ConvertTo-SidValue -Identity $rule.IdentityReference + if ($allowed -notcontains $sid -or + $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or + (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl)) { + throw "Protected ForkPress path has unexpected ACL entry $($rule.IdentityReference): $Path" + } + } +} + +function Test-ForkPressProtectedAcl { + param([string] $Path) + + try { + Assert-ForkPressProtectedAcl -Path $Path + return $true + } catch { + return $false + } +} + +function Assert-AutoMountVhdPathIsProtected { + param([string] $VhdPath) + + $protectedRoot = [System.IO.Path]::GetFullPath((Join-Path $env:ProgramData 'ForkPress')) + $protectedRootPrefix = $protectedRoot.TrimEnd('\') + '\' + $fullVhdPath = [System.IO.Path]::GetFullPath($VhdPath) + if (-not ($fullVhdPath.StartsWith($protectedRootPrefix, [StringComparison]::OrdinalIgnoreCase))) { + throw "Persistent auto-mount requires the VHDX to live under protected storage: $protectedRoot" + } +} + +function Grant-ForkPressDevDriveAccess { + param( + [string] $MountPath, + [string] $UserId + ) + + Invoke-CheckedNativeCommand -FilePath 'icacls.exe' -Arguments @( + $MountPath, + '/grant', + "${UserId}:(OI)(CI)F" + ) | Out-Null +} + +function Wait-DiskImageDisk { + param([string] $ImagePath) + + $deadline = (Get-Date).AddSeconds(15) + do { + $disk = Get-DiskImage -ImagePath $ImagePath | Get-Disk -ErrorAction SilentlyContinue + if ($disk) { + return $disk + } + Start-Sleep -Milliseconds 250 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for attached VHDX disk: $ImagePath" +} + +function Test-ForkPressVhdMountedAtPath { + param( + [string] $VhdPath, + [string] $MountPath + ) + + try { + $image = Get-DiskImage -ImagePath $VhdPath -ErrorAction Stop + if (-not $image.Attached) { + return $false + } + $disk = $image | Get-Disk -ErrorAction Stop + $paths = Get-Partition -DiskNumber $disk.Number | + ForEach-Object { $_.AccessPaths } | + Where-Object { $_ } + return $paths -contains $MountPath + } catch { + return $false + } +} + +function Register-ForkPressDevDriveAutoMount { + param( + [string] $VhdPath, + [string] $MountPath, + [string] $AutoMountUserId + ) + + Write-Step 'Registering Dev Drive auto-mount' + $vhdLiteral = ConvertTo-PowerShellSingleQuotedString $VhdPath + $mountLiteral = ConvertTo-PowerShellSingleQuotedString $MountPath + $attachCommand = @" +`$ErrorActionPreference = 'Stop' +`$vhdPath = $vhdLiteral +`$mountPath = $mountLiteral +`$image = Get-DiskImage -ImagePath `$vhdPath -ErrorAction SilentlyContinue +if (-not `$image -or -not `$image.Attached) { + Mount-DiskImage -ImagePath `$vhdPath -ErrorAction Stop | Out-Null +} +`$disk = Get-DiskImage -ImagePath `$vhdPath | Get-Disk -ErrorAction Stop +`$partition = Get-Partition -DiskNumber `$disk.Number | Where-Object { `$_.Type -ne 'Reserved' } | Select-Object -First 1 +if (`$partition -and @(`$partition.AccessPaths) -notcontains `$mountPath) { + Add-PartitionAccessPath -DiskNumber `$disk.Number -PartitionNumber `$partition.PartitionNumber -AccessPath `$mountPath +} +"@ + $encodedCommand = ConvertTo-PowerShellEncodedCommand $attachCommand + $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $action = New-ScheduledTaskAction -Execute $powerShellPath -Argument "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCommand" + $trigger = New-ScheduledTaskTrigger -AtLogOn + if ([string]::IsNullOrWhiteSpace($AutoMountUserId)) { + $AutoMountUserId = [Security.Principal.WindowsIdentity]::GetCurrent().Name + } + $principal = New-ScheduledTaskPrincipal -UserId $AutoMountUserId -LogonType Interactive -RunLevel Highest + Register-ScheduledTask ` + -TaskName 'ForkPress Attach Dev Drive' ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Description 'Attach the ForkPress Dev Drive VHDX at user logon.' ` + -Force | Out-Null +} + +$transcriptStarted = $false +try { + if (-not (Test-Administrator)) { + throw 'ForkPress Dev Drive setup must run from an elevated PowerShell session. Use ForkPressSetup.exe for the guided installer flow.' + } + + if ($LogPath) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null + Start-Transcript -Path $LogPath -Append | Out-Null + $transcriptStarted = $true + } + + if (-not $AttachOnly -and $SizeGB -lt 50) { + throw 'Dev Drive volumes must be at least 50 GB.' + } + + $VhdPath = [System.IO.Path]::GetFullPath($VhdPath) + $MountPath = [System.IO.Path]::GetFullPath($MountPath) + if (-not $MountPath.EndsWith('\')) { + $MountPath = "$MountPath\" + } + if (-not $SkipAutoMount) { + Assert-AutoMountVhdPathIsProtected -VhdPath $VhdPath + } + + if (-not $AllowPlainReFS) { + $formatVolume = Get-Command Format-Volume -ErrorAction Stop + if (-not $formatVolume.Parameters.ContainsKey('DevDrive')) { + throw 'This Windows build does not expose Format-Volume -DevDrive. Update Windows 11, reboot, then run ForkPress Setup again.' + } + + $os = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + $build = [int] $os.CurrentBuildNumber + $ubr = if ($os.UBR -ne $null) { [int] $os.UBR } else { 0 } + if ($build -lt 22621 -or ($build -eq 22621 -and $ubr -lt 2338)) { + throw "ForkPress Dev Drive setup needs Windows 11 build 22621.2338 or newer. Current build is $build.$ubr." + } + } + + if (-not $AttachOnly) { + $memory = Get-CimInstance Win32_ComputerSystem + if ([UInt64] $memory.TotalPhysicalMemory -lt 8GB) { + throw 'ForkPress Dev Drive setup needs at least 8 GB of RAM.' + } + + $hostDrive = Get-PSDrive -Name ([System.IO.Path]::GetPathRoot($VhdPath).Substring(0, 1)) + if ($hostDrive.Free -lt 50GB) { + throw "ForkPress Dev Drive setup needs at least 50 GB free on $($hostDrive.Name):." + } + } + + Protect-ForkPressVhdPath -VhdPath $VhdPath + if (Test-Path -LiteralPath $MountPath) { + $existing = Get-ChildItem -LiteralPath $MountPath -Force -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($existing -and -not (Test-ForkPressVhdMountedAtPath -VhdPath $VhdPath -MountPath $MountPath)) { + throw "Mount path $MountPath is not empty and is not the ForkPress Dev Drive. Choose an empty folder or remove its contents." + } + } else { + New-Item -ItemType Directory -Force -Path $MountPath | Out-Null + } + + if (-not $AllowPlainReFS) { + Write-Step 'Enabling Windows Dev Drive support' + Invoke-CheckedNativeCommand -FilePath 'fsutil.exe' -Arguments @('devdrv', 'enable', '/allowAv') | Out-Null + } + + if (-not (Test-Path -LiteralPath $VhdPath)) { + if ($AttachOnly) { + throw "No ForkPress VHDX exists at $VhdPath." + } + Write-Step 'Creating dynamic VHDX' + $sizeMB = [UInt64] $SizeGB * 1024 + Invoke-DiskPartScript @" +create vdisk file="$VhdPath" maximum=$sizeMB type=expandable +"@ + Set-ForkPressProtectedAcl -Path $VhdPath + } + + Write-Step 'Attaching VHDX' + $image = Get-DiskImage -ImagePath $VhdPath -ErrorAction SilentlyContinue + if (-not $image -or -not $image.Attached) { + Mount-DiskImage -ImagePath $VhdPath -ErrorAction Stop | Out-Null + } + + $disk = Wait-DiskImageDisk $VhdPath + if ($disk.IsOffline) { + Set-Disk -Number $disk.Number -IsOffline $false + } + if ($disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $false + } + + if ($disk.PartitionStyle -eq 'RAW') { + if ($AttachOnly) { + throw "The ForkPress VHDX exists but is not initialized: $VhdPath" + } + Write-Step 'Initializing VHDX' + Initialize-Disk -Number $disk.Number -PartitionStyle GPT | Out-Null + $partition = New-Partition -DiskNumber $disk.Number -UseMaximumSize + } else { + $partition = Get-Partition -DiskNumber $disk.Number | + Where-Object { $_.Type -ne 'Reserved' } | + Select-Object -First 1 + } + + if (-not $partition) { + throw "No usable partition found on $VhdPath" + } + + $volume = $partition | Get-Volume -ErrorAction SilentlyContinue + if (-not $volume -or -not $volume.FileSystem) { + if ($AttachOnly) { + throw "The ForkPress VHDX partition exists but is not formatted: $VhdPath" + } + Write-Step 'Formatting as Dev Drive' + $formatParams = @{ + Partition = $partition + FileSystem = 'ReFS' + NewFileSystemLabel = 'ForkPress' + Confirm = $false + } + if (-not $AllowPlainReFS) { + $formatParams['DevDrive'] = $true + } + Format-Volume @formatParams | Out-Null + } elseif ($volume.FileSystem -ne 'ReFS') { + throw "Existing ForkPress VHDX is formatted as $($volume.FileSystem), not ReFS. Remove $VhdPath and run setup again." + } + + $partition = Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber + $paths = @($partition.AccessPaths) + if ($paths -notcontains $MountPath) { + Write-Step 'Mounting Dev Drive folder' + Add-PartitionAccessPath ` + -DiskNumber $disk.Number ` + -PartitionNumber $partition.PartitionNumber ` + -AccessPath $MountPath + } + + if (-not $AllowPlainReFS) { + Write-Step 'Trusting Dev Drive' + Invoke-CheckedNativeCommand -FilePath 'fsutil.exe' -Arguments @('devdrv', 'trust', '/f', $MountPath) | Out-Null + Assert-ForkPressTrustedDevDrive -MountPath $MountPath + } + + if ([string]::IsNullOrWhiteSpace($AutoMountUserId)) { + $AutoMountUserId = [Security.Principal.WindowsIdentity]::GetCurrent().Name + } + Write-Step 'Granting user access to Dev Drive' + Grant-ForkPressDevDriveAccess -MountPath $MountPath -UserId $AutoMountUserId + + if (-not $SkipAutoMount) { + Register-ForkPressDevDriveAutoMount -VhdPath $VhdPath -MountPath $MountPath -AutoMountUserId $AutoMountUserId + } + + Write-Host '' + Write-Host 'ForkPress Dev Drive is ready.' + Write-Host " VHDX: $VhdPath" + Write-Host " Mount: $MountPath" + Write-Host '' +} finally { + if ($transcriptStarted) { + Stop-Transcript | Out-Null + } +} diff --git a/scripts/windows/sign.ps1 b/scripts/windows/sign.ps1 new file mode 100644 index 00000000..c7976826 --- /dev/null +++ b/scripts/windows/sign.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS +Signs Windows release artifacts when a code-signing certificate is available. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string[]] $Files, + [string] $CertificateBase64 = $env:WINDOWS_CODESIGN_CERT_BASE64, + [string] $CertificatePassword = $env:WINDOWS_CODESIGN_PASSWORD, + [string] $TimestampUrl = 'http://timestamp.digicert.com' +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($CertificateBase64)) { + Write-Host 'WINDOWS_CODESIGN_CERT_BASE64 is not set; skipping Authenticode signing.' + exit 0 +} + +$sdkRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" +$signtool = Get-ChildItem -LiteralPath $sdkRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 +if (-not $signtool) { + throw "signtool.exe was not found under $sdkRoot" +} + +$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } +$pfx = Join-Path $tempRoot 'forkpress-codesign.pfx' +[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($CertificateBase64)) + +try { + foreach ($file in $Files) { + if (-not (Test-Path -LiteralPath $file)) { + throw "Cannot sign missing file: $file" + } + & $signtool.FullName sign ` + /fd SHA256 ` + /td SHA256 ` + /tr $TimestampUrl ` + /f $pfx ` + /p $CertificatePassword ` + $file + if ($LASTEXITCODE -ne 0) { + throw "signtool failed with exit code $LASTEXITCODE for $file" + } + + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne 'Valid') { + throw "Authenticode signature for $file is $($signature.Status)." + } + } +} finally { + Remove-Item -Force -LiteralPath $pfx -ErrorAction SilentlyContinue +}