diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c356be33..4cd1468c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,24 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # The bundled Node.js version is pinned in package.json's + # devEngines.runtime.version (see standalone/src-tauri/build.rs, which + # fails the build unless the bundled binary matches this pin). + - name: Read pinned Node.js version + id: node-pin + shell: bash + run: | + set -euo pipefail + version=$(jq -r '.devEngines.runtime.version' package.json) + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: package.json devEngines.runtime.version is not MAJOR.MINOR.PATCH, got: '$version'" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version-file: standalone/.node-version + node-version: ${{ steps.node-pin.outputs.version }} - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: diff --git a/SECURITY.md b/SECURITY.md index 41cdbc77..2bbbd8b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,12 +16,12 @@ Every dependency shipped in the end-user application is listed at =20'} + node@runtime:22.22.3: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-QAJqz3ioCOQ9IioLDg5xG5X/J5NGlreSW1Mpi8fIxgo= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Daf/dO+GETKMghLxeUM2hxOirZU/t9iajIoOrofCMgc= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-RYMLp1L6DYksbc1kCUZmmAEpPKyCCjNZHe1ArAdRmOw= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-zIvIKy3QtZXDuVpMPJyMNQkHz/ARr73uPRN56BLh4+M= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-GPvhv91CBFrxXwIlauPllJDRuf+T38J6IjrMLJJ0AlA= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-armv7l.tar.gz + targets: + - cpu: armv7l + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-qQ9SPPFk4cdhv0xZtcaqDZ1VKJJwGraUmeaD7h/Aa7w= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-S4TkPCnZDk+QGBXDmRBqSa9O5zDRKTOvgsgtTcRQMIw= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-x6ENaBbajqqnU03XPHHG4rLDkdu/hF42SQLRVmFd0bg= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-AL4SmgnohyzVLTu4u6EkEsVzPSIkEjpIKi3KSm+/JYY= + prefix: node-v22.22.3-win-arm64 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-bI1U9jX+/033bCyoD0UzLrL/V9JSJu3ONlkuUaF37jM= + prefix: node-v22.22.3-win-x64 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-e6QmD2nha6libQd8sRJPP7AfYEIa8qbHOWrrTi0Nja4= + prefix: node-v22.22.3-win-x86 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-x86.zip + targets: + - cpu: x86 + os: win32 + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Cch6y6+uZeGPzA6xiDdsElTKt03HaRGPS2BHnF+jJs8= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-arm64-musl.tar.gz + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-0idpJYPWrhfEQCj9jxfLGzZyFnprqgNYGCQMu5226ko= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + version: 22.22.3 + hasBin: true + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6775,6 +6921,8 @@ snapshots: '@types/sarif': 2.1.7 fs-extra: 11.3.5 + node@runtime:22.22.3: {} + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 diff --git a/standalone/.node-version b/standalone/.node-version deleted file mode 100644 index 941d7c07..00000000 --- a/standalone/.node-version +++ /dev/null @@ -1 +0,0 @@ -22.22.3 diff --git a/standalone/src-tauri/Cargo.toml b/standalone/src-tauri/Cargo.toml index c48712bc..17145a31 100644 --- a/standalone/src-tauri/Cargo.toml +++ b/standalone/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["lib", "cdylib", "staticlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +serde_json = "1" [dependencies] tauri = { version = "2", features = [] } diff --git a/standalone/src-tauri/build.rs b/standalone/src-tauri/build.rs index cacec651..87c37af3 100644 --- a/standalone/src-tauri/build.rs +++ b/standalone/src-tauri/build.rs @@ -26,13 +26,14 @@ fn bundle_node_runtime() -> Result<(), Box> { let host = env::var("HOST")?; let node_source = resolve_node_binary(&host, &target)?; - println!("cargo:rerun-if-changed={}", node_source.display()); validate_node_binary(&node_source, &target)?; // The supply-chain page (website/src/data/dependencies-runtime.json) discloses // an exact Node.js version. Fail the build if the binary we're about to bundle - // doesn't match standalone/.node-version, so the disclosed version provably - // equals what ships. CI installs the pin via setup-node's node-version-file. + // doesn't match the pin in the root package.json's devEngines.runtime.version, + // so the disclosed version provably equals what ships. Locally pnpm honors + // devEngines (onFail: "download") so scripts run with the pinned Node; CI + // reads the same field to drive actions/setup-node. let pinned_version = read_pinned_node_version(&manifest_dir)?; verify_node_version(&node_source, &host, &target, &pinned_version)?; @@ -42,6 +43,10 @@ fn bundle_node_runtime() -> Result<(), Box> { let node_dest = binaries_dir.join(node_binary_name(&target)); fs::copy(&node_source, &node_dest)?; + if target.contains("windows") { + force_windows_gui_subsystem(&node_dest)?; + } + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -54,6 +59,56 @@ fn bundle_node_runtime() -> Result<(), Box> { Ok(()) } +// Rewrite the PE subsystem byte of the bundled node.exe from 3 (Windows +// console) to 2 (Windows GUI). Node.js does not care which subsystem its +// host binary advertises — it reads stdio handles from STARTUPINFO either +// way — but a console-subsystem process triggers Windows' default-terminal +// COM handoff, which on Win11 with Windows Terminal as DefTerm activates WT +// to host the sidecar (visible as a stray WT window titled with the node.exe +// path behind Dormouse). Neither CREATE_NO_WINDOW nor DETACHED_PROCESS opts +// out of that handoff; only a non-console subsystem does. +fn force_windows_gui_subsystem(path: &Path) -> Result<(), Box> { + const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2; + const IMAGE_SUBSYSTEM_WINDOWS_CUI: u16 = 3; + + let mut bytes = fs::read(path)?; + if bytes.len() < 0x40 || &bytes[0..2] != b"MZ" { + return Err(format!("{} is not a PE/COFF binary", path.display()).into()); + } + let pe_offset = u32::from_le_bytes(bytes[0x3C..0x40].try_into()?) as usize; + // PE signature (4) + COFF header (20) + Optional header up to Subsystem (0x44). + let subsystem_offset = pe_offset + 0x5C; + if bytes.len() < subsystem_offset + 2 || &bytes[pe_offset..pe_offset + 4] != b"PE\0\0" { + return Err(format!("{} has no PE signature at expected offset", path.display()).into()); + } + let current = u16::from_le_bytes(bytes[subsystem_offset..subsystem_offset + 2].try_into()?); + if current == IMAGE_SUBSYSTEM_WINDOWS_GUI { + return Ok(()); + } + if current != IMAGE_SUBSYSTEM_WINDOWS_CUI { + return Err(format!( + "{} has unexpected PE subsystem {current}; refusing to patch", + path.display() + ) + .into()); + } + bytes[subsystem_offset..subsystem_offset + 2] + .copy_from_slice(&IMAGE_SUBSYSTEM_WINDOWS_GUI.to_le_bytes()); + + // fs::copy preserves the source's read-only attribute. When the runtime + // comes from pnpm's content-addressable store (devEngines `onFail: + // "download"`), the source node.exe is typically read-only, so the + // destination would be too — and fs::write would fail with "access + // denied". Clear it defensively before writing the patched bytes back. + let mut perms = fs::metadata(path)?.permissions(); + if perms.readonly() { + perms.set_readonly(false); + fs::set_permissions(path, perms)?; + } + fs::write(path, &bytes)?; + Ok(()) +} + #[cfg(target_os = "macos")] fn validate_node_binary(node_source: &Path, target: &str) -> Result<(), Box> { if target.contains("apple-darwin") { @@ -92,15 +147,31 @@ fn reject_macos_dynamic_node(node_source: &Path) -> Result<(), Box> { } fn read_pinned_node_version(manifest_dir: &Path) -> Result> { - let pin_path = manifest_dir + let repo_root = manifest_dir .parent() - .ok_or("manifest dir has no parent")? - .join(".node-version"); + .and_then(Path::parent) + .ok_or("manifest dir has no grandparent (expected /standalone/src-tauri)")?; + let pin_path = repo_root.join("package.json"); println!("cargo:rerun-if-changed={}", pin_path.display()); let raw = fs::read_to_string(&pin_path) .map_err(|err| format!("failed to read {}: {err}", pin_path.display()))?; - let version = raw.trim().trim_start_matches('v').to_owned(); + let pkg: serde_json::Value = serde_json::from_str(&raw) + .map_err(|err| format!("failed to parse {}: {err}", pin_path.display()))?; + let version = pkg + .get("devEngines") + .and_then(|v| v.get("runtime")) + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + format!( + "{} is missing devEngines.runtime.version (string)", + pin_path.display() + ) + })? + .trim() + .trim_start_matches('v') + .to_owned(); let is_exact = version.split('.').count() == 3 && version @@ -108,7 +179,7 @@ fn read_pinned_node_version(manifest_dir: &Path) -> Result