From 3751bc8fee07a81e85de0d0aae1c5cd48045fc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 11 May 2026 13:52:37 +0200 Subject: [PATCH 1/2] Add Windows ReFS COW storage support --- README.md | 15 ++- crates/forkpress-cli/src/app.rs | 11 +- crates/forkpress-server/src/lib.rs | 137 +++++++++++++++++-- crates/forkpress-storage/src/lib.rs | 200 ++++++++++++++++++++++++++-- docs/lazy-overlay-cow.md | 13 +- docs/storage-drivers.md | 22 ++- docs/windows-cow.md | 81 +++++++++++ scripts/windows/setup-dev-drive.ps1 | 139 +++++++++++++++++++ 8 files changed, 582 insertions(+), 36 deletions(-) create mode 100644 docs/windows-cow.md create mode 100644 scripts/windows/setup-dev-drive.ps1 diff --git a/README.md b/README.md index 59d6e142..6f3cc985 100644 --- a/README.md +++ b/README.md @@ -312,14 +312,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, run `scripts/windows/setup-dev-drive.ps1` once and + create ForkPress projects under `%USERPROFILE%\ForkPressDevDrive`. +4. **Full file copy.** This is the final fallback when COW storage is not available. Inspect the selected file view: @@ -412,7 +416,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/src/app.rs b/crates/forkpress-cli/src/app.rs index d587800f..1e420454 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: scripts\\windows\\setup-dev-drive.ps1 creates 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-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..e2b273a9 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 +or run `scripts/windows/setup-dev-drive.ps1` once to create one. A regular full +copy is only the last-resort file view. 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 @@ -715,7 +724,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 +736,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 +1430,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 +1524,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 +1708,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..34146c07 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, run `./forkpress init`, `./forkpress serve`. On Windows, run the guided Dev Drive setup once if the current volume cannot clone files. | 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,9 @@ 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 remaining product gap is installer integration and + a native lazy branch namespace such as ProjFS. - 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..ef4acda5 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, use `scripts/windows/setup-dev-drive.ps1` once. It + prompts for elevation, creates a dynamic VHDX under `%LOCALAPPDATA%`, formats + it as ReFS/Dev Drive when the OS supports Dev Drive formatting, and mounts it + at `%USERPROFILE%\ForkPressDevDrive`. 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,10 @@ 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. +- installer integration that runs the Windows Dev Drive setup flow before + `forkpress init` chooses file-copy fallback; +- 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..e1a2ae39 --- /dev/null +++ b/docs/windows-cow.md @@ -0,0 +1,81 @@ +# 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. Install ForkPress. +2. Run the Windows setup flow once and accept the UAC prompt. +3. If Windows asks for a reboot after enabling optional components, reboot. +4. Open the ForkPress Dev Drive folder. +5. Run `forkpress init`. + +The script backing that setup flow is: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\windows\setup-dev-drive.ps1 +``` + +By default it creates: + +```text +%LOCALAPPDATA%\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. + +## 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, guide the user to the + Dev Drive setup flow before treating full copy as acceptable. +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 first Windows implementation supports materialized COW, not lazy + namespace COW. Branch creation still walks the source tree and creates a full + directory namespace. +- The Dev Drive setup script is the install-time building block. A release + installer should call the same flow rather than asking users to paste + PowerShell. +- 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/scripts/windows/setup-dev-drive.ps1 b/scripts/windows/setup-dev-drive.ps1 new file mode 100644 index 00000000..41db1c4a --- /dev/null +++ b/scripts/windows/setup-dev-drive.ps1 @@ -0,0 +1,139 @@ +<# +.SYNOPSIS +Creates a ForkPress ReFS Dev Drive VHDX for copy-on-write branch storage. + +.DESCRIPTION +This script is intended for a bare Windows laptop setup flow. If it is not +already elevated, it relaunches itself with a UAC prompt. It creates a dynamic +VHDX under the current user's LocalAppData folder, attaches it, formats it as +ReFS/Dev Drive when the OS supports Dev Drive formatting, and mounts it at a +normal user-visible folder. +#> + +[CmdletBinding()] +param( + [string] $VhdPath = "$env:LOCALAPPDATA\ForkPress\Storage\forkpress-dev-drive.vhdx", + [string] $MountPath = "$env:USERPROFILE\ForkPressDevDrive", + [UInt32] $SizeGB = 128, + [switch] $EnableProjFS +) + +$ErrorActionPreference = 'Stop' + +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 + } +} + +if (-not (Test-Administrator)) { + $startArgs = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$PSCommandPath`"", + '-VhdPath', "`"$VhdPath`"", + '-MountPath', "`"$MountPath`"", + '-SizeGB', "$SizeGB" + ) + if ($EnableProjFS) { + $startArgs += '-EnableProjFS' + } + Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $startArgs + exit +} + +if ($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\" +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $VhdPath) | Out-Null +New-Item -ItemType Directory -Force -Path $MountPath | Out-Null + +if ($EnableProjFS) { + $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS + if ($feature.State -ne 'Enabled') { + $result = Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart + if ($result.RestartNeeded) { + Write-Host 'Windows enabled ProjFS and requested a reboot.' + } + } +} + +if (-not (Test-Path -LiteralPath $VhdPath)) { + $sizeMB = [UInt64] $SizeGB * 1024 + Invoke-DiskPartScript @" +create vdisk file="$VhdPath" maximum=$sizeMB type=expandable +"@ +} + +$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 +if ($disk.PartitionStyle -eq 'RAW') { + 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) { + $formatParams = @{ + Partition = $partition + FileSystem = 'ReFS' + NewFileSystemLabel = 'ForkPress' + Confirm = $false + } + if ((Get-Command Format-Volume).Parameters.ContainsKey('DevDrive')) { + $formatParams['DevDrive'] = $true + } + Format-Volume @formatParams | Out-Null +} + +$partition = Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber +$paths = @($partition.AccessPaths) +if ($paths -notcontains $MountPath) { + Add-PartitionAccessPath ` + -DiskNumber $disk.Number ` + -PartitionNumber $partition.PartitionNumber ` + -AccessPath $MountPath +} + +Write-Host '' +Write-Host 'ForkPress Dev Drive is ready.' +Write-Host " VHDX: $VhdPath" +Write-Host " Mount: $MountPath" +Write-Host '' +Write-Host 'Create or move ForkPress projects under that mount path, then run:' +Write-Host " cd `"$MountPath`"" +Write-Host ' forkpress init' From 817d2c8a9f8c31ea16fe081235a70ae0008d5640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 11 May 2026 14:40:31 +0200 Subject: [PATCH 2/2] Add click-through Windows COW installer --- .github/workflows/ci.yml | 58 ++- .github/workflows/release.yml | 151 +++++++- AGENTS.md | 5 +- README.md | 24 +- crates/forkpress-cli/build.rs | 39 +- crates/forkpress-cli/src/app.rs | 2 +- crates/forkpress-runtime/src/lib.rs | 24 +- crates/forkpress-storage/src/lib.rs | 16 +- docs/lazy-overlay-cow.md | 8 +- docs/storage-drivers.md | 13 +- docs/windows-cow.md | 50 ++- installer/windows/ForkPress.iss | 59 ++++ scripts/windows/build-dist.ps1 | 104 ++++++ scripts/windows/install.ps1 | 296 ++++++++++++++++ scripts/windows/package.ps1 | 55 +++ scripts/windows/setup-dev-drive.ps1 | 529 ++++++++++++++++++++++++---- scripts/windows/sign.ps1 | 58 +++ 17 files changed, 1345 insertions(+), 146 deletions(-) create mode 100644 installer/windows/ForkPress.iss create mode 100644 scripts/windows/build-dist.ps1 create mode 100644 scripts/windows/install.ps1 create mode 100644 scripts/windows/package.ps1 create mode 100644 scripts/windows/sign.ps1 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 6f3cc985..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` @@ -321,8 +339,8 @@ ForkPress tries the cheapest ordinary-file view first: `.forkpress/macos-cow/mount`, stores the physical branch trees there, and exposes public branch directories like `./main` and `./marketing`. 3. **Guided ReFS Dev Drive setup on Windows.** If a Windows project is not on - clone-capable storage, run `scripts/windows/setup-dev-drive.ps1` once and - create ForkPress projects under `%USERPROFILE%\ForkPressDevDrive`. + 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. 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 1e420454..b7c2209d 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -885,7 +885,7 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result { #[cfg(target_os = "windows")] { println!(" Windows ReFS Dev Drive: recommended before file-copy fallback"); - println!(" setup: scripts\\windows\\setup-dev-drive.ps1 creates a ReFS Dev Drive VHDX"); + 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" ); 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-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index e2b273a9..d6d2fdf2 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -46,8 +46,8 @@ 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 -or run `scripts/windows/setup-dev-drive.ps1` once to create one. A regular full -copy is only the last-resort file view. +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 @@ -176,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) { @@ -187,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 { diff --git a/docs/lazy-overlay-cow.md b/docs/lazy-overlay-cow.md index 34146c07..ee7e9c00 100644 --- a/docs/lazy-overlay-cow.md +++ b/docs/lazy-overlay-cow.md @@ -88,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`. On Windows, run the guided Dev Drive setup once if the current volume cannot clone files. | 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. | +| 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. | @@ -116,8 +116,10 @@ architecture. Known gaps: - Sparsebundle detach can still be blocked by terminals, editors, or processes holding files open under the mounted path. - Windows supports the native materialized COW tier when the project lives on a - ReFS/Dev Drive volume. The remaining product gap is installer integration and - a native lazy branch namespace such as ProjFS. + 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 ef4acda5..fd7a2ca9 100644 --- a/docs/storage-drivers.md +++ b/docs/storage-drivers.md @@ -94,11 +94,11 @@ directory namespace for each branch. `.forkpress/macos-cow/mount`, and symlinks public branch directories to the APFS-backed physical branch trees. 3. **Guided ReFS Dev Drive setup on Windows.** If a Windows project is not on - clone-capable storage, use `scripts/windows/setup-dev-drive.ps1` once. It - prompts for elevation, creates a dynamic VHDX under `%LOCALAPPDATA%`, formats - it as ReFS/Dev Drive when the OS supports Dev Drive formatting, and mounts it - at `%USERPROFILE%\ForkPressDevDrive`. Projects created there can use the - native file clone tier. + 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 @@ -223,8 +223,7 @@ 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; -- installer integration that runs the Windows Dev Drive setup flow before - `forkpress init` chooses file-copy fallback; +- 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. diff --git a/docs/windows-cow.md b/docs/windows-cow.md index e1a2ae39..cc8220e2 100644 --- a/docs/windows-cow.md +++ b/docs/windows-cow.md @@ -10,13 +10,14 @@ Windows path is native ReFS block cloning on a Dev Drive. For a fresh Windows laptop: -1. Install ForkPress. -2. Run the Windows setup flow once and accept the UAC prompt. -3. If Windows asks for a reboot after enabling optional components, reboot. -4. Open the ForkPress Dev Drive folder. -5. Run `forkpress init`. +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 script backing that setup flow is: +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 @@ -25,7 +26,7 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\setup-dev-drive.ps1 By default it creates: ```text -%LOCALAPPDATA%\ForkPress\Storage\forkpress-dev-drive.vhdx +%ProgramData%\ForkPress\Storage\forkpress-dev-drive.vhdx %USERPROFILE%\ForkPressDevDrive\ ``` @@ -33,6 +34,24 @@ 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 @@ -42,8 +61,9 @@ name: 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, guide the user to the - Dev Drive setup flow before treating full copy as acceptable. +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. @@ -64,12 +84,16 @@ marketing\wp-load.php until one side writes ## Current Boundaries -- The first Windows implementation supports materialized COW, not lazy +- The Windows implementation supports materialized COW, not lazy namespace COW. Branch creation still walks the source tree and creates a full directory namespace. -- The Dev Drive setup script is the install-time building block. A release - installer should call the same flow rather than asking users to paste - PowerShell. +- 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. 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 index 41db1c4a..56056f8e 100644 --- a/scripts/windows/setup-dev-drive.ps1 +++ b/scripts/windows/setup-dev-drive.ps1 @@ -1,25 +1,33 @@ <# .SYNOPSIS -Creates a ForkPress ReFS Dev Drive VHDX for copy-on-write branch storage. +Creates and attaches the ForkPress ReFS Dev Drive VHDX. .DESCRIPTION -This script is intended for a bare Windows laptop setup flow. If it is not -already elevated, it relaunches itself with a UAC prompt. It creates a dynamic -VHDX under the current user's LocalAppData folder, attaches it, formats it as -ReFS/Dev Drive when the OS supports Dev Drive formatting, and mounts it at a -normal user-visible folder. +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:LOCALAPPDATA\ForkPress\Storage\forkpress-dev-drive.vhdx", + [string] $VhdPath = "$env:ProgramData\ForkPress\Storage\forkpress-dev-drive.vhdx", [string] $MountPath = "$env:USERPROFILE\ForkPressDevDrive", [UInt32] $SizeGB = 128, - [switch] $EnableProjFS + [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) @@ -41,99 +49,466 @@ function Invoke-DiskPartScript { } } -if (-not (Test-Administrator)) { - $startArgs = @( - '-NoProfile', - '-ExecutionPolicy', 'Bypass', - '-File', "`"$PSCommandPath`"", - '-VhdPath', "`"$VhdPath`"", - '-MountPath', "`"$MountPath`"", - '-SizeGB', "$SizeGB" +function Invoke-CheckedNativeCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() ) - if ($EnableProjFS) { - $startArgs += '-EnableProjFS' + + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($output) { + $output | ForEach-Object { Write-Host $_ } } - Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $startArgs - exit + if ($exitCode -ne 0) { + throw "$FilePath $($Arguments -join ' ') failed with exit code $exitCode`n$($output -join "`n")" + } + return @($output | ForEach-Object { "$_" }) } -if ($SizeGB -lt 50) { - throw 'Dev Drive volumes must be at least 50 GB.' +function ConvertTo-PowerShellEncodedCommand { + param([string] $Command) + + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) } -$VhdPath = [System.IO.Path]::GetFullPath($VhdPath) -$MountPath = [System.IO.Path]::GetFullPath($MountPath) -if (-not $MountPath.EndsWith('\')) { - $MountPath = "$MountPath\" +function ConvertTo-PowerShellSingleQuotedString { + param([string] $Value) + + return "'$($Value -replace "'", "''")'" } -New-Item -ItemType Directory -Force -Path (Split-Path -Parent $VhdPath) | Out-Null -New-Item -ItemType Directory -Force -Path $MountPath | Out-Null +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 ($EnableProjFS) { - $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS - if ($feature.State -ne 'Enabled') { - $result = Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart - if ($result.RestartNeeded) { - Write-Host 'Windows enabled ProjFS and requested a reboot.' + 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 } } -if (-not (Test-Path -LiteralPath $VhdPath)) { - $sizeMB = [UInt64] $SizeGB * 1024 - Invoke-DiskPartScript @" -create vdisk file="$VhdPath" maximum=$sizeMB type=expandable -"@ +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 } -$image = Get-DiskImage -ImagePath $VhdPath -ErrorAction SilentlyContinue -if (-not $image -or -not $image.Attached) { - Mount-DiskImage -ImagePath $VhdPath -ErrorAction Stop | Out-Null +function ConvertTo-SidValue { + param([System.Security.Principal.IdentityReference] $Identity) + + try { + return $Identity.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + return $Identity.Value + } } -$disk = Get-DiskImage -ImagePath $VhdPath | Get-Disk -if ($disk.PartitionStyle -eq 'RAW') { - 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 +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 + } } -if (-not $partition) { - throw "No usable partition found on $VhdPath" +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" + } + } } -$volume = $partition | Get-Volume -ErrorAction SilentlyContinue -if (-not $volume -or -not $volume.FileSystem) { - $formatParams = @{ - Partition = $partition - FileSystem = 'ReFS' - NewFileSystemLabel = 'ForkPress' - Confirm = $false +function Test-ForkPressProtectedAcl { + param([string] $Path) + + try { + Assert-ForkPressProtectedAcl -Path $Path + return $true + } catch { + return $false } - if ((Get-Command Format-Volume).Parameters.ContainsKey('DevDrive')) { - $formatParams['DevDrive'] = $true +} + +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" } - Format-Volume @formatParams | Out-Null } -$partition = Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber -$paths = @($partition.AccessPaths) -if ($paths -notcontains $MountPath) { - Add-PartitionAccessPath ` - -DiskNumber $disk.Number ` - -PartitionNumber $partition.PartitionNumber ` - -AccessPath $MountPath +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" } -Write-Host '' -Write-Host 'ForkPress Dev Drive is ready.' -Write-Host " VHDX: $VhdPath" -Write-Host " Mount: $MountPath" -Write-Host '' -Write-Host 'Create or move ForkPress projects under that mount path, then run:' -Write-Host " cd `"$MountPath`"" -Write-Host ' forkpress init' +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 +}