From 95e35659917d88258f43c40e51bc006dd37e2123 Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Tue, 21 Jul 2026 22:26:30 +0900 Subject: [PATCH 1/5] Fix packaged terminal launchers --- .cargo/config.toml | 3 + .github/workflows/release.yaml | 55 +++- .github/workflows/update-homebrew.yaml | 2 +- Cargo.lock | 11 +- Cargo.toml | 5 +- README.md | 31 +- crates/procnote-cli/Cargo.toml | 21 -- crates/procnote-cli/src/main.rs | 3 - scripts/validate-linux-package.sh | 44 +++ scripts/validate-macos-package.sh | 62 ++++ scripts/validate-windows-package.ps1 | 371 +++++++++++++++++++++++ src-tauri/launchers/README.md | 19 ++ src-tauri/launchers/linux/procnote | 22 ++ src-tauri/launchers/macos/procnote | 23 ++ src-tauri/launchers/windows/procnote.cmd | 2 + src-tauri/nsis/hooks.nsh | 47 ++- src-tauri/nsis/update-user-path.ps1 | 76 +++++ src-tauri/resources/cli/.gitkeep | 0 src-tauri/src/lib.rs | 17 +- src-tauri/src/main.rs | 2 +- src-tauri/tauri.conf.json | 3 - src-tauri/tauri.linux.conf.json | 17 ++ src-tauri/tauri.macos.conf.json | 7 + src-tauri/tauri.windows.conf.json | 8 + 24 files changed, 758 insertions(+), 93 deletions(-) delete mode 100644 crates/procnote-cli/Cargo.toml delete mode 100644 crates/procnote-cli/src/main.rs create mode 100755 scripts/validate-linux-package.sh create mode 100755 scripts/validate-macos-package.sh create mode 100644 scripts/validate-windows-package.ps1 create mode 100644 src-tauri/launchers/README.md create mode 100755 src-tauri/launchers/linux/procnote create mode 100755 src-tauri/launchers/macos/procnote create mode 100644 src-tauri/launchers/windows/procnote.cmd create mode 100644 src-tauri/nsis/update-user-path.ps1 delete mode 100644 src-tauri/resources/cli/.gitkeep create mode 100644 src-tauri/tauri.linux.conf.json create mode 100644 src-tauri/tauri.macos.conf.json create mode 100644 src-tauri/tauri.windows.conf.json diff --git a/.cargo/config.toml b/.cargo/config.toml index cd082c0..7afdf61 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [env] TS_RS_EXPORT_DIR = { value = "src/lib/types/generated", relative = true } + +[target.'cfg(all(target_os = "windows", target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7230d14..c1f60e7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,8 @@ concurrency: env: NODE_VERSION: 22 + # tauri-build statically links the versioned VC runtime when this is enabled. + STATIC_VCRUNTIME: "true" jobs: build-tauri: @@ -20,9 +22,11 @@ jobs: - platform: macos-latest args: "--target aarch64-apple-darwin" rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" + binary-arch: arm64 - platform: macos-latest args: "--target x86_64-apple-darwin" rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" + binary-arch: x86_64 - platform: ubuntu-22.04 args: "" rust-targets: "" @@ -61,11 +65,12 @@ jobs: - run: pnpm install - - name: Build CLI binary (Windows only) - if: matrix.platform == 'windows-latest' + - name: Remove cached package artifacts + shell: bash run: | - cargo build --release --package procnote-cli - Copy-Item "target/release/procnote.exe" "src-tauri/resources/cli/procnote.exe" + if [ -d target ]; then + find target -type d -name bundle -prune -exec rm -rf {} + + fi - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: @@ -77,3 +82,45 @@ jobs: releaseDraft: true prerelease: false args: ${{ matrix.args }} + + - name: Validate packaged macOS launcher + if: runner.os == 'macOS' + shell: bash + run: | + dmg=$(find target -type f -path '*/bundle/dmg/*.dmg' -print -quit) + test -n "$dmg" + scripts/validate-macos-package.sh "$dmg" "${{ matrix['binary-arch'] }}" + + - name: Validate packaged Linux launcher + if: runner.os == 'Linux' + shell: bash + run: | + deb=$(find target -type f -path '*/bundle/deb/*.deb' -print -quit) + test -n "$deb" + scripts/validate-linux-package.sh "$deb" + + - name: Validate packaged Windows launchers and installers + if: runner.os == 'Windows' + shell: pwsh + run: | + $installer = Get-ChildItem target -Recurse -Filter "*-setup.exe" | + Where-Object { $_.FullName.Contains("\bundle\nsis\") } | + Select-Object -First 1 + $msi = Get-ChildItem target -Recurse -Filter "*.msi" | + Where-Object { $_.FullName.Contains("\bundle\msi\") } | + Select-Object -First 1 + if ($null -eq $installer) { + throw "NSIS installer was not produced" + } + if ($null -eq $msi) { + throw "MSI installer was not produced" + } + + $legacyInstaller = Join-Path $env:RUNNER_TEMP "procnote_0.0.4_x64-setup.exe" + Invoke-WebRequest ` + -Uri "https://github.com/ut-issl/procnote/releases/download/v0.0.4/procnote_0.0.4_x64-setup.exe" ` + -OutFile $legacyInstaller + ./scripts/validate-windows-package.ps1 ` + -InstallerPath $installer.FullName ` + -MsiPath $msi.FullName ` + -LegacyInstallerPath $legacyInstaller diff --git a/.github/workflows/update-homebrew.yaml b/.github/workflows/update-homebrew.yaml index 5bdd9e9..f2a4bb9 100644 --- a/.github/workflows/update-homebrew.yaml +++ b/.github/workflows/update-homebrew.yaml @@ -91,7 +91,7 @@ jobs: depends_on :macos app "procnote.app" - binary "\#{appdir}/procnote.app/Contents/MacOS/procnote" + binary "\#{appdir}/procnote.app/Contents/Resources/bin/procnote" uninstall quit: "com.github.shunichironomura.procnote" diff --git a/Cargo.lock b/Cargo.lock index 48a428e..e14e505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3133,16 +3133,9 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "procnote-cli" -version = "0.0.3" -dependencies = [ - "procnote-tauri", -] - [[package]] name = "procnote-core" -version = "0.0.3" +version = "0.0.5" dependencies = [ "chrono", "log", @@ -3159,7 +3152,7 @@ dependencies = [ [[package]] name = "procnote-tauri" -version = "0.0.3" +version = "0.0.5" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 6da8fd8..5a9fe19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "3" -members = ["crates/procnote-cli", "crates/procnote-core", "src-tauri"] +members = ["crates/procnote-core", "src-tauri"] [workspace.package] -version = "0.0.4" +version = "0.0.5" description = "Event-sourced desktop app for tracking step-by-step procedures" readme = "README.md" repository = "https://github.com/shunichironomura/procnote" @@ -16,7 +16,6 @@ chrono = { version = "0.4.43", features = ["serde"] } clap = { version = "4.5.56", features = ["derive"] } log = "0.4" names = { version = "0.14.0", default-features = false } -procnote-tauri = { path = "src-tauri" } pulldown-cmark = "0.13.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.md b/README.md index d1bddb3..d4d96df 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,11 @@ Connect PSU to DUT J1 connector. Set voltage to 5.0V. Enable output. brew install --cask ut-issl/tap/procnote ``` -This installs the app to `/Applications/` and creates a `procnote` CLI command in your PATH. +Homebrew installs the app to `/Applications/` and links its terminal launcher into your PATH. If you install the DMG manually, create the link yourself: + +```sh +sudo ln -sf "/Applications/procnote.app/Contents/Resources/bin/procnote" /usr/local/bin/procnote +``` > [!NOTE] > The macOS builds are not currently code-signed or notarized. After installing, you need to remove the quarantine attribute: @@ -159,15 +163,24 @@ This installs the app to `/Applications/` and creates a `procnote` CLI command i > > Without this, macOS will show a "damaged and can't be opened" error. -### Windows / Linux +### Windows -> [!NOTE] -> Windows and Linux distribution is not well organized yet. Download installers manually from the [Releases page](https://github.com/ut-issl/procnote/releases). -> -> Available artifacts: -> -> - **Windows:** `.msi` and `.exe` installers -> - **Linux:** `.deb` package and `.AppImage` +Download the NSIS `.exe` installer from the [Releases page](https://github.com/ut-issl/procnote/releases). It installs the terminal launcher and adds its directory to your user PATH. Open a new terminal after installation. + +The `.msi` installer also contains the launcher, but its `bin` directory must currently be added to PATH manually. + +### Linux + +The `.deb` package installs both the desktop application and `/usr/bin/procnote`. AppImage users must install their own PATH entry or detached launcher for the downloaded AppImage. + +### Launch from a terminal + +```sh +procnote . +procnote /path/to/workspace +``` + +The launcher starts the desktop application with the requested workspace and immediately returns control to the terminal. ## Development diff --git a/crates/procnote-cli/Cargo.toml b/crates/procnote-cli/Cargo.toml deleted file mode 100644 index e8f296b..0000000 --- a/crates/procnote-cli/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "procnote-cli" -version.workspace = true -edition = "2024" -description.workspace = true -repository.workspace = true -license.workspace = true -readme.workspace = true -keywords.workspace = true -categories.workspace = true - -[[bin]] -name = "procnote" -path = "src/main.rs" -doc = false - -[dependencies] -procnote-tauri.workspace = true - -[lints] -workspace = true diff --git a/crates/procnote-cli/src/main.rs b/crates/procnote-cli/src/main.rs deleted file mode 100644 index d8df3ae..0000000 --- a/crates/procnote-cli/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - procnote_tauri_lib::run_cli(); -} diff --git a/scripts/validate-linux-package.sh b/scripts/validate-linux-package.sh new file mode 100755 index 0000000..5ba13fc --- /dev/null +++ b/scripts/validate-linux-package.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +deb=$1 +source_launcher=src-tauri/launchers/linux/procnote +extract_dir=$(mktemp -d) +trap 'rm -rf "$extract_dir"' EXIT HUP INT TERM + +dpkg-deb --extract "$deb" "$extract_dir" + +gui=$extract_dir/usr/bin/procnote-gui +launcher=$extract_dir/usr/bin/procnote + +if [ ! -x "$gui" ]; then + printf 'missing executable GUI in Debian package: %s\n' "$gui" >&2 + exit 1 +fi +if [ ! -x "$launcher" ]; then + printf 'missing executable launcher in Debian package: %s\n' "$launcher" >&2 + exit 1 +fi + +cmp "$source_launcher" "$launcher" +sh -n "$launcher" +"$gui" --version | grep -F 'procnote ' >/dev/null + +if ldd "$gui" | grep -F 'not found' >/dev/null; then + printf 'GUI has unresolved shared-library dependencies:\n' >&2 + ldd "$gui" >&2 + exit 1 +fi + +desktop_file=$(find "$extract_dir/usr/share/applications" -type f -name '*.desktop' -print -quit) +if [ -z "$desktop_file" ] || ! grep -F 'Exec=procnote-gui' "$desktop_file" >/dev/null; then + printf 'desktop entry does not launch procnote-gui\n' >&2 + exit 1 +fi + +printf 'Validated Linux package: %s\n' "$deb" diff --git a/scripts/validate-macos-package.sh b/scripts/validate-macos-package.sh new file mode 100755 index 0000000..a4bd3b9 --- /dev/null +++ b/scripts/validate-macos-package.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +dmg=$1 +expected_arch=$2 +case $expected_arch in + arm64 | x86_64) ;; + *) + printf 'unsupported expected architecture: %s\n' "$expected_arch" >&2 + exit 2 + ;; +esac +source_launcher=src-tauri/launchers/macos/procnote +mount_dir=$(mktemp -d) +attached=false + +cleanup() { + if [ "$attached" = true ]; then + hdiutil detach "$mount_dir" -quiet || true + fi + rm -rf "$mount_dir" +} +trap cleanup EXIT HUP INT TERM + +hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" -quiet +attached=true + +app=$mount_dir/procnote.app +gui=$app/Contents/MacOS/procnote +launcher=$app/Contents/Resources/bin/procnote + +if [ ! -x "$gui" ]; then + printf 'missing executable GUI in DMG: %s\n' "$gui" >&2 + exit 1 +fi +if [ ! -x "$launcher" ]; then + printf 'missing executable launcher in DMG: %s\n' "$launcher" >&2 + exit 1 +fi + +cmp "$source_launcher" "$launcher" +sh -n "$launcher" +actual_arch=$(lipo -archs "$gui") +if [ "$actual_arch" != "$expected_arch" ]; then + printf 'unexpected GUI architecture: expected %s, got %s\n' "$expected_arch" "$actual_arch" >&2 + exit 1 +fi +if [ "$(uname -m)" = "$expected_arch" ]; then + "$gui" --version | grep -F 'procnote ' >/dev/null +fi + +if [ -e "$app/Contents/Resources/cli/procnote" ] || [ -e "$app/Contents/Resources/cli/procnote.exe" ]; then + printf 'legacy CLI executable is still packaged in the DMG\n' >&2 + exit 1 +fi + +printf 'Validated macOS package: %s\n' "$dmg" diff --git a/scripts/validate-windows-package.ps1 b/scripts/validate-windows-package.ps1 new file mode 100644 index 0000000..ca030d4 --- /dev/null +++ b/scripts/validate-windows-package.ps1 @@ -0,0 +1,371 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InstallerPath, + [Parameter(Mandatory = $true)] + [string]$MsiPath, + [string]$LegacyInstallerPath +) + +$ErrorActionPreference = "Stop" + +function Find-Executable { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [string[]]$SearchRoots = @() + ) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + foreach ($root in $SearchRoots) { + if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path $root)) { + continue + } + $match = Get-ChildItem $root -Recurse -Filter $Name -File | + Where-Object { $_.FullName.Contains("\x64\") } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -ne $match) { + return $match.FullName + } + } + + throw "Could not find required executable: $Name" +} + +function Get-UserPathSnapshot { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment") + if ($null -eq $key) { + return [pscustomobject]@{ + Exists = $false + Value = $null + Kind = $null + } + } + + try { + $exists = @($key.GetValueNames()) -contains "Path" + if (-not $exists) { + return [pscustomobject]@{ + Exists = $false + Value = $null + Kind = $null + } + } + + return [pscustomobject]@{ + Exists = $true + Value = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + Kind = $key.GetValueKind("Path") + } + } + finally { + $key.Dispose() + } +} + +function Set-UserPath { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Value, + [Microsoft.Win32.RegistryValueKind]$Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + ) + + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") + try { + $key.SetValue("Path", $Value, $Kind) + } + finally { + $key.Dispose() + } +} + +function Restore-UserPath { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Snapshot + ) + + if ($Snapshot.Exists) { + Set-UserPath -Value ([string]$Snapshot.Value) -Kind $Snapshot.Kind + return + } + + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") + try { + $key.DeleteValue("Path", $false) + } + finally { + $key.Dispose() + } +} + +function Invoke-Process { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @(), + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -PassThru -Wait + if ($process.ExitCode -ne 0) { + throw "$Description failed with exit code $($process.ExitCode)" + } +} + +$installer = (Resolve-Path $InstallerPath).Path +$msi = (Resolve-Path $MsiPath).Path +$legacyInstaller = if ([string]::IsNullOrWhiteSpace($LegacyInstallerPath)) { + $null +} +else { + (Resolve-Path $LegacyInstallerPath).Path +} +$extractDir = Join-Path $env:RUNNER_TEMP "procnote-nsis-extracted" +$msiExtractDir = Join-Path $env:RUNNER_TEMP "procnote-msi-extracted" +$manifestPath = Join-Path $env:RUNNER_TEMP "procnote.exe.manifest" +Remove-Item $extractDir, $msiExtractDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item $extractDir -ItemType Directory | Out-Null + +& 7z x -y "-o$extractDir" $installer | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "Failed to extract NSIS installer: $installer" +} + +$gui = Join-Path $extractDir "procnote.exe" +$launcher = Join-Path $extractDir "bin\procnote.cmd" +$pathUpdater = Join-Path $extractDir "installer\update-user-path.ps1" +$sourceLauncher = Join-Path $PWD "src-tauri\launchers\windows\procnote.cmd" +$sourcePathUpdater = Join-Path $PWD "src-tauri\nsis\update-user-path.ps1" + +if (-not (Test-Path $gui -PathType Leaf)) { + throw "Packaged GUI executable is missing: $gui" +} +$nsisGuiMatches = @(Get-ChildItem $extractDir -Recurse -Filter "procnote.exe" -File) +if ($nsisGuiMatches.Count -ne 1) { + throw "NSIS must contain exactly one GUI executable; found $($nsisGuiMatches.Count)" +} +if (-not (Test-Path $launcher -PathType Leaf)) { + throw "Packaged terminal launcher is missing: $launcher" +} +if (-not (Test-Path $pathUpdater -PathType Leaf)) { + throw "Packaged PATH updater is missing: $pathUpdater" +} +if (Test-Path (Join-Path $extractDir "cli")) { + throw "Legacy CLI directory is still packaged" +} +if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $launcher).Hash) { + throw "Packaged launcher differs from its source file" +} +if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $pathUpdater).Hash) { + throw "Packaged PATH updater differs from its source file" +} + +# An administrative install extracts MSI payloads without changing machine state. +New-Item $msiExtractDir -ItemType Directory | Out-Null +Invoke-Process ` + -FilePath "msiexec.exe" ` + -ArgumentList @("/a", "`"$msi`"", "/qn", "/norestart", "TARGETDIR=`"$msiExtractDir`"") ` + -Description "MSI administrative extraction" + +$msiGuiMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.exe" -File) +$msiLauncherMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.cmd" -File) +$msiPathUpdaterMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "update-user-path.ps1" -File) +if ($msiGuiMatches.Count -ne 1) { + throw "MSI must contain exactly one GUI executable; found $($msiGuiMatches.Count)" +} +if ($msiLauncherMatches.Count -ne 1 -or + -not $msiLauncherMatches[0].FullName.EndsWith("\bin\procnote.cmd", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "MSI does not contain the terminal launcher under its bin directory" +} +if ($msiPathUpdaterMatches.Count -ne 1 -or + -not $msiPathUpdaterMatches[0].FullName.EndsWith("\installer\update-user-path.ps1", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "MSI does not contain the packaged PATH updater under its installer directory" +} +if ((Get-FileHash $gui).Hash -ne (Get-FileHash $msiGuiMatches[0].FullName).Hash) { + throw "MSI and NSIS contain different GUI executables" +} +if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $msiLauncherMatches[0].FullName).Hash) { + throw "MSI launcher differs from its source file" +} +if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $msiPathUpdaterMatches[0].FullName).Hash) { + throw "MSI PATH updater differs from its source file" +} + +$windowsKits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin" +$mt = Find-Executable -Name "mt.exe" -SearchRoots @($windowsKits) +& $mt -nologo "-inputresource:$gui;#1" "-out:$manifestPath" +if ($LASTEXITCODE -ne 0) { + throw "Failed to extract the GUI application manifest" +} +$manifest = Get-Content $manifestPath -Raw +if (-not $manifest.Contains('name="Microsoft.Windows.Common-Controls"') -or -not $manifest.Contains('version="6.0.0.0"')) { + throw "GUI application manifest does not activate Common Controls v6" +} + +$visualStudioRoot = $null +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +if (Test-Path $vswhere) { + $visualStudioRoot = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +} +$dumpbinRoots = if ([string]::IsNullOrWhiteSpace($visualStudioRoot)) { @() } else { @(Join-Path $visualStudioRoot "VC\Tools\MSVC") } +$dumpbin = Find-Executable -Name "dumpbin.exe" -SearchRoots $dumpbinRoots + +$headers = (& $dumpbin /headers $gui | Out-String).ToLowerInvariant() +if (-not $headers.Contains("machine (x64)")) { + throw "Packaged GUI is not an x64 executable" +} +if (-not $headers.Contains("subsystem (windows gui)")) { + throw "Packaged executable does not use the Windows GUI subsystem" +} + +$dependencies = (& $dumpbin /dependents $gui | Out-String).ToUpperInvariant() +# UCRTBASE.DLL and API-MS-WIN-CRT-* are Windows components and are expected: +# Tauri statically links the versioned VC runtime while dynamically linking UCRT. +$redistributableRuntimePrefixes = @( + "VCRUNTIME", + "MSVCR1", + "MSVCP", + "CONCRT", + "VCAMP", + "VCOMP" +) +foreach ($runtimePrefix in $redistributableRuntimePrefixes) { + if ($dependencies.Contains($runtimePrefix)) { + throw "Packaged GUI dynamically imports a Visual C++ Redistributable library: $runtimePrefix" + } +} + +$process = Start-Process -FilePath $gui -ArgumentList "--version" -PassThru +if (-not $process.WaitForExit(30000)) { + $process.Kill() + throw "Packaged GUI did not complete --version within 30 seconds" +} +if ($process.ExitCode -ne 0) { + throw "Packaged GUI failed --version with exit code $($process.ExitCode)" +} + +$wrapperProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @("/d", "/c", "call `"$launcher`" --version") -PassThru -Wait +if ($wrapperProcess.ExitCode -ne 0) { + throw "Packaged launcher failed with exit code $($wrapperProcess.ExitCode)" +} +Get-Process -Name "procnote" -ErrorAction SilentlyContinue | Wait-Process -Timeout 30 + +# Exercise the real NSIS hooks against controlled user-PATH entries. When the +# v0.0.4 installer is supplied, this performs an actual silent upgrade; otherwise +# it creates the legacy directory layout directly. +$installDir = Join-Path $env:LOCALAPPDATA "procnote" +$installedLauncher = Join-Path $installDir "bin\procnote.cmd" +$installedPathUpdater = Join-Path $installDir "installer\update-user-path.ps1" +$uninstaller = Join-Path $installDir "uninstall.exe" +$legacyDir = Join-Path $installDir "cli" +$binDir = Join-Path $installDir "bin" +$pathSnapshot = Get-UserPathSnapshot + +if (Test-Path $installDir) { + throw "Refusing to overwrite an existing validation install: $installDir" +} + +$beforeEntry = "C:\procnote-path-test-before" +$legacyNeighbor = "${legacyDir}-tools" +$binNeighbor = "${binDir}-tools" +$afterEntry = "C:\procnote-path-test-after" +$paddingEntries = @("%USERPROFILE%\procnote-path-test-env", "") + + @(0..39 | ForEach-Object { "C:\procnote-path-test-padding-{0:D3}" -f $_ }) +$preservedEntries = @($beforeEntry) + $paddingEntries + @($legacyNeighbor, $binNeighbor, $afterEntry) +$initialEntries = @($beforeEntry) + $paddingEntries + @( + $legacyDir.ToUpperInvariant(), + $legacyNeighbor, + $binDir.ToUpperInvariant(), + $binNeighbor, + $binDir, + $afterEntry +) +$initialPath = $initialEntries -join ";" +$expectedInstalledPath = @($preservedEntries + $binDir) -join ";" +$expectedUninstalledPath = $preservedEntries -join ";" +if ($initialPath.Length -le 1024) { + throw "PATH lifecycle fixture must exceed NSIS's string limit" +} + +try { + if ($null -ne $legacyInstaller) { + $expectedLegacyInstallerHash = "CA310856FFF274EEAB13D9698B27C2D1F1CF681733E6861AF99FBEF418A0E7B5" + if ((Get-FileHash $legacyInstaller).Hash -cne $expectedLegacyInstallerHash) { + throw "v0.0.4 installer hash does not match the published artifact" + } + + Set-UserPath -Value $beforeEntry + Invoke-Process -FilePath $legacyInstaller -ArgumentList @("/S") -Description "v0.0.4 NSIS installation" + if (-not (Test-Path (Join-Path $legacyDir "procnote.exe") -PathType Leaf)) { + throw "v0.0.4 installer did not create the legacy CLI executable" + } + $legacyPath = Get-UserPathSnapshot + $expectedLegacyPath = @($beforeEntry, $legacyDir) -join ";" + if (-not $legacyPath.Exists -or + $legacyPath.Kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString -or + $legacyPath.Value -cne $expectedLegacyPath) { + throw "v0.0.4 installer produced an unexpected user PATH: $($legacyPath.Value)" + } + } + else { + New-Item $legacyDir -ItemType Directory -Force | Out-Null + Set-Content (Join-Path $legacyDir "legacy-marker.txt") "legacy CLI layout" + } + Set-UserPath -Value $initialPath + + Invoke-Process -FilePath $installer -ArgumentList @("/S") -Description "current NSIS installation" + + if (-not (Test-Path $installedLauncher -PathType Leaf)) { + throw "NSIS installation did not install the terminal launcher" + } + if (-not (Test-Path $installedPathUpdater -PathType Leaf)) { + throw "NSIS installation did not install its PATH updater" + } + if (Test-Path $legacyDir) { + throw "NSIS installation did not remove the legacy CLI directory" + } + if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $installedLauncher).Hash) { + throw "Installed launcher differs from its source file" + } + if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $installedPathUpdater).Hash) { + throw "Installed PATH updater differs from its source file" + } + + $installedPath = Get-UserPathSnapshot + if (-not $installedPath.Exists -or + $installedPath.Kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString -or + $installedPath.Value -cne $expectedInstalledPath) { + throw "NSIS installation produced an unexpected user PATH: $($installedPath.Value)" + } + + if (-not (Test-Path $uninstaller -PathType Leaf)) { + throw "NSIS installation did not create an uninstaller" + } + Invoke-Process -FilePath $uninstaller -ArgumentList @("/S") -Description "NSIS uninstallation" + + $uninstalledPath = Get-UserPathSnapshot + if (-not $uninstalledPath.Exists -or + $uninstalledPath.Kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString -or + $uninstalledPath.Value -cne $expectedUninstalledPath) { + throw "NSIS uninstallation produced an unexpected user PATH: $($uninstalledPath.Value)" + } +} +finally { + if (Test-Path $uninstaller -PathType Leaf) { + $cleanup = Start-Process -FilePath $uninstaller -ArgumentList @("/S") -PassThru -Wait + if ($cleanup.ExitCode -ne 0) { + Write-Warning "Validation uninstaller cleanup failed with exit code $($cleanup.ExitCode)" + } + } + Restore-UserPath -Snapshot $pathSnapshot + Remove-Item $installDir -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Validated Windows NSIS/MSI packages and installer lifecycle: $installer; $msi" diff --git a/src-tauri/launchers/README.md b/src-tauri/launchers/README.md new file mode 100644 index 0000000..f7c5021 --- /dev/null +++ b/src-tauri/launchers/README.md @@ -0,0 +1,19 @@ +# Terminal launchers + +`procnote` has one compiled desktop executable. These scripts expose a +VS Code-style `procnote ` command without building a second copy of +the Tauri application. + +- Windows bundles `windows/procnote.cmd` as `bin/procnote.cmd`; the NSIS hook + uses `nsis/update-user-path.ps1` to add that directory to the user's PATH + without NSIS's string-length limit. +- macOS bundles `macos/procnote` inside the app. The Homebrew cask links this + script, and DMG users can link it manually. +- Linux packages rename the bundled GUI to `procnote-gui` and install + `linux/procnote` as `/usr/bin/procnote`. + +Each launcher preserves the caller's working directory and environment, +forwards arguments unchanged, detaches the GUI process, and returns control to +the terminal. Do not replace these launchers with a separately built Tauri +binary: Cargo build-script resource and linker settings are package-scoped, so +a second package can silently lose the GUI manifest or runtime configuration. diff --git a/src-tauri/launchers/linux/procnote b/src-tauri/launchers/linux/procnote new file mode 100755 index 0000000..35d9952 --- /dev/null +++ b/src-tauri/launchers/linux/procnote @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +launcher=$0 +while [ -L "$launcher" ]; do + launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) + target=$(readlink "$launcher") + case $target in + /*) launcher=$target ;; + *) launcher=$launcher_dir/$target ;; + esac +done + +launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) +gui=$launcher_dir/procnote-gui + +if [ ! -x "$gui" ]; then + printf 'procnote: application executable not found: %s\n' "$gui" >&2 + exit 1 +fi + +nohup "$gui" "$@" /dev/null 2>&1 & diff --git a/src-tauri/launchers/macos/procnote b/src-tauri/launchers/macos/procnote new file mode 100755 index 0000000..5455010 --- /dev/null +++ b/src-tauri/launchers/macos/procnote @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +launcher=$0 +while [ -L "$launcher" ]; do + launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) + target=$(readlink "$launcher") + case $target in + /*) launcher=$target ;; + *) launcher=$launcher_dir/$target ;; + esac +done + +launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) +contents_dir=$(CDPATH= cd -P "$launcher_dir/../.." && pwd) +gui=$contents_dir/MacOS/procnote + +if [ ! -x "$gui" ]; then + printf 'procnote: application executable not found: %s\n' "$gui" >&2 + exit 1 +fi + +nohup "$gui" "$@" /dev/null 2>&1 & diff --git a/src-tauri/launchers/windows/procnote.cmd b/src-tauri/launchers/windows/procnote.cmd new file mode 100644 index 0000000..db18f1e --- /dev/null +++ b/src-tauri/launchers/windows/procnote.cmd @@ -0,0 +1,2 @@ +@echo off +start "" "%~dp0..\procnote.exe" %* diff --git a/src-tauri/nsis/hooks.nsh b/src-tauri/nsis/hooks.nsh index 9ede43b..71b8b5e 100644 --- a/src-tauri/nsis/hooks.nsh +++ b/src-tauri/nsis/hooks.nsh @@ -1,36 +1,23 @@ -; Add $INSTDIR\cli to user PATH on install +; Add $INSTDIR\bin to the current user's PATH and migrate the old CLI location. !macro NSIS_HOOK_POSTINSTALL - ReadRegStr $0 HKCU "Environment" "Path" - ; Check if already in PATH - ${WordFind} "$0" "$INSTDIR\cli" "E+1{" $1 - IfErrors 0 _path_already_set - ; Not found — append - StrCmp $0 "" 0 +2 - StrCpy $0 "$INSTDIR\cli" - StrCmp $0 "$INSTDIR\cli" +2 0 - StrCpy $0 "$0;$INSTDIR\cli" - WriteRegExpandStr HKCU "Environment" "Path" "$0" - SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 - _path_already_set: + ; The pre-0.0.5 installer placed a second Tauri executable here. It must not + ; remain ahead of the launcher because PATHEXT resolves .exe before .cmd. + RMDir /r "$INSTDIR\cli" + + ; Use PowerShell's registry API instead of NSIS strings, which are limited to + ; 1024 characters and can otherwise truncate a long user PATH. + nsExec::ExecToLog /TIMEOUT=30000 '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\installer\update-user-path.ps1" install "$INSTDIR\bin" "$INSTDIR\cli"' + Pop $0 + StrCmp $0 0 +2 + DetailPrint "Could not add the procnote launcher to user PATH (exit code $0)" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 !macroend -; Remove $INSTDIR\cli from user PATH on uninstall +; Remove both the current and legacy launcher directories from user PATH. !macro NSIS_HOOK_PREUNINSTALL - ReadRegStr $0 HKCU "Environment" "Path" - ; Remove "$INSTDIR\cli" (handles ";dir", "dir;", and standalone "dir") - ${WordReplace} "$0" "$INSTDIR\cli" "" "+" $0 - ; Clean up double semicolons - ${WordReplace} "$0" ";;" ";" "+*" $0 - ; Remove leading semicolon - StrCpy $1 $0 1 - StrCmp $1 ";" 0 +2 - StrCpy $0 $0 "" 1 - ; Remove trailing semicolon - StrLen $1 $0 - IntOp $1 $1 - 1 - StrCpy $2 $0 1 $1 - StrCmp $2 ";" 0 +2 - StrCpy $0 $0 $1 - WriteRegExpandStr HKCU "Environment" "Path" "$0" + nsExec::ExecToLog /TIMEOUT=30000 '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\installer\update-user-path.ps1" uninstall "$INSTDIR\bin" "$INSTDIR\cli"' + Pop $0 + StrCmp $0 0 +2 + DetailPrint "Could not remove the procnote launcher from user PATH (exit code $0)" SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 !macroend diff --git a/src-tauri/nsis/update-user-path.ps1 b/src-tauri/nsis/update-user-path.ps1 new file mode 100644 index 0000000..4962df8 --- /dev/null +++ b/src-tauri/nsis/update-user-path.ps1 @@ -0,0 +1,76 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet("install", "uninstall")] + [string]$Action, + [Parameter(Mandatory = $true, Position = 1)] + [string]$LauncherDirectory, + [Parameter(Mandatory = $true, Position = 2)] + [string]$LegacyCliDirectory +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Update-PathEntries { + param( + [AllowEmptyString()] + [string]$PathValue, + [Parameter(Mandatory = $true)] + [string]$LauncherDirectory, + [Parameter(Mandatory = $true)] + [string]$LegacyCliDirectory, + [Parameter(Mandatory = $true)] + [bool]$Install + ) + + $entries = New-Object "System.Collections.Generic.List[string]" + if (-not [string]::IsNullOrEmpty($PathValue)) { + foreach ($entry in $PathValue.Split([char]";")) { + $isLauncher = [string]::Equals($entry, $LauncherDirectory, [System.StringComparison]::OrdinalIgnoreCase) + $isLegacyCli = [string]::Equals($entry, $LegacyCliDirectory, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $isLauncher -and -not $isLegacyCli) { + $entries.Add($entry) + } + } + } + + if ($Install) { + $entries.Add($LauncherDirectory) + } + + return [string]::Join(";", $entries.ToArray()) +} + +$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") +try { + $valueExists = @($key.GetValueNames()) -contains "Path" + $valueKind = if ($valueExists) { + $key.GetValueKind("Path") + } + else { + [Microsoft.Win32.RegistryValueKind]::ExpandString + } + + if ($valueKind -ne [Microsoft.Win32.RegistryValueKind]::String -and + $valueKind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { + throw "The user Path registry value has unsupported type $valueKind" + } + + $pathValue = [string]$key.GetValue( + "Path", + "", + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $updatedPath = Update-PathEntries ` + -PathValue $pathValue ` + -LauncherDirectory $LauncherDirectory ` + -LegacyCliDirectory $LegacyCliDirectory ` + -Install ($Action -eq "install") + + $key.SetValue("Path", $updatedPath, $valueKind) +} +finally { + if ($null -ne $key) { + $key.Dispose() + } +} diff --git a/src-tauri/resources/cli/.gitkeep b/src-tauri/resources/cli/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b8abd4..fda2010 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,29 +25,28 @@ use commands::template::list_templates; use drop_point::{DropPointClient, DropPointConfig, DropPointSessions, cleanup_persisted_sessions}; use state::AppState; -/// Command-line arguments shared by both binary crates. +/// Arguments accepted by the desktop executable. #[derive(Parser, Debug)] #[command( name = "procnote", version, about = "procnote - Procedure execution tool for hardware testing." )] -pub struct Args { +struct Args { /// Workspace directory containing procedure subdirectories. /// Defaults to the current working directory. #[arg(default_value = ".")] - pub workspace: PathBuf, + workspace: PathBuf, } -/// Entry point used by both `procnote-cli` and `procnote-tauri` binaries. -/// Parses CLI arguments and hands off to [`run`]. -pub fn run_cli() { +/// Parses the optional workspace argument and starts the desktop application. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { let args = Args::parse(); - run(&args.workspace); + run_with_workspace(&args.workspace); } -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run(workspace: &Path) { +fn run_with_workspace(workspace: &Path) { // Canonicalize early so relative paths like "." resolve correctly. let procedures_dir = workspace.canonicalize().unwrap_or_else(|_| { panic!( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 700cbaa..ee75a6d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - procnote_tauri_lib::run_cli(); + procnote_tauri_lib::run(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e298e31..dde3138 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,9 +33,6 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": { - "resources/cli/*": "cli/" - }, "windows": { "nsis": { "installerHooks": "nsis/hooks.nsh" diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..84cd8c6 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,17 @@ +{ + "mainBinaryName": "procnote-gui", + "bundle": { + "linux": { + "deb": { + "files": { + "/usr/bin/procnote": "launchers/linux/procnote" + } + }, + "rpm": { + "files": { + "/usr/bin/procnote": "launchers/linux/procnote" + } + } + } + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..b1d083b --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "resources": { + "launchers/macos/procnote": "bin/procnote" + } + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..5ffb888 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,8 @@ +{ + "bundle": { + "resources": { + "launchers/windows/procnote.cmd": "bin/procnote.cmd", + "nsis/update-user-path.ps1": "installer/update-user-path.ps1" + } + } +} From 20686c6c067417fafb156b0fad9d5f0612f7887d Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Tue, 21 Jul 2026 23:26:58 +0900 Subject: [PATCH 2/5] Replace script wrappers with a native launcher --- .github/workflows/release.yaml | 1 + .gitignore | 1 + CLAUDE.md | 1 + Cargo.lock | 11 + Cargo.toml | 2 +- README.md | 6 +- crates/procnote-launcher/Cargo.toml | 26 ++ crates/procnote-launcher/src/lib.rs | 255 ++++++++++++++++++ crates/procnote-launcher/src/main.rs | 31 +++ crates/procnote-launcher/tests/cli.rs | 138 ++++++++++ justfile | 2 +- package.json | 2 + scripts/prepare-launcher.mjs | 116 ++++++++ scripts/tauri.mjs | 88 ++++++ scripts/validate-linux-package.sh | 24 +- scripts/validate-macos-package.sh | 32 ++- scripts/validate-windows-package.ps1 | 212 ++++++++++++--- src-tauri/launchers/README.md | 45 ++-- src-tauri/launchers/linux/procnote | 22 -- src-tauri/launchers/macos/procnote | 23 -- src-tauri/launchers/windows/procnote.cmd | 2 - src-tauri/src/lib.rs | 24 ++ src-tauri/tauri.bundle.linux.conf.json | 16 ++ src-tauri/tauri.bundle.macos.conf.json | 7 + ...nf.json => tauri.bundle.windows.conf.json} | 2 +- src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.linux.conf.json | 16 +- src-tauri/tauri.macos.conf.json | 7 - 28 files changed, 978 insertions(+), 136 deletions(-) create mode 100644 crates/procnote-launcher/Cargo.toml create mode 100644 crates/procnote-launcher/src/lib.rs create mode 100644 crates/procnote-launcher/src/main.rs create mode 100644 crates/procnote-launcher/tests/cli.rs create mode 100644 scripts/prepare-launcher.mjs create mode 100644 scripts/tauri.mjs delete mode 100755 src-tauri/launchers/linux/procnote delete mode 100755 src-tauri/launchers/macos/procnote delete mode 100644 src-tauri/launchers/windows/procnote.cmd create mode 100644 src-tauri/tauri.bundle.linux.conf.json create mode 100644 src-tauri/tauri.bundle.macos.conf.json rename src-tauri/{tauri.windows.conf.json => tauri.bundle.windows.conf.json} (63%) delete mode 100644 src-tauri/tauri.macos.conf.json diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c1f60e7..2b3e117 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -81,6 +81,7 @@ jobs: releaseBody: "See the assets to download and install this version." releaseDraft: true prerelease: false + tauriScript: "pnpm run tauri" args: ${{ matrix.args }} - name: Validate packaged macOS launcher diff --git a/.gitignore b/.gitignore index 0a7b374..4ded041 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules # Rust /target +/src-tauri/launchers/bin/ # Runtime data (execution logs inside each procedure directory) .executions diff --git a/CLAUDE.md b/CLAUDE.md index 6f62e5c..2c7320e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ 1. **`crates/procnote-core/`** — Pure Rust domain logic. No Tauri dependency. Contains event types, execution state machine, and template parser. 2. **`src-tauri/`** — Tauri shell. Bridges core to desktop via IPC commands. Owns serialization DTOs (`ExecutionSummary`, `StepSummary`, etc.) and filesystem I/O. 3. **`src/`** — SvelteKit + Svelte 5 frontend. Uses runes (`$state`, `$derived`, `$props`), not stores. +- **`crates/procnote-launcher/`** is a Tauri-free console adapter outside those domain layers. It owns `procnote [WORKSPACE]`, prints help/version synchronously, and detaches the packaged GUI. It must never depend on `procnote-tauri`, Tauri, or GUI/dialog libraries. ### Core domain (`procnote-core`) diff --git a/Cargo.lock b/Cargo.lock index e14e505..ebe38a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3150,6 +3150,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "procnote-launcher" +version = "0.0.5" +dependencies = [ + "clap", + "libc", + "tempfile", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + [[package]] name = "procnote-tauri" version = "0.0.5" diff --git a/Cargo.toml b/Cargo.toml index 5a9fe19..e2d36ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/procnote-core", "src-tauri"] +members = ["crates/procnote-core", "crates/procnote-launcher", "src-tauri"] [workspace.package] version = "0.0.5" diff --git a/README.md b/README.md index d4d96df..ae5db2a 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,11 @@ The `.deb` package installs both the desktop application and `/usr/bin/procnote` ```sh procnote . procnote /path/to/workspace +procnote --help +procnote --version ``` -The launcher starts the desktop application with the requested workspace and immediately returns control to the terminal. +Workspace commands start the desktop application and immediately return control to the terminal. Help, version, and argument errors are printed synchronously without starting the GUI. ## Development @@ -211,4 +213,6 @@ Three layers with strict dependency direction: 2. **`src-tauri/`** -- Tauri shell. Bridges core to desktop via IPC commands. Owns serialization DTOs and filesystem I/O. 3. **`src/`** -- SvelteKit + Svelte 5 frontend. +**`crates/procnote-launcher/`** is a separate, Tauri-free console adapter. It handles the public terminal interface and starts the packaged GUI; it does not participate in the domain dependency layers above. + Executions are stored as append-only JSONL event logs under `.executions/`. diff --git a/crates/procnote-launcher/Cargo.toml b/crates/procnote-launcher/Cargo.toml new file mode 100644 index 0000000..73824e1 --- /dev/null +++ b/crates/procnote-launcher/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "procnote-launcher" +edition = "2024" +version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +clap.workspace = true +thiserror.workspace = true + +[lints] +workspace = true + +[target."cfg(unix)".dependencies] +libc = "0.2.186" + +[target."cfg(windows)".dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_System_Threading"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/procnote-launcher/src/lib.rs b/crates/procnote-launcher/src/lib.rs new file mode 100644 index 0000000..b959890 --- /dev/null +++ b/crates/procnote-launcher/src/lib.rs @@ -0,0 +1,255 @@ +use std::env; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LaunchError { + #[error("could not determine the launcher executable path")] + CurrentExecutable(#[source] io::Error), + + #[error("could not resolve launcher executable path {}", path.display())] + ResolveLauncher { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("launcher path {} does not match the expected {layout} package layout", path.display())] + InvalidPackageLayout { path: PathBuf, layout: &'static str }, + + #[error("could not start application executable {}", path.display())] + Spawn { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PackageLayout { + name: &'static str, + ancestor_levels: usize, + gui_components: &'static [&'static str], +} + +/// Starts the packaged GUI for `workspace` and returns as soon as the child has +/// been created. +/// +/// The child inherits the launcher's current working directory and environment, +/// but is placed in a detached process group/session with null standard streams. +pub fn launch(workspace: &Path) -> Result<(), LaunchError> { + let launcher = env::current_exe().map_err(LaunchError::CurrentExecutable)?; + let launcher = launcher + .canonicalize() + .map_err(|source| LaunchError::ResolveLauncher { + path: launcher, + source, + })?; + let gui = packaged_gui_path(&launcher, current_package_layout())?; + + spawn_detached(&gui, workspace) + .map(|_| ()) + .map_err(|source| LaunchError::Spawn { path: gui, source }) +} + +fn packaged_gui_path(launcher: &Path, layout: PackageLayout) -> Result { + let invalid_layout = || LaunchError::InvalidPackageLayout { + path: launcher.to_path_buf(), + layout: layout.name, + }; + let package_root = (0..layout.ancestor_levels) + .try_fold(launcher, |path, _| path.parent().ok_or_else(invalid_layout))?; + + Ok(layout + .gui_components + .iter() + .fold(package_root.to_path_buf(), |path, component| { + path.join(component) + })) +} + +fn spawn_detached(gui: &Path, workspace: &Path) -> io::Result { + detached_command(gui, workspace).spawn() +} + +fn detached_command(gui: &Path, workspace: &Path) -> Command { + let mut command = Command::new(gui); + command + .arg("--") + .arg(workspace) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_detachment(&mut command); + command +} + +#[cfg(unix)] +#[expect( + unsafe_code, + reason = "stable CommandExt does not yet expose a safe setsid operation" +)] +fn configure_detachment(command: &mut Command) { + use std::os::unix::process::CommandExt; + + // SAFETY: POSIX specifies setsid as async-signal-safe. The closure performs + // only that call and captures no state, satisfying pre_exec's requirements. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } +} + +#[cfg(windows)] +fn configure_detachment(command: &mut Command) { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS}; + + command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); +} + +#[cfg(target_os = "windows")] +const fn current_package_layout() -> PackageLayout { + PackageLayout { + name: "Windows", + ancestor_levels: 2, + gui_components: &["procnote.exe"], + } +} + +#[cfg(target_os = "macos")] +const fn current_package_layout() -> PackageLayout { + PackageLayout { + name: "macOS", + ancestor_levels: 3, + gui_components: &["MacOS", "procnote"], + } +} + +#[cfg(target_os = "linux")] +const fn current_package_layout() -> PackageLayout { + PackageLayout { + name: "Linux", + ancestor_levels: 1, + gui_components: &["procnote-gui"], + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +compile_error!("procnote-launcher supports Windows, macOS, and Linux only"); + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + + use super::*; + + const WINDOWS_LAYOUT: PackageLayout = PackageLayout { + name: "Windows", + ancestor_levels: 2, + gui_components: &["procnote.exe"], + }; + const MACOS_LAYOUT: PackageLayout = PackageLayout { + name: "macOS", + ancestor_levels: 3, + gui_components: &["MacOS", "procnote"], + }; + const LINUX_LAYOUT: PackageLayout = PackageLayout { + name: "Linux", + ancestor_levels: 1, + gui_components: &["procnote-gui"], + }; + + #[test] + fn resolves_windows_package_layout() { + let launcher = Path::new("/install/procnote/bin/procnote.exe"); + + assert_eq!( + packaged_gui_path(launcher, WINDOWS_LAYOUT).expect("valid Windows package layout"), + Path::new("/install/procnote/procnote.exe") + ); + } + + #[test] + fn resolves_macos_package_layout() { + let launcher = Path::new("/Applications/procnote.app/Contents/Resources/bin/procnote"); + + assert_eq!( + packaged_gui_path(launcher, MACOS_LAYOUT).expect("valid macOS package layout"), + Path::new("/Applications/procnote.app/Contents/MacOS/procnote") + ); + } + + #[test] + fn resolves_linux_package_layout() { + let launcher = Path::new("/usr/bin/procnote"); + + assert_eq!( + packaged_gui_path(launcher, LINUX_LAYOUT).expect("valid Linux package layout"), + Path::new("/usr/bin/procnote-gui") + ); + } + + #[test] + fn rejects_launcher_outside_expected_layout() { + let error = packaged_gui_path(Path::new("procnote"), WINDOWS_LAYOUT) + .expect_err("a relative filename has too few ancestors"); + + assert!(matches!(error, LaunchError::InvalidPackageLayout { .. })); + } + + #[test] + fn command_preserves_workspace_as_one_os_argument() { + let command = detached_command( + Path::new("procnote-gui"), + Path::new("workspace with spaces"), + ); + + assert_eq!( + command.get_args().collect::>(), + [OsStr::new("--"), OsStr::new("workspace with spaces")] + ); + assert_eq!(command.get_current_dir(), None); + assert_eq!(command.get_envs().count(), 0); + } + + #[cfg(unix)] + #[test] + fn detached_child_inherits_working_directory_and_receives_workspace() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().expect("create temporary package"); + let gui = temporary.path().join("fake gui"); + let output = temporary.path().join("fake gui.output"); + fs::write( + &gui, + "#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' \"$1\" \"$2\" \"$PWD\" > \"$0.output\"\n", + ) + .expect("write fake GUI"); + fs::set_permissions(&gui, fs::Permissions::from_mode(0o755)) + .expect("make fake GUI executable"); + + let workspace = Path::new("workspace with spaces"); + let mut child = spawn_detached(&gui, workspace).expect("spawn fake GUI"); + assert!(child.wait().expect("wait for fake GUI").success()); + + let lines = fs::read_to_string(output).expect("read fake GUI output"); + let expected = format!( + "--\n{}\n{}\n", + workspace.display(), + env::current_dir() + .expect("read current directory") + .display() + ); + assert_eq!(lines, expected); + } +} diff --git a/crates/procnote-launcher/src/main.rs b/crates/procnote-launcher/src/main.rs new file mode 100644 index 0000000..32eb035 --- /dev/null +++ b/crates/procnote-launcher/src/main.rs @@ -0,0 +1,31 @@ +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; + +#[derive(Debug, Parser)] +#[command( + name = "procnote", + bin_name = "procnote", + version, + about = "Procedure execution tool for hardware testing." +)] +struct Args { + /// Workspace directory containing procedure subdirectories. + /// Defaults to the current working directory. + #[arg(default_value = ".", value_name = "WORKSPACE")] + workspace: PathBuf, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + match procnote_launcher::launch(&args.workspace) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let _ = writeln!(io::stderr().lock(), "procnote: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/procnote-launcher/tests/cli.rs b/crates/procnote-launcher/tests/cli.rs new file mode 100644 index 0000000..825a3ce --- /dev/null +++ b/crates/procnote-launcher/tests/cli.rs @@ -0,0 +1,138 @@ +use std::process::Command; + +fn launcher() -> Command { + Command::new(env!("CARGO_BIN_EXE_procnote-launcher")) +} + +#[test] +fn version_is_printed_without_starting_the_gui() { + let output = launcher() + .arg("--version") + .output() + .expect("run launcher --version"); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).expect("version output is UTF-8"), + format!("procnote {}\n", env!("CARGO_PKG_VERSION")) + ); + assert!(output.stderr.is_empty()); +} + +#[test] +fn help_is_printed_without_starting_the_gui() { + let output = launcher() + .arg("--help") + .output() + .expect("run launcher --help"); + let stdout = String::from_utf8(output.stdout).expect("help output is UTF-8"); + + assert!(output.status.success()); + assert!(stdout.contains("Usage: procnote [WORKSPACE]")); + assert!(stdout.contains("--version")); + assert!(output.stderr.is_empty()); +} + +#[test] +fn invalid_options_fail_in_the_foreground() { + let output = launcher() + .arg("--not-a-procnote-option") + .output() + .expect("run launcher with an invalid option"); + let stderr = String::from_utf8(output.stderr).expect("error output is UTF-8"); + + assert_eq!(output.status.code(), Some(2)); + assert!(stderr.contains("unexpected argument '--not-a-procnote-option'")); + assert!(output.stdout.is_empty()); +} + +#[cfg(unix)] +#[test] +fn packaged_launcher_detaches_gui_and_preserves_process_context() { + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::thread; + use std::time::Duration; + + let temporary = tempfile::tempdir().expect("create package fixture"); + let (packaged_launcher, gui) = package_paths(temporary.path()); + fs::create_dir_all( + packaged_launcher + .parent() + .expect("packaged launcher has a parent"), + ) + .expect("create launcher directory"); + fs::create_dir_all(gui.parent().expect("fake GUI has a parent")).expect("create GUI directory"); + fs::copy(env!("CARGO_BIN_EXE_procnote-launcher"), &packaged_launcher) + .expect("copy launcher into package fixture"); + fs::set_permissions(&packaged_launcher, fs::Permissions::from_mode(0o755)) + .expect("make packaged launcher executable"); + + fs::write( + &gui, + "#!/bin/sh\n{ printf '%s\\n%s\\n' \"$1\" \"$2\"; pwd -P; printf '%s\\n' \"$PROCNOTE_LAUNCH_TEST_ENV\"; } > \"$PROCNOTE_LAUNCH_TEST_OUTPUT.tmp\"\nmv \"$PROCNOTE_LAUNCH_TEST_OUTPUT.tmp\" \"$PROCNOTE_LAUNCH_TEST_OUTPUT\"\n", + ) + .expect("write fake GUI"); + fs::set_permissions(&gui, fs::Permissions::from_mode(0o755)).expect("make fake GUI executable"); + + let link_directory = temporary.path().join("PATH directory with spaces"); + fs::create_dir(&link_directory).expect("create symlink directory"); + let launcher_link = link_directory.join("procnote"); + symlink(&packaged_launcher, &launcher_link).expect("symlink packaged launcher"); + + let workspace = temporary.path().join("workspace with spaces"); + fs::create_dir(&workspace).expect("create workspace"); + let gui_output = temporary.path().join("gui-output"); + let launcher_output = Command::new(&launcher_link) + .arg(".") + .current_dir(&workspace) + .env("PROCNOTE_LAUNCH_TEST_ENV", "environment preserved") + .env("PROCNOTE_LAUNCH_TEST_OUTPUT", &gui_output) + .output() + .expect("run packaged launcher"); + + assert!(launcher_output.status.success()); + assert!(launcher_output.stdout.is_empty()); + assert!(launcher_output.stderr.is_empty()); + assert!( + (0..100).any(|_| { + if gui_output.is_file() { + true + } else { + thread::sleep(Duration::from_millis(20)); + false + } + }), + "detached GUI did not write its output" + ); + + let expected = format!( + "--\n.\n{}\nenvironment preserved\n", + workspace + .canonicalize() + .expect("canonicalize workspace") + .display() + ); + assert_eq!( + fs::read_to_string(gui_output).expect("read detached GUI output"), + expected + ); +} + +#[cfg(target_os = "macos")] +fn package_paths(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + let contents = root + .join("Application with spaces") + .join("procnote.app") + .join("Contents"); + ( + contents.join("Resources").join("bin").join("procnote"), + contents.join("MacOS").join("procnote"), + ) +} + +#[cfg(target_os = "linux")] +fn package_paths(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + let bin = root.join("package with spaces").join("bin"); + (bin.join("procnote"), bin.join("procnote-gui")) +} diff --git a/justfile b/justfile index 306e388..9b70898 100644 --- a/justfile +++ b/justfile @@ -79,4 +79,4 @@ pre-commit: # Clean build artifacts clean: cargo clean - rm -rf build .svelte-kit node_modules/.vite + rm -rf build .svelte-kit node_modules/.vite src-tauri/launchers/bin diff --git a/package.json b/package.json index c8a1d10..98931b8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "scripts": { "dev": "vp dev", "build": "vp build", + "build:launcher": "node scripts/prepare-launcher.mjs", "preview": "vp preview", + "tauri": "node scripts/tauri.mjs", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/scripts/prepare-launcher.mjs b/scripts/prepare-launcher.mjs new file mode 100644 index 0000000..41715d8 --- /dev/null +++ b/scripts/prepare-launcher.mjs @@ -0,0 +1,116 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const stagedDirectory = join(repositoryRoot, "src-tauri", "launchers", "bin"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + ...options, + }); + + if (result.error) { + throw result.error; + } + if (result.status === null) { + throw new Error(`${command} terminated by signal ${result.signal}`); + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + } + + return result.stdout?.trim() ?? ""; +} + +function normalizeArchitecture(architecture) { + switch (architecture) { + case "arm64": + return "aarch64"; + case "x64": + return "x86_64"; + default: + return architecture; + } +} + +function targetFromTauriEnvironment() { + const platform = process.env.TAURI_ENV_PLATFORM; + const architecture = process.env.TAURI_ENV_ARCH; + + if (platform === undefined && architecture === undefined) { + return undefined; + } + if (platform === undefined || architecture === undefined) { + throw new Error( + "TAURI_ENV_PLATFORM and TAURI_ENV_ARCH must either both be set or both be unset", + ); + } + + const rustArchitecture = normalizeArchitecture(architecture); + switch (platform) { + case "darwin": + case "macos": + return { triple: `${rustArchitecture}-apple-darwin`, executableSuffix: "" }; + case "linux": + return { triple: `${rustArchitecture}-unknown-linux-gnu`, executableSuffix: "" }; + case "windows": + return { triple: `${rustArchitecture}-pc-windows-msvc`, executableSuffix: ".exe" }; + default: + throw new Error(`unsupported Tauri target platform: ${platform}`); + } +} + +function hostTarget() { + const triple = run("rustc", ["--print", "host-tuple"]); + if (triple.length === 0) { + throw new Error("rustc returned an empty host target triple"); + } + + return { + triple, + executableSuffix: process.platform === "win32" ? ".exe" : "", + }; +} + +function buildProfile() { + switch (process.env.TAURI_ENV_DEBUG) { + case "true": + return "debug"; + case undefined: + case "": + case "false": + return "release"; + default: + throw new Error(`invalid TAURI_ENV_DEBUG value: ${process.env.TAURI_ENV_DEBUG}`); + } +} + +const target = targetFromTauriEnvironment() ?? hostTarget(); +const profile = buildProfile(); +const cargoArguments = ["build", "--package", "procnote-launcher", "--target", target.triple]; +if (profile === "release") { + cargoArguments.push("--release"); +} + +run("cargo", cargoArguments, { stdio: "inherit" }); + +const metadata = JSON.parse(run("cargo", ["metadata", "--format-version", "1", "--no-deps"])); +const executableName = `procnote-launcher${target.executableSuffix}`; +const builtLauncher = join(metadata.target_directory, target.triple, profile, executableName); +if (!existsSync(builtLauncher)) { + throw new Error(`Cargo did not produce the launcher at ${builtLauncher}`); +} + +rmSync(stagedDirectory, { recursive: true, force: true }); +mkdirSync(stagedDirectory, { recursive: true }); +const stagedLauncher = join(stagedDirectory, executableName); +copyFileSync(builtLauncher, stagedLauncher); +if (target.executableSuffix.length === 0) { + chmodSync(stagedLauncher, 0o755); +} + +process.stdout.write(`Staged ${target.triple} launcher at ${stagedLauncher}\n`); diff --git a/scripts/tauri.mjs b/scripts/tauri.mjs new file mode 100644 index 0000000..a432f3b --- /dev/null +++ b/scripts/tauri.mjs @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(import.meta.url); +const tauriCli = require.resolve("@tauri-apps/cli/tauri.js"); + +function requestedTarget(args) { + const separatorIndex = args.indexOf("--"); + const commandArguments = separatorIndex === -1 ? args : args.slice(0, separatorIndex); + const targets = commandArguments.flatMap((argument, index) => { + if (argument === "--target" || argument === "-t") { + const value = commandArguments[index + 1]; + if (value === undefined) { + throw new Error(`${argument} requires a target triple`); + } + return [value]; + } + if (argument.startsWith("--target=")) { + return [argument.slice("--target=".length)]; + } + return []; + }); + + if (targets.length > 1) { + throw new Error("the Tauri target may only be specified once"); + } + return targets[0]; +} + +function targetPlatform(target) { + if (target === undefined) { + return process.platform; + } + + const components = target.split("-"); + if (components.includes("windows")) { + return "win32"; + } + if (components.includes("darwin")) { + return "darwin"; + } + if (components.includes("linux")) { + return "linux"; + } + throw new Error(`unsupported desktop target: ${target}`); +} + +function bundleConfiguration(platform) { + const filename = (() => { + switch (platform) { + case "darwin": + return "tauri.bundle.macos.conf.json"; + case "linux": + return "tauri.bundle.linux.conf.json"; + case "win32": + return "tauri.bundle.windows.conf.json"; + default: + throw new Error(`unsupported desktop platform: ${platform}`); + } + })(); + + return join("src-tauri", filename); +} + +const args = process.argv.slice(2); +const forwardedArgs = + args[0] === "build" + ? [ + "build", + "--config", + bundleConfiguration(targetPlatform(requestedTarget(args))), + ...args.slice(1), + ] + : args; +const result = spawnSync(process.execPath, [tauriCli, ...forwardedArgs], { + cwd: repositoryRoot, + stdio: "inherit", +}); +if (result.error) { + throw result.error; +} +if (result.status === null) { + throw new Error(`Tauri CLI terminated by signal ${result.signal}`); +} +process.exitCode = result.status; diff --git a/scripts/validate-linux-package.sh b/scripts/validate-linux-package.sh index 5ba13fc..65bdf52 100755 --- a/scripts/validate-linux-package.sh +++ b/scripts/validate-linux-package.sh @@ -7,7 +7,7 @@ if [ "$#" -ne 1 ]; then fi deb=$1 -source_launcher=src-tauri/launchers/linux/procnote +source_launcher=src-tauri/launchers/bin/procnote-launcher extract_dir=$(mktemp -d) trap 'rm -rf "$extract_dir"' EXIT HUP INT TERM @@ -26,14 +26,32 @@ if [ ! -x "$launcher" ]; then fi cmp "$source_launcher" "$launcher" -sh -n "$launcher" -"$gui" --version | grep -F 'procnote ' >/dev/null +launcher_version=$("$launcher" --version) +gui_version=$("$gui" --version) +if [ "$launcher_version" != "$gui_version" ]; then + printf 'launcher and GUI versions differ: %s != %s\n' "$launcher_version" "$gui_version" >&2 + exit 1 +fi +printf '%s\n' "$launcher_version" | grep -F 'procnote ' >/dev/null + +launcher_help=$("$launcher" --help) +printf '%s\n' "$launcher_help" | grep -F 'Usage: procnote [WORKSPACE]' >/dev/null +printf '%s\n' "$launcher_help" | grep -F -- '--version' >/dev/null if ldd "$gui" | grep -F 'not found' >/dev/null; then printf 'GUI has unresolved shared-library dependencies:\n' >&2 ldd "$gui" >&2 exit 1 fi +if ldd "$launcher" | grep -F 'not found' >/dev/null; then + printf 'launcher has unresolved shared-library dependencies:\n' >&2 + ldd "$launcher" >&2 + exit 1 +fi +if ldd "$launcher" | grep -F 'libwebkit' >/dev/null || ldd "$launcher" | grep -F 'libgtk' >/dev/null; then + printf 'terminal launcher unexpectedly links GUI libraries\n' >&2 + exit 1 +fi desktop_file=$(find "$extract_dir/usr/share/applications" -type f -name '*.desktop' -print -quit) if [ -z "$desktop_file" ] || ! grep -F 'Exec=procnote-gui' "$desktop_file" >/dev/null; then diff --git a/scripts/validate-macos-package.sh b/scripts/validate-macos-package.sh index a4bd3b9..3963bba 100755 --- a/scripts/validate-macos-package.sh +++ b/scripts/validate-macos-package.sh @@ -15,7 +15,7 @@ case $expected_arch in exit 2 ;; esac -source_launcher=src-tauri/launchers/macos/procnote +source_launcher=src-tauri/launchers/bin/procnote-launcher mount_dir=$(mktemp -d) attached=false @@ -44,14 +44,34 @@ if [ ! -x "$launcher" ]; then fi cmp "$source_launcher" "$launcher" -sh -n "$launcher" -actual_arch=$(lipo -archs "$gui") -if [ "$actual_arch" != "$expected_arch" ]; then - printf 'unexpected GUI architecture: expected %s, got %s\n' "$expected_arch" "$actual_arch" >&2 +gui_arch=$(lipo -archs "$gui") +launcher_arch=$(lipo -archs "$launcher") +if [ "$gui_arch" != "$expected_arch" ]; then + printf 'unexpected GUI architecture: expected %s, got %s\n' "$expected_arch" "$gui_arch" >&2 exit 1 fi +if [ "$launcher_arch" != "$expected_arch" ]; then + printf 'unexpected launcher architecture: expected %s, got %s\n' "$expected_arch" "$launcher_arch" >&2 + exit 1 +fi + +if otool -L "$launcher" | grep -F 'WebKit' >/dev/null; then + printf 'terminal launcher unexpectedly links WebKit\n' >&2 + exit 1 +fi + if [ "$(uname -m)" = "$expected_arch" ]; then - "$gui" --version | grep -F 'procnote ' >/dev/null + launcher_version=$("$launcher" --version) + gui_version=$("$gui" --version) + if [ "$launcher_version" != "$gui_version" ]; then + printf 'launcher and GUI versions differ: %s != %s\n' "$launcher_version" "$gui_version" >&2 + exit 1 + fi + printf '%s\n' "$launcher_version" | grep -F 'procnote ' >/dev/null + + launcher_help=$("$launcher" --help) + printf '%s\n' "$launcher_help" | grep -F 'Usage: procnote [WORKSPACE]' >/dev/null + printf '%s\n' "$launcher_help" | grep -F -- '--version' >/dev/null fi if [ -e "$app/Contents/Resources/cli/procnote" ] || [ -e "$app/Contents/Resources/cli/procnote.exe" ]; then diff --git a/scripts/validate-windows-package.ps1 b/scripts/validate-windows-package.ps1 index ca030d4..b8556be 100644 --- a/scripts/validate-windows-package.ps1 +++ b/scripts/validate-windows-package.ps1 @@ -119,6 +119,77 @@ function Invoke-Process { } } +function Invoke-CapturedProcess { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @(), + [int]$TimeoutMilliseconds = 30000 + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $ArgumentList) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Failed to start captured process: $FilePath" + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + throw "Process did not exit within $TimeoutMilliseconds ms: $FilePath" + } + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Stdout = $stdout.GetAwaiter().GetResult() + Stderr = $stderr.GetAwaiter().GetResult() + } + } + finally { + $process.Dispose() + } +} + +function Assert-NoRedistributableRuntime { + param( + [Parameter(Mandatory = $true)] + [string]$BinaryPath, + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [string]$DumpbinPath + ) + + $dependencies = (& $DumpbinPath /dependents $BinaryPath | Out-String).ToUpperInvariant() + # UCRTBASE.DLL and API-MS-WIN-CRT-* are Windows components and are expected. + $redistributableRuntimePrefixes = @( + "VCRUNTIME", + "MSVCR1", + "MSVCP", + "CONCRT", + "VCAMP", + "VCOMP" + ) + foreach ($runtimePrefix in $redistributableRuntimePrefixes) { + if ($dependencies.Contains($runtimePrefix)) { + throw "$Description dynamically imports a Visual C++ Redistributable library: $runtimePrefix" + } + } + + return $dependencies +} + $installer = (Resolve-Path $InstallerPath).Path $msi = (Resolve-Path $MsiPath).Path $legacyInstaller = if ([string]::IsNullOrWhiteSpace($LegacyInstallerPath)) { @@ -139,29 +210,43 @@ if ($LASTEXITCODE -ne 0) { } $gui = Join-Path $extractDir "procnote.exe" -$launcher = Join-Path $extractDir "bin\procnote.cmd" +$launcher = Join-Path $extractDir "bin\procnote.exe" $pathUpdater = Join-Path $extractDir "installer\update-user-path.ps1" -$sourceLauncher = Join-Path $PWD "src-tauri\launchers\windows\procnote.cmd" +$sourceLauncher = Join-Path $PWD "src-tauri\launchers\bin\procnote-launcher.exe" $sourcePathUpdater = Join-Path $PWD "src-tauri\nsis\update-user-path.ps1" +$metadataJson = & cargo metadata --no-deps --format-version 1 | Out-String +if ($LASTEXITCODE -ne 0) { + throw "Could not read Cargo package metadata" +} +$metadata = $metadataJson | ConvertFrom-Json +$launcherPackages = @($metadata.packages | Where-Object { $_.name -eq "procnote-launcher" }) +if ($launcherPackages.Count -ne 1) { + throw "Expected exactly one procnote-launcher package in Cargo metadata" +} +$expectedVersionOutput = "procnote $($launcherPackages[0].version)" + if (-not (Test-Path $gui -PathType Leaf)) { throw "Packaged GUI executable is missing: $gui" } -$nsisGuiMatches = @(Get-ChildItem $extractDir -Recurse -Filter "procnote.exe" -File) -if ($nsisGuiMatches.Count -ne 1) { - throw "NSIS must contain exactly one GUI executable; found $($nsisGuiMatches.Count)" -} if (-not (Test-Path $launcher -PathType Leaf)) { throw "Packaged terminal launcher is missing: $launcher" } +$nsisExecutables = @(Get-ChildItem $extractDir -Recurse -Filter "procnote.exe" -File) +if ($nsisExecutables.Count -ne 2) { + throw "NSIS must contain one GUI and one console launcher; found $($nsisExecutables.Count) procnote executables" +} if (-not (Test-Path $pathUpdater -PathType Leaf)) { throw "Packaged PATH updater is missing: $pathUpdater" } if (Test-Path (Join-Path $extractDir "cli")) { throw "Legacy CLI directory is still packaged" } +if (Get-ChildItem $extractDir -Recurse -Filter "procnote.cmd" -File) { + throw "Obsolete command-script launcher is still packaged" +} if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $launcher).Hash) { - throw "Packaged launcher differs from its source file" + throw "Packaged launcher differs from its freshly built source file" } if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $pathUpdater).Hash) { throw "Packaged PATH updater differs from its source file" @@ -174,15 +259,22 @@ Invoke-Process ` -ArgumentList @("/a", "`"$msi`"", "/qn", "/norestart", "TARGETDIR=`"$msiExtractDir`"") ` -Description "MSI administrative extraction" -$msiGuiMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.exe" -File) -$msiLauncherMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.cmd" -File) +$msiExecutables = @(Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.exe" -File) +$msiLauncherMatches = @($msiExecutables | Where-Object { + $_.FullName.EndsWith("\bin\procnote.exe", [System.StringComparison]::OrdinalIgnoreCase) + }) +$msiGuiMatches = @($msiExecutables | Where-Object { + -not $_.FullName.EndsWith("\bin\procnote.exe", [System.StringComparison]::OrdinalIgnoreCase) + }) $msiPathUpdaterMatches = @(Get-ChildItem $msiExtractDir -Recurse -Filter "update-user-path.ps1" -File) if ($msiGuiMatches.Count -ne 1) { throw "MSI must contain exactly one GUI executable; found $($msiGuiMatches.Count)" } -if ($msiLauncherMatches.Count -ne 1 -or - -not $msiLauncherMatches[0].FullName.EndsWith("\bin\procnote.cmd", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "MSI does not contain the terminal launcher under its bin directory" +if ($msiLauncherMatches.Count -ne 1) { + throw "MSI does not contain exactly one console launcher under its bin directory" +} +if (Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.cmd" -File) { + throw "MSI still contains the obsolete command-script launcher" } if ($msiPathUpdaterMatches.Count -ne 1 -or -not $msiPathUpdaterMatches[0].FullName.EndsWith("\installer\update-user-path.ps1", [System.StringComparison]::OrdinalIgnoreCase)) { @@ -192,7 +284,7 @@ if ((Get-FileHash $gui).Hash -ne (Get-FileHash $msiGuiMatches[0].FullName).Hash) throw "MSI and NSIS contain different GUI executables" } if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $msiLauncherMatches[0].FullName).Hash) { - throw "MSI launcher differs from its source file" + throw "MSI launcher differs from its freshly built source file" } if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $msiPathUpdaterMatches[0].FullName).Hash) { throw "MSI PATH updater differs from its source file" @@ -217,29 +309,29 @@ if (Test-Path $vswhere) { $dumpbinRoots = if ([string]::IsNullOrWhiteSpace($visualStudioRoot)) { @() } else { @(Join-Path $visualStudioRoot "VC\Tools\MSVC") } $dumpbin = Find-Executable -Name "dumpbin.exe" -SearchRoots $dumpbinRoots -$headers = (& $dumpbin /headers $gui | Out-String).ToLowerInvariant() -if (-not $headers.Contains("machine (x64)")) { +$guiHeaders = (& $dumpbin /headers $gui | Out-String).ToLowerInvariant() +if (-not $guiHeaders.Contains("machine (x64)")) { throw "Packaged GUI is not an x64 executable" } -if (-not $headers.Contains("subsystem (windows gui)")) { - throw "Packaged executable does not use the Windows GUI subsystem" +if (-not $guiHeaders.Contains("subsystem (windows gui)")) { + throw "Packaged GUI does not use the Windows GUI subsystem" } -$dependencies = (& $dumpbin /dependents $gui | Out-String).ToUpperInvariant() -# UCRTBASE.DLL and API-MS-WIN-CRT-* are Windows components and are expected: -# Tauri statically links the versioned VC runtime while dynamically linking UCRT. -$redistributableRuntimePrefixes = @( - "VCRUNTIME", - "MSVCR1", - "MSVCP", - "CONCRT", - "VCAMP", - "VCOMP" -) -foreach ($runtimePrefix in $redistributableRuntimePrefixes) { - if ($dependencies.Contains($runtimePrefix)) { - throw "Packaged GUI dynamically imports a Visual C++ Redistributable library: $runtimePrefix" - } +$launcherHeaders = (& $dumpbin /headers $launcher | Out-String).ToLowerInvariant() +if (-not $launcherHeaders.Contains("machine (x64)")) { + throw "Packaged launcher is not an x64 executable" +} +if (-not $launcherHeaders.Contains("subsystem (windows cui)")) { + throw "Packaged launcher does not use the Windows console subsystem" +} + +$null = Assert-NoRedistributableRuntime -BinaryPath $gui -Description "Packaged GUI" -DumpbinPath $dumpbin +$launcherDependencies = Assert-NoRedistributableRuntime ` + -BinaryPath $launcher ` + -Description "Packaged launcher" ` + -DumpbinPath $dumpbin +if ($launcherDependencies.Contains("COMCTL32.DLL")) { + throw "Console launcher unexpectedly imports the Common Controls GUI library" } $process = Start-Process -FilePath $gui -ArgumentList "--version" -PassThru @@ -251,17 +343,37 @@ if ($process.ExitCode -ne 0) { throw "Packaged GUI failed --version with exit code $($process.ExitCode)" } -$wrapperProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @("/d", "/c", "call `"$launcher`" --version") -PassThru -Wait -if ($wrapperProcess.ExitCode -ne 0) { - throw "Packaged launcher failed with exit code $($wrapperProcess.ExitCode)" +$versionResult = Invoke-CapturedProcess -FilePath $launcher -ArgumentList @("--version") +if ($versionResult.ExitCode -ne 0 -or + $versionResult.Stdout.Trim() -cne $expectedVersionOutput -or + -not [string]::IsNullOrEmpty($versionResult.Stderr)) { + throw "Packaged launcher produced unexpected --version output: $($versionResult.Stdout) $($versionResult.Stderr)" +} + +$helpResult = Invoke-CapturedProcess -FilePath $launcher -ArgumentList @("--help") +if ($helpResult.ExitCode -ne 0 -or + -not $helpResult.Stdout.Contains("Usage: procnote [WORKSPACE]") -or + -not $helpResult.Stdout.Contains("--version") -or + -not [string]::IsNullOrEmpty($helpResult.Stderr)) { + throw "Packaged launcher produced unexpected --help output: $($helpResult.Stdout) $($helpResult.Stderr)" +} + +$invalidResult = Invoke-CapturedProcess -FilePath $launcher -ArgumentList @("--not-a-procnote-option") +if ($invalidResult.ExitCode -ne 2 -or + -not $invalidResult.Stderr.Contains("unexpected argument '--not-a-procnote-option'") -or + -not [string]::IsNullOrEmpty($invalidResult.Stdout)) { + throw "Packaged launcher did not report an invalid argument in the foreground" +} + +if (Get-Process -Name "procnote" -ErrorAction SilentlyContinue) { + throw "A procnote process remained after terminal-only launcher commands" } -Get-Process -Name "procnote" -ErrorAction SilentlyContinue | Wait-Process -Timeout 30 # Exercise the real NSIS hooks against controlled user-PATH entries. When the # v0.0.4 installer is supplied, this performs an actual silent upgrade; otherwise # it creates the legacy directory layout directly. $installDir = Join-Path $env:LOCALAPPDATA "procnote" -$installedLauncher = Join-Path $installDir "bin\procnote.cmd" +$installedLauncher = Join-Path $installDir "bin\procnote.exe" $installedPathUpdater = Join-Path $installDir "installer\update-user-path.ps1" $uninstaller = Join-Path $installDir "uninstall.exe" $legacyDir = Join-Path $installDir "cli" @@ -332,7 +444,7 @@ try { throw "NSIS installation did not remove the legacy CLI directory" } if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $installedLauncher).Hash) { - throw "Installed launcher differs from its source file" + throw "Installed launcher differs from its freshly built source file" } if ((Get-FileHash $sourcePathUpdater).Hash -ne (Get-FileHash $installedPathUpdater).Hash) { throw "Installed PATH updater differs from its source file" @@ -345,6 +457,30 @@ try { throw "NSIS installation produced an unexpected user PATH: $($installedPath.Value)" } + $originalProcessPath = $env:Path + try { + $env:Path = [Environment]::ExpandEnvironmentVariables([string]$installedPath.Value) + $resolvedCommands = @(Get-Command "procnote" -CommandType Application -ErrorAction Stop) + if ($resolvedCommands.Count -ne 1 -or + -not [string]::Equals( + $resolvedCommands[0].Source, + $installedLauncher, + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "A fresh terminal PATH did not resolve the installed launcher: $($resolvedCommands.Source)" + } + + $installedVersionResult = Invoke-CapturedProcess -FilePath $resolvedCommands[0].Source -ArgumentList @("--version") + if ($installedVersionResult.ExitCode -ne 0 -or + $installedVersionResult.Stdout.Trim() -cne $expectedVersionOutput -or + -not [string]::IsNullOrEmpty($installedVersionResult.Stderr)) { + throw "Installed launcher produced unexpected --version output" + } + } + finally { + $env:Path = $originalProcessPath + } + if (-not (Test-Path $uninstaller -PathType Leaf)) { throw "NSIS installation did not create an uninstaller" } diff --git a/src-tauri/launchers/README.md b/src-tauri/launchers/README.md index f7c5021..05988cc 100644 --- a/src-tauri/launchers/README.md +++ b/src-tauri/launchers/README.md @@ -1,19 +1,34 @@ -# Terminal launchers +# Terminal launcher -`procnote` has one compiled desktop executable. These scripts expose a -VS Code-style `procnote ` command without building a second copy of -the Tauri application. +`procnote` ships one Tauri desktop executable and one small, Tauri-free console +launcher. The launcher owns the public `procnote [WORKSPACE]` interface: -- Windows bundles `windows/procnote.cmd` as `bin/procnote.cmd`; the NSIS hook - uses `nsis/update-user-path.ps1` to add that directory to the user's PATH +- `--help`, `--version`, and argument errors are handled synchronously in the + terminal by `crates/procnote-launcher`. +- A valid workspace starts the GUI in a detached process session/group with + null standard streams, then immediately returns control to the terminal. +- The GUI inherits the caller's working directory and environment, and receives + the workspace as one OS-native argument without shell interpolation. + +The launcher resolves symlinks and locates the GUI from the package layout: + +- Windows installs it as `bin/procnote.exe` beside the root `procnote.exe` GUI. + The NSIS hook uses `nsis/update-user-path.ps1` to add `bin` to the user's PATH without NSIS's string-length limit. -- macOS bundles `macos/procnote` inside the app. The Homebrew cask links this - script, and DMG users can link it manually. -- Linux packages rename the bundled GUI to `procnote-gui` and install - `linux/procnote` as `/usr/bin/procnote`. +- macOS bundles it as `Contents/Resources/bin/procnote`; the Homebrew cask links + this executable, and DMG users can link it manually. +- Linux packages name the GUI `procnote-gui` and install the launcher as + `/usr/bin/procnote`. + +`scripts/prepare-launcher.mjs` builds the launcher for Tauri's target and stages +it under the ignored `src-tauri/launchers/bin/` directory before Tauri compiles +and bundles the app. `scripts/tauri.mjs` injects the matching +`tauri.bundle.*.conf.json` extension for desktop builds, which places the staged +binary at the paths above. Keeping generated-resource paths out of Tauri's +automatically loaded platform configuration allows direct `cargo check` and +`cargo test` runs to work from a clean checkout. -Each launcher preserves the caller's working directory and environment, -forwards arguments unchanged, detaches the GUI process, and returns control to -the terminal. Do not replace these launchers with a separately built Tauri -binary: Cargo build-script resource and linker settings are package-scoped, so -a second package can silently lose the GUI manifest or runtime configuration. +The launcher must never depend on `procnote-tauri`, Tauri, or GUI/dialog +libraries. This keeps it a normal console executable and ensures the package has +exactly one Tauri application receiving Tauri's package-scoped manifest, +resource, linker, and runtime settings. diff --git a/src-tauri/launchers/linux/procnote b/src-tauri/launchers/linux/procnote deleted file mode 100755 index 35d9952..0000000 --- a/src-tauri/launchers/linux/procnote +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -set -eu - -launcher=$0 -while [ -L "$launcher" ]; do - launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) - target=$(readlink "$launcher") - case $target in - /*) launcher=$target ;; - *) launcher=$launcher_dir/$target ;; - esac -done - -launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) -gui=$launcher_dir/procnote-gui - -if [ ! -x "$gui" ]; then - printf 'procnote: application executable not found: %s\n' "$gui" >&2 - exit 1 -fi - -nohup "$gui" "$@" /dev/null 2>&1 & diff --git a/src-tauri/launchers/macos/procnote b/src-tauri/launchers/macos/procnote deleted file mode 100755 index 5455010..0000000 --- a/src-tauri/launchers/macos/procnote +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -set -eu - -launcher=$0 -while [ -L "$launcher" ]; do - launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) - target=$(readlink "$launcher") - case $target in - /*) launcher=$target ;; - *) launcher=$launcher_dir/$target ;; - esac -done - -launcher_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) -contents_dir=$(CDPATH= cd -P "$launcher_dir/../.." && pwd) -gui=$contents_dir/MacOS/procnote - -if [ ! -x "$gui" ]; then - printf 'procnote: application executable not found: %s\n' "$gui" >&2 - exit 1 -fi - -nohup "$gui" "$@" /dev/null 2>&1 & diff --git a/src-tauri/launchers/windows/procnote.cmd b/src-tauri/launchers/windows/procnote.cmd deleted file mode 100644 index db18f1e..0000000 --- a/src-tauri/launchers/windows/procnote.cmd +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -start "" "%~dp0..\procnote.exe" %* diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fda2010..06cd60e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,3 +120,27 @@ fn run_with_workspace(workspace: &Path) { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use clap::Parser; + + use super::Args; + + #[test] + fn accepts_launcher_workspace_after_option_terminator() { + let args = Args::try_parse_from(["procnote", "--", "workspace with spaces"]) + .expect("parse launcher arguments"); + + assert_eq!(args.workspace, PathBuf::from("workspace with spaces")); + } + + #[test] + fn defaults_workspace_to_current_directory() { + let args = Args::try_parse_from(["procnote"]).expect("parse default arguments"); + + assert_eq!(args.workspace, PathBuf::from(".")); + } +} diff --git a/src-tauri/tauri.bundle.linux.conf.json b/src-tauri/tauri.bundle.linux.conf.json new file mode 100644 index 0000000..d7c7d73 --- /dev/null +++ b/src-tauri/tauri.bundle.linux.conf.json @@ -0,0 +1,16 @@ +{ + "bundle": { + "linux": { + "deb": { + "files": { + "/usr/bin/procnote": "launchers/bin/procnote-launcher" + } + }, + "rpm": { + "files": { + "/usr/bin/procnote": "launchers/bin/procnote-launcher" + } + } + } + } +} diff --git a/src-tauri/tauri.bundle.macos.conf.json b/src-tauri/tauri.bundle.macos.conf.json new file mode 100644 index 0000000..7040841 --- /dev/null +++ b/src-tauri/tauri.bundle.macos.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "resources": { + "launchers/bin/procnote-launcher": "bin/procnote" + } + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.bundle.windows.conf.json similarity index 63% rename from src-tauri/tauri.windows.conf.json rename to src-tauri/tauri.bundle.windows.conf.json index 5ffb888..fd1de8a 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.bundle.windows.conf.json @@ -1,7 +1,7 @@ { "bundle": { "resources": { - "launchers/windows/procnote.cmd": "bin/procnote.cmd", + "launchers/bin/procnote-launcher.exe": "bin/procnote.exe", "nsis/update-user-path.ps1": "installer/update-user-path.ps1" } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dde3138..cd9fe1e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "frontendDist": "../build", "devUrl": "http://localhost:5180", "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeBuildCommand": "pnpm build && pnpm run build:launcher" }, "app": { "windows": [ diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 84cd8c6..4d55a6e 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -1,17 +1,3 @@ { - "mainBinaryName": "procnote-gui", - "bundle": { - "linux": { - "deb": { - "files": { - "/usr/bin/procnote": "launchers/linux/procnote" - } - }, - "rpm": { - "files": { - "/usr/bin/procnote": "launchers/linux/procnote" - } - } - } - } + "mainBinaryName": "procnote-gui" } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json deleted file mode 100644 index b1d083b..0000000 --- a/src-tauri/tauri.macos.conf.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "bundle": { - "resources": { - "launchers/macos/procnote": "bin/procnote" - } - } -} From 1d2e6b5a87fe2c5ae1244ec031b307742010c082 Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Wed, 22 Jul 2026 00:35:12 +0900 Subject: [PATCH 3/5] Fix Windows execution persistence --- .github/workflows/lint-rust.yaml | 14 ++++++++++++- src-tauri/src/persistence/event_log.rs | 27 ++++++++++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/workflows/lint-rust.yaml b/.github/workflows/lint-rust.yaml index b62ab18..0b4bdb1 100644 --- a/.github/workflows/lint-rust.yaml +++ b/.github/workflows/lint-rust.yaml @@ -55,6 +55,18 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo test --workspace + test-windows: + name: cargo test (Windows persistence) + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Rust toolchain + run: rustup toolchain install --no-self-update + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: "cargo test --package procnote-tauri persistence::" + doc: name: cargo doc runs-on: ubuntu-latest @@ -103,7 +115,7 @@ jobs: - uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0 collect-lint-rust: - needs: [clippy, fmt, test, doc, check-types, typos] + needs: [clippy, fmt, test, test-windows, doc, check-types, typos] runs-on: ubuntu-latest steps: - run: echo "All Rust checks passed!" diff --git a/src-tauri/src/persistence/event_log.rs b/src-tauri/src/persistence/event_log.rs index 32f7b23..3acbc07 100644 --- a/src-tauri/src/persistence/event_log.rs +++ b/src-tauri/src/persistence/event_log.rs @@ -161,16 +161,16 @@ pub(super) fn sync_dir(path: &Path) -> Result<(), std::io::Error> { } #[cfg(windows)] -pub(super) fn sync_dir(path: &Path) -> Result<(), std::io::Error> { - use std::os::windows::fs::OpenOptionsExt; - - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; - - std::fs::OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) - .open(path)? - .sync_all() +#[expect( + clippy::unnecessary_wraps, + reason = "keeps the directory durability API uniform across platforms" +)] +pub(super) fn sync_dir(_path: &Path) -> Result<(), std::io::Error> { + // Windows has no supported equivalent of POSIX directory fsync. Calling + // File::sync_all on a read-only directory handle uses FlushFileBuffers, + // which requires write access and returns ERROR_ACCESS_DENIED. Actual files + // continue to use File::sync_all at their durable write points. + Ok(()) } #[cfg(test)] @@ -188,6 +188,13 @@ mod tests { } } + #[test] + fn syncing_existing_directory_succeeds() { + let dir = tempfile::tempdir().unwrap(); + + sync_dir(dir.path()).unwrap(); + } + #[test] fn durable_append_creates_parent_dirs_and_round_trips() { let dir = tempfile::tempdir().unwrap(); From 41bdf71e40a78b087a261e4a5de612ca3d5e4053 Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Wed, 22 Jul 2026 01:06:05 +0900 Subject: [PATCH 4/5] Stage launcher under its public filename --- scripts/prepare-launcher.mjs | 7 ++++--- scripts/validate-linux-package.sh | 7 ++++++- scripts/validate-macos-package.sh | 6 +++++- scripts/validate-windows-package.ps1 | 8 +++++++- src-tauri/launchers/README.md | 9 ++++++--- src-tauri/tauri.bundle.linux.conf.json | 4 ++-- src-tauri/tauri.bundle.macos.conf.json | 2 +- src-tauri/tauri.bundle.windows.conf.json | 2 +- 8 files changed, 32 insertions(+), 13 deletions(-) diff --git a/scripts/prepare-launcher.mjs b/scripts/prepare-launcher.mjs index 41715d8..46e2f54 100644 --- a/scripts/prepare-launcher.mjs +++ b/scripts/prepare-launcher.mjs @@ -99,15 +99,16 @@ if (profile === "release") { run("cargo", cargoArguments, { stdio: "inherit" }); const metadata = JSON.parse(run("cargo", ["metadata", "--format-version", "1", "--no-deps"])); -const executableName = `procnote-launcher${target.executableSuffix}`; -const builtLauncher = join(metadata.target_directory, target.triple, profile, executableName); +const cargoExecutableName = `procnote-launcher${target.executableSuffix}`; +const builtLauncher = join(metadata.target_directory, target.triple, profile, cargoExecutableName); if (!existsSync(builtLauncher)) { throw new Error(`Cargo did not produce the launcher at ${builtLauncher}`); } rmSync(stagedDirectory, { recursive: true, force: true }); mkdirSync(stagedDirectory, { recursive: true }); -const stagedLauncher = join(stagedDirectory, executableName); +const stagedExecutableName = `procnote${target.executableSuffix}`; +const stagedLauncher = join(stagedDirectory, stagedExecutableName); copyFileSync(builtLauncher, stagedLauncher); if (target.executableSuffix.length === 0) { chmodSync(stagedLauncher, 0o755); diff --git a/scripts/validate-linux-package.sh b/scripts/validate-linux-package.sh index 65bdf52..8b8191e 100755 --- a/scripts/validate-linux-package.sh +++ b/scripts/validate-linux-package.sh @@ -7,7 +7,7 @@ if [ "$#" -ne 1 ]; then fi deb=$1 -source_launcher=src-tauri/launchers/bin/procnote-launcher +source_launcher=src-tauri/launchers/bin/procnote extract_dir=$(mktemp -d) trap 'rm -rf "$extract_dir"' EXIT HUP INT TERM @@ -53,6 +53,11 @@ if ldd "$launcher" | grep -F 'libwebkit' >/dev/null || ldd "$launcher" | grep -F exit 1 fi +if [ -n "$(find "$extract_dir" -type f -name 'procnote-launcher' -print -quit)" ]; then + printf 'internal launcher filename is still packaged in the Debian package\n' >&2 + exit 1 +fi + desktop_file=$(find "$extract_dir/usr/share/applications" -type f -name '*.desktop' -print -quit) if [ -z "$desktop_file" ] || ! grep -F 'Exec=procnote-gui' "$desktop_file" >/dev/null; then printf 'desktop entry does not launch procnote-gui\n' >&2 diff --git a/scripts/validate-macos-package.sh b/scripts/validate-macos-package.sh index 3963bba..fc7d3f8 100755 --- a/scripts/validate-macos-package.sh +++ b/scripts/validate-macos-package.sh @@ -15,7 +15,7 @@ case $expected_arch in exit 2 ;; esac -source_launcher=src-tauri/launchers/bin/procnote-launcher +source_launcher=src-tauri/launchers/bin/procnote mount_dir=$(mktemp -d) attached=false @@ -78,5 +78,9 @@ if [ -e "$app/Contents/Resources/cli/procnote" ] || [ -e "$app/Contents/Resource printf 'legacy CLI executable is still packaged in the DMG\n' >&2 exit 1 fi +if [ -n "$(find "$app" -type f -name 'procnote-launcher' -print -quit)" ]; then + printf 'internal launcher filename is still packaged in the DMG\n' >&2 + exit 1 +fi printf 'Validated macOS package: %s\n' "$dmg" diff --git a/scripts/validate-windows-package.ps1 b/scripts/validate-windows-package.ps1 index b8556be..c08f490 100644 --- a/scripts/validate-windows-package.ps1 +++ b/scripts/validate-windows-package.ps1 @@ -212,7 +212,7 @@ if ($LASTEXITCODE -ne 0) { $gui = Join-Path $extractDir "procnote.exe" $launcher = Join-Path $extractDir "bin\procnote.exe" $pathUpdater = Join-Path $extractDir "installer\update-user-path.ps1" -$sourceLauncher = Join-Path $PWD "src-tauri\launchers\bin\procnote-launcher.exe" +$sourceLauncher = Join-Path $PWD "src-tauri\launchers\bin\procnote.exe" $sourcePathUpdater = Join-Path $PWD "src-tauri\nsis\update-user-path.ps1" $metadataJson = & cargo metadata --no-deps --format-version 1 | Out-String @@ -245,6 +245,9 @@ if (Test-Path (Join-Path $extractDir "cli")) { if (Get-ChildItem $extractDir -Recurse -Filter "procnote.cmd" -File) { throw "Obsolete command-script launcher is still packaged" } +if (Get-ChildItem $extractDir -Recurse -Filter "procnote-launcher.exe" -File) { + throw "NSIS still contains the internal launcher filename" +} if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $launcher).Hash) { throw "Packaged launcher differs from its freshly built source file" } @@ -276,6 +279,9 @@ if ($msiLauncherMatches.Count -ne 1) { if (Get-ChildItem $msiExtractDir -Recurse -Filter "procnote.cmd" -File) { throw "MSI still contains the obsolete command-script launcher" } +if (Get-ChildItem $msiExtractDir -Recurse -Filter "procnote-launcher.exe" -File) { + throw "MSI still contains the internal launcher filename" +} if ($msiPathUpdaterMatches.Count -ne 1 -or -not $msiPathUpdaterMatches[0].FullName.EndsWith("\installer\update-user-path.ps1", [System.StringComparison]::OrdinalIgnoreCase)) { throw "MSI does not contain the packaged PATH updater under its installer directory" diff --git a/src-tauri/launchers/README.md b/src-tauri/launchers/README.md index 05988cc..214d349 100644 --- a/src-tauri/launchers/README.md +++ b/src-tauri/launchers/README.md @@ -20,9 +20,12 @@ The launcher resolves symlinks and locates the GUI from the package layout: - Linux packages name the GUI `procnote-gui` and install the launcher as `/usr/bin/procnote`. -`scripts/prepare-launcher.mjs` builds the launcher for Tauri's target and stages -it under the ignored `src-tauri/launchers/bin/` directory before Tauri compiles -and bundles the app. `scripts/tauri.mjs` injects the matching +`scripts/prepare-launcher.mjs` builds the launcher for Tauri's target and copies +Cargo's `procnote-launcher[.exe]` artifact into the ignored +`src-tauri/launchers/bin/` directory as the public `procnote[.exe]` filename. +Bundle mappings preserve that basename instead of requesting a rename because +the Tauri WiX bundler preserves a resource's source filename. `scripts/tauri.mjs` +injects the matching `tauri.bundle.*.conf.json` extension for desktop builds, which places the staged binary at the paths above. Keeping generated-resource paths out of Tauri's automatically loaded platform configuration allows direct `cargo check` and diff --git a/src-tauri/tauri.bundle.linux.conf.json b/src-tauri/tauri.bundle.linux.conf.json index d7c7d73..51f1f51 100644 --- a/src-tauri/tauri.bundle.linux.conf.json +++ b/src-tauri/tauri.bundle.linux.conf.json @@ -3,12 +3,12 @@ "linux": { "deb": { "files": { - "/usr/bin/procnote": "launchers/bin/procnote-launcher" + "/usr/bin/procnote": "launchers/bin/procnote" } }, "rpm": { "files": { - "/usr/bin/procnote": "launchers/bin/procnote-launcher" + "/usr/bin/procnote": "launchers/bin/procnote" } } } diff --git a/src-tauri/tauri.bundle.macos.conf.json b/src-tauri/tauri.bundle.macos.conf.json index 7040841..16e21ee 100644 --- a/src-tauri/tauri.bundle.macos.conf.json +++ b/src-tauri/tauri.bundle.macos.conf.json @@ -1,7 +1,7 @@ { "bundle": { "resources": { - "launchers/bin/procnote-launcher": "bin/procnote" + "launchers/bin/procnote": "bin/procnote" } } } diff --git a/src-tauri/tauri.bundle.windows.conf.json b/src-tauri/tauri.bundle.windows.conf.json index fd1de8a..b432b12 100644 --- a/src-tauri/tauri.bundle.windows.conf.json +++ b/src-tauri/tauri.bundle.windows.conf.json @@ -1,7 +1,7 @@ { "bundle": { "resources": { - "launchers/bin/procnote-launcher.exe": "bin/procnote.exe", + "launchers/bin/procnote.exe": "bin/procnote.exe", "nsis/update-user-path.ps1": "installer/update-user-path.ps1" } } From 932dae997e4ef5ab475004cd1327cf0892bf1e40 Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Wed, 22 Jul 2026 07:56:37 +0900 Subject: [PATCH 5/5] Ignore Tauri bundle-type marker in installer GUI comparison Tauri patches the main executable with its package type before building each installer, so the NSIS and MSI copies always differ by the NSS/MSI marker. Canonicalize that marker to UNK before hashing so the equivalence check still proves both installers ship the same compiled GUI. --- scripts/validate-windows-package.ps1 | 41 ++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/scripts/validate-windows-package.ps1 b/scripts/validate-windows-package.ps1 index c08f490..35e1f31 100644 --- a/scripts/validate-windows-package.ps1 +++ b/scripts/validate-windows-package.ps1 @@ -161,6 +161,38 @@ function Invoke-CapturedProcess { } } +function Get-CanonicalBundleBinaryHash { + param( + [Parameter(Mandatory = $true)] + [string]$BinaryPath, + [Parameter(Mandatory = $true)] + [ValidateSet("NSS", "MSI")] + [string]$BundleType + ) + + $markerPrefix = "__TAURI_BUNDLE_TYPE_VAR_" + $expectedMarker = "$markerPrefix$BundleType" + $canonicalMarker = "${markerPrefix}UNK" + $bytes = [System.IO.File]::ReadAllBytes($BinaryPath) + $text = [System.Text.Encoding]::ASCII.GetString($bytes) + $markerIndex = $text.IndexOf($expectedMarker, [System.StringComparison]::Ordinal) + if ($markerIndex -lt 0 -or + $markerIndex -ne $text.LastIndexOf($expectedMarker, [System.StringComparison]::Ordinal)) { + throw "$BinaryPath does not contain exactly one $expectedMarker marker" + } + + $canonicalBytes = [System.Text.Encoding]::ASCII.GetBytes($canonicalMarker) + [System.Array]::Copy($canonicalBytes, 0, $bytes, $markerIndex, $canonicalBytes.Length) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return [System.Convert]::ToHexString($sha256.ComputeHash($bytes)) + } + finally { + $sha256.Dispose() + } +} + function Assert-NoRedistributableRuntime { param( [Parameter(Mandatory = $true)] @@ -286,8 +318,13 @@ if ($msiPathUpdaterMatches.Count -ne 1 -or -not $msiPathUpdaterMatches[0].FullName.EndsWith("\installer\update-user-path.ps1", [System.StringComparison]::OrdinalIgnoreCase)) { throw "MSI does not contain the packaged PATH updater under its installer directory" } -if ((Get-FileHash $gui).Hash -ne (Get-FileHash $msiGuiMatches[0].FullName).Hash) { - throw "MSI and NSIS contain different GUI executables" +# Tauri deliberately patches the main binary with its package type before +# creating each installer. Compare canonicalized binaries so NSS/MSI is the +# only accepted difference between the two GUI executables. +$nsisGuiHash = Get-CanonicalBundleBinaryHash -BinaryPath $gui -BundleType "NSS" +$msiGuiHash = Get-CanonicalBundleBinaryHash -BinaryPath $msiGuiMatches[0].FullName -BundleType "MSI" +if ($nsisGuiHash -cne $msiGuiHash) { + throw "MSI and NSIS GUI executables differ beyond their Tauri bundle-type markers" } if ((Get-FileHash $sourceLauncher).Hash -ne (Get-FileHash $msiLauncherMatches[0].FullName).Hash) { throw "MSI launcher differs from its freshly built source file"