diff --git a/CHANGELOG.md b/CHANGELOG.md index b2726a7..1ee8e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ User-facing changes to ConductLoop (the desktop app and its local server), newest first. +## Unreleased + +- **Windows: a script to build an installer (experimental, unsigned).** `scripts/build-windows.ps1` freezes the server with PyInstaller and runs `tauri build --bundles nsis` (an `.msi` with `-Bundles nsis,msi`); the Windows settings live in `desktop/src-tauri/tauri.windows.conf.json`, so the macOS build does not change. The installer is not signed, so Windows SmartScreen warns when it runs ("More info", then "Run anyway"), and it needs WebView2 (included in Windows 11 and current Windows 10; the installer downloads it if missing). No updates on Windows: the app does not look for updates at start-up there. On Windows the bundled server now runs in UTF-8 mode. Not tested on a real Windows machine yet; Windows is still not a supported platform. `docs/PORTABILITY.md` has the status. + ## 1.2.3 — 2026-10-29 — A home for your notes and a Brain that helps without a key The first version that installs from the app: in 1.2.2, "Check for Updates…" diff --git a/desktop/conductloop_server.spec b/desktop/conductloop_server.spec index 61e1f4d..e1506a2 100644 --- a/desktop/conductloop_server.spec +++ b/desktop/conductloop_server.spec @@ -8,6 +8,7 @@ Build from the repo root: Output: dist/conductloop-server/ (onedir) with the launcher binary + the ops_dashboard package and its web/ assets bundled. """ +import sys from pathlib import Path from PyInstaller.utils.hooks import ( # noqa: F821 @@ -65,10 +66,17 @@ a = Analysis( pyz = PYZ(a.pure) +# Windows only: run the frozen interpreter in UTF-8 mode (the same as +# python -X utf8; PyInstaller bakes it into the executable). +# Without it open() without an encoding uses the ANSI code page (cp1252) there. +# The macOS and Linux builds get no options, as before. +options = [("X utf8", None, "OPTION")] if sys.platform == "win32" else [] + # onefile: a single executable, so Tauri can ship it as an externalBin sidecar. exe = EXE( pyz, a.scripts, + options, a.binaries, a.datas, [], diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 646602a..aec5bb7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -321,9 +321,10 @@ pub fn run() { }) .setup(|app| { // Release builds look for an update once, quietly, after start-up. - // Not on Linux: the AppImage and .deb have no signed updates (and - // the feed in tauri.conf.json is the macOS one). - if !cfg!(debug_assertions) && !cfg!(target_os = "linux") { + // Only on macOS: the AppImage, the .deb and the Windows installer + // have no signed updates (and the feed in tauri.conf.json is the + // macOS one). + if !cfg!(debug_assertions) && cfg!(target_os = "macos") { updates::check_after_startup(app.handle()); } // 0. In-app browser state + filtering proxy (browser design PR 4/5). diff --git a/desktop/src-tauri/tauri.windows.conf.json b/desktop/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..629d189 --- /dev/null +++ b/desktop/src-tauri/tauri.windows.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["nsis"], + "createUpdaterArtifacts": false, + "shortDescription": "Local-first second-brain", + "windows": { + "webviewInstallMode": { "type": "downloadBootstrapper" }, + "nsis": { "installMode": "currentUser" } + } + } +} diff --git a/docs/PORTABILITY.md b/docs/PORTABILITY.md index 6ac9bbe..441bfe3 100644 --- a/docs/PORTABILITY.md +++ b/docs/PORTABILITY.md @@ -579,10 +579,55 @@ except through the Linux CI tests.** Notes: | | macOS | Linux | Windows | |---|---|---|---| -| Status | done | not built | not built | +| Status | done | script and CI bundle job, untested on a desktop | script (NSIS), untested on a real machine | | Risk | - | Medium (glibc, onnxruntime size) | Medium (AV, long paths) | | PR | - | PR 4 | PR 11 | +### Windows status + +**Experimental and unsigned. Nothing here has been run on a real Windows machine.** +What exists: `scripts/build-windows.ps1` (the Windows twin of +`scripts/build-linux.sh`) and `desktop/src-tauri/tauri.windows.conf.json`. What +does not: code signing, an updater, a Windows keychain backend (PR 10), the +in-app browser, code sessions (no sandbox) and any release. + +- **Build.** On 64-bit Windows with Python >= 3.13, Node, Rust (MSVC toolchain) + and the Visual Studio Build Tools, `scripts\build-windows.ps1` freezes the + server with `desktop/conductloop_server.spec` into + `desktop/src-tauri/binaries/`, named `conductloop-server-x86_64-pc-windows-msvc.exe` + (what Tauri expects: `externalBin` + `-.exe`), and runs + `tauri build --bundles nsis`. `-Bundles nsis,msi` also builds an `.msi`; + `-SidecarOnly` and `-SkipSidecar` split the two steps. Output: + `desktop/src-tauri/target/release/bundle/nsis/ConductLoop__x64-setup.exe`. + macOS (`scripts/release-desktop.sh`, `tauri.conf.json`) is not touched: the + Windows settings live only in `tauri.windows.conf.json`, which Tauri reads on + Windows only. +- **No updater.** `createUpdaterArtifacts` is `false` for Windows (no `.sig`, no + signing key needed, `TAURI_SIGNING_*` is dropped by the script), and the app + does not look for updates at start-up there: the check runs on macOS only + (`lib.rs`). The updater endpoint in `tauri.conf.json` is the macOS manifest and + stays so. To update, download and run the new installer. +- **UTF-8.** The frozen server runs in UTF-8 mode on Windows (a PyInstaller + option in the spec, Windows only), so files are read and written as UTF-8 + instead of the ANSI code page. macOS and Linux builds are unchanged. +- **Installer.** NSIS, per user (`installMode: currentUser`): no administrator + prompt, installed under the user's profile. +- **WebView2 (requirement).** The app window is a Microsoft Edge WebView2. + Windows 11 and current Windows 10 already have the runtime; if it is missing + the installer downloads it (`downloadBootstrapper`), so that machine needs an + internet connection during the install. Windows 10 or 11 on x86_64 only (no + Windows on ARM build). +- **SmartScreen warning (unsigned binary).** There is no code-signing + certificate, so Windows Defender SmartScreen shows "Windows protected your PC" + when the installer is run. Click **More info**, then **Run anyway**. Antivirus + software can also flag a PyInstaller one-file executable, which unpacks itself + to a temporary folder on every start (slower start, false positives are + common). Both go away only with a signed build, which is not planned yet. +- **Known limits on Windows today:** the in-app browser is off, code sessions + are off, API keys go to the encrypted vault (no Credential Manager yet) and the + automated checks that exist run on `windows-latest` (see 8): compile and the + Windows-safe tests, not the installer. + --- ## 6. Native menu and shortcuts @@ -886,6 +931,9 @@ byte-for-byte (see "Invariantes de macOS"). "Tests" are what that PR must add. new script, the `update-windows-x86_64.json` flow, and the "never publish a non-macOS release as latest" check in `scripts/release_check.py` (a check only). *Tests:* `release_check` tests. + *Done so far:* the NSIS packaging script and config, unsigned and without an + updater (see "Windows status" in section 5). The updater manifest, the Help + menu entry and the `release_check` rule are still open. Optional later: re-enable the in-app browser on Linux/Windows once the upstream issues are resolved and the proxy is verified on real machines. A Windows diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..f29b1d4 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,173 @@ +<# +Build the Windows installer of the desktop app: an NSIS setup.exe (and, only if +asked for, an .msi). Windows x86_64 only. + + scripts\build-windows.ps1 [-SidecarOnly | -SkipSidecar] [-Bundles nsis[,msi]] [-Help] + +What it does, in order: + 1. Freezes the local server (desktop\conductloop_server.spec) with + PyInstaller, in a venv of its own under build\windows-venv, and copies it + to desktop\src-tauri\binaries\conductloop-server-x86_64-pc-windows-msvc.exe, + the name Tauri expects for an externalBin on this target (-.exe). + 2. Runs `tauri build --bundles ` from desktop\. The Windows-only + settings live in desktop\src-tauri\tauri.windows.conf.json, which Tauri + merges over tauri.conf.json on Windows only; macOS builds do not read it. + +Output: desktop\src-tauri\target\release\bundle\nsis\*.exe (and \msi\*.msi). + +There is no code signing and no updater on Windows: the override turns off the +updater artifacts, so no signing key is needed, and any TAURI_SIGNING_* +variable in the environment is dropped. The installer is NOT signed with a +code-signing certificate, so Windows SmartScreen warns when it is run ("More +info", then "Run anyway"). This script never publishes anything. It does not +touch scripts\release-desktop.sh (the macOS release). + + -SidecarOnly stop after step 1 (needs only Python). + -SkipSidecar reuse the sidecar already in desktop\src-tauri\binaries\. + -Bundles installer formats to build, "nsis" (default) and/or "msi". + +Needs: Windows 10 or 11 on x86_64, Python >= 3.13 64-bit (set PYTHON to pick +one; default: python on PATH), Node/npm, a Rust toolchain for +x86_64-pc-windows-msvc with the Visual Studio Build Tools (C++ workload), and +internet access (Tauri downloads the NSIS tools; the installer downloads the +WebView2 runtime on machines that lack it). +#> +[CmdletBinding()] +param( + [switch]$SidecarOnly, + [switch]$SkipSidecar, + [string[]]$Bundles = @("nsis"), + [Alias("h")] + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +$Triple = "x86_64-pc-windows-msvc" +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$TauriDir = Join-Path $Root "desktop\src-tauri" +$SidecarDest = Join-Path $TauriDir "binaries\conductloop-server-$Triple.exe" +$Venv = if ($env:CONDUCTLOOP_WINDOWS_VENV) { $env:CONDUCTLOOP_WINDOWS_VENV } else { Join-Path $Root "build\windows-venv" } +$Python = if ($env:PYTHON) { $env:PYTHON } else { "python" } + +function Fail([string]$message) { + [Console]::Error.WriteLine("x $message") + exit 1 +} + +# Run a native command and stop when it fails ($ErrorActionPreference does not +# cover exit codes of programs). +function Invoke-Native([string]$what, [scriptblock]$command) { + & $command + if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)" } +} + +if ($Help) { + $text = Get-Content -LiteralPath $PSCommandPath -Raw + $inner = $text -replace '(?s)^<#\r?\n', '' -replace '(?s)#>.*$', '' + Write-Output $inner.TrimEnd() + exit 0 +} + +if ($SidecarOnly -and $SkipSidecar) { Fail "-SidecarOnly and -SkipSidecar exclude each other (see -Help)" } +# PowerShell turns `-Bundles nsis,msi` into an array, and `-File x.ps1 -Bundles +# nsis,msi` from another program into one string: accept both. +$BundleList = @($Bundles | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +if (-not $BundleList -or @($BundleList | Where-Object { $_ -notin @("nsis", "msi") })) { + Fail "-Bundles must be nsis, msi or nsis,msi (got '$($Bundles -join ',')')" +} +$BundleArg = ($BundleList | Select-Object -Unique) -join "," + +if ([System.Environment]::OSVersion.Platform -ne "Win32NT" -or -not [System.Environment]::Is64BitOperatingSystem) { + Fail "build-windows.ps1 builds $Triple packages and must run on 64-bit Windows" +} + +Set-Location $Root + +# -- 1. Sidecar --------------------------------------------------------------- +if (-not $SkipSidecar) { + Write-Output "> Building the sidecar (PyInstaller) for $Triple" + & $Python -c "import sys, struct; sys.exit(0 if sys.version_info >= (3, 13) and struct.calcsize('P') == 8 else 1)" + if ($LASTEXITCODE -ne 0) { Fail "$Python is not a 64-bit Python >= 3.13 (pyproject.toml: requires-python >= 3.13); set PYTHON=" } + + Invoke-Native "creating the venv" { & $Python -m venv $Venv } + $VenvPython = Join-Path $Venv "Scripts\python.exe" + $PyInstaller = Join-Path $Venv "Scripts\pyinstaller.exe" + Invoke-Native "upgrading pip" { & $VenvPython -m pip install --quiet --upgrade pip } + + # The same dependencies the macOS release freezes, minus sentence-transformers + # (it pulls in torch, which the spec excludes: embeddings run on ONNX). + New-Item -ItemType Directory -Force -Path (Join-Path $Root "build") | Out-Null + $SidecarRequirements = Join-Path $Root "build\windows-sidecar-requirements.txt" + $lines = Get-Content -LiteralPath (Join-Path $Root "requirements.txt") | + Where-Object { $_ -notmatch '^\s*(#|-r|sentence-transformers|$)' } | + ForEach-Object { $_ -replace '\s*#.*$', '' } + [System.IO.File]::WriteAllLines($SidecarRequirements, [string[]]$lines, (New-Object System.Text.UTF8Encoding($false))) + + Invoke-Native "installing the sidecar dependencies" { + & $VenvPython -m pip install --quiet -r requirements-core.txt -r $SidecarRequirements pyinstaller + } + Invoke-Native "PyInstaller" { + & $PyInstaller desktop\conductloop_server.spec --noconfirm --distpath dist --workpath build\pyi + } + $Frozen = Join-Path $Root "dist\conductloop-server.exe" + if (-not (Test-Path -LiteralPath $Frozen)) { Fail "PyInstaller did not produce $Frozen" } + New-Item -ItemType Directory -Force -Path (Split-Path $SidecarDest) | Out-Null + Copy-Item -LiteralPath $Frozen -Destination $SidecarDest -Force + Get-Item -LiteralPath $SidecarDest | Format-List Name, Length | Out-String | Write-Output +} +if ($SidecarOnly) { exit 0 } + +if (-not (Test-Path -LiteralPath $SidecarDest)) { + Fail "$SidecarDest is missing: run without -SkipSidecar first" +} + +# -- 2. Installer ------------------------------------------------------------- +$rustHost = ((& rustc -vV) | Where-Object { $_ -like "host:*" }) -replace '^host:\s*', '' +if ($rustHost -ne $Triple) { + Fail "the Rust host is '$rustHost', not $Triple (install the x86_64 MSVC toolchain: rustup default stable-x86_64-pc-windows-msvc)" +} + +Write-Output "> Building the installer ($BundleArg) (tauri build)" +# No signing on Windows: make sure a key left in the environment is not used. +Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY -ErrorAction SilentlyContinue +Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD -ErrorAction SilentlyContinue + +# Panic locations embed absolute source paths (...\.cargo\registry\...): do not +# publish the builder's home directory in the binary (same as the macOS release). +# RUSTFLAGS is split on spaces, so a home directory with a space cannot be mapped. +$savedRustflags = $env:RUSTFLAGS +if ($env:USERPROFILE -and $env:USERPROFILE -notmatch '\s') { + $env:RUSTFLAGS = "$env:RUSTFLAGS --remap-path-prefix=$($env:USERPROFILE)=/build".Trim() +} else { + [Console]::Error.WriteLine("! the home directory has a space: its path stays in the binary's panic messages") +} +Push-Location (Join-Path $Root "desktop") +try { + Invoke-Native "npm ci" { & npm ci --no-audit --no-fund } + Invoke-Native "tauri build" { & npm run --silent tauri -- build --bundles $BundleArg } +} finally { + Pop-Location + $env:RUSTFLAGS = $savedRustflags +} + +$Bin = Join-Path $TauriDir "target\release\conductloop-desktop.exe" +if ((Test-Path -LiteralPath $Bin) -and $env:USERPROFILE) { + $bytes = [System.IO.File]::ReadAllBytes($Bin) + $latin1 = [System.Text.Encoding]::GetEncoding(28591).GetString($bytes) + if ($latin1.IndexOf($env:USERPROFILE, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + [Console]::Error.WriteLine("! the desktop binary still contains $($env:USERPROFILE)") + } +} + +Write-Output "" +Write-Output "Built:" +$found = @() +foreach ($dir in "nsis", "msi") { + $found += Get-ChildItem -Path (Join-Path $TauriDir "target\release\bundle\$dir\*") -Include *.exe, *.msi -File -ErrorAction SilentlyContinue +} +if (-not $found) { Fail "no installer was produced under $TauriDir\target\release\bundle" } +foreach ($f in $found) { + "{0} {1:N0} bytes" -f $f.FullName, $f.Length + " sha256 {0}" -f (Get-FileHash -Algorithm SHA256 -LiteralPath $f.FullName).Hash.ToLower() +} diff --git a/tests/test_linux_packaging.py b/tests/test_linux_packaging.py index 3ae4f1e..7b2409f 100644 --- a/tests/test_linux_packaging.py +++ b/tests/test_linux_packaging.py @@ -140,9 +140,10 @@ def test_it_refuses_to_run_off_linux_x86_64(tmp_path, system, machine): assert not (tmp_path / "build").exists() and not (tmp_path / "dist").exists() -def test_the_startup_update_check_is_off_on_linux_and_unchanged_elsewhere(): +def test_the_startup_update_check_is_off_on_linux_and_unchanged_on_macos(): + # Since the Windows packaging it is "macOS only" (Linux stays off, as before). lib = (TAURI / "src" / "lib.rs").read_text() - assert 'if !cfg!(debug_assertions) && !cfg!(target_os = "linux") {' in lib + assert 'if !cfg!(debug_assertions) && cfg!(target_os = "macos") {' in lib assert "updates::check_after_startup(app.handle());" in lib # The plugin stays registered on every platform (the commands need its state). assert ".plugin(tauri_plugin_updater::Builder::new().build())" in lib diff --git a/tests/test_linux_update_manifest.py b/tests/test_linux_update_manifest.py index a3cbfd7..8c186a6 100644 --- a/tests/test_linux_update_manifest.py +++ b/tests/test_linux_update_manifest.py @@ -323,7 +323,8 @@ def test_the_linux_build_still_has_no_updater(): assert "unset TAURI_SIGNING_PRIVATE_KEY" in build assert lum.MANIFEST_NAME not in build and "linux_update_manifest" not in build lib = (ROOT / "desktop" / "src-tauri" / "src" / "lib.rs").read_text() - assert "!cfg!(target_os = \"linux\")" in lib # no update check at start-up on Linux + # No update check at start-up on Linux: it runs on macOS only. + assert 'if !cfg!(debug_assertions) && cfg!(target_os = "macos") {' in lib def test_the_bundle_workflow_publishes_nothing(): diff --git a/tests/test_windows_packaging.py b/tests/test_windows_packaging.py new file mode 100644 index 0000000..21d81d3 --- /dev/null +++ b/tests/test_windows_packaging.py @@ -0,0 +1,267 @@ +"""Windows packaging: the override config and scripts/build-windows.ps1. + +What can be checked without a Windows machine: the Windows override is valid and +only touches ``bundle`` (so macOS and Linux builds, which never read it, do not +change), the merged Windows config builds an NSIS installer and needs no signing +key, there is no updater on Windows, and the script names the sidecar the way +Tauri expects. The PowerShell script is parsed (and its refusals run) when +PowerShell is installed, as it is on Windows and on the Windows CI runner. The +real PyInstaller/Tauri build and running the installer are NOT tested here: they +run in the manual "Windows bundles" workflow and need a Windows machine. +""" +from __future__ import annotations + +import copy +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +TAURI = ROOT / "desktop" / "src-tauri" +BASE = json.loads((TAURI / "tauri.conf.json").read_text(encoding="utf-8")) +WINDOWS = json.loads((TAURI / "tauri.windows.conf.json").read_text(encoding="utf-8")) +SCRIPT = ROOT / "scripts" / "build-windows.ps1" +TRIPLE = "x86_64-pc-windows-msvc" +POWERSHELL = shutil.which("pwsh") or shutil.which("powershell") +needs_powershell = pytest.mark.skipif(POWERSHELL is None, reason="PowerShell is not installed") + + +def merge(base, patch): + """JSON Merge Patch (RFC 7396): what Tauri does with a platform config.""" + if not isinstance(patch, dict): + return copy.deepcopy(patch) + out = copy.deepcopy(base) if isinstance(base, dict) else {} + for key, value in patch.items(): + if value is None: + out.pop(key, None) + else: + out[key] = merge(out.get(key), value) + return out + + +MERGED = merge(BASE, {k: v for k, v in WINDOWS.items() if k != "$schema"}) + + +def script_code() -> str: + """The script without its help block and its comments.""" + text = SCRIPT.read_text(encoding="utf-8") + text = re.sub(r"(?s)<#.*?#>", "", text) + return "\n".join(re.sub(r"(^|\s)#.*$", "", line) for line in text.splitlines()) + + +# -- the override --------------------------------------------------------------- + +def test_the_override_only_touches_the_bundle_section(): + # Anything else (app, plugins, identifier, version) must stay the shared one. + assert set(WINDOWS) - {"$schema"} == {"bundle"} + + +def test_the_override_is_called_what_tauri_looks_for(): + assert (TAURI / "tauri.windows.conf.json").is_file() + assert not (TAURI / "tauri.macos.conf.json").exists() + + +def test_macos_and_linux_config_are_unchanged_by_the_override(): + bundle = BASE["bundle"] + assert bundle["targets"] == ["dmg", "app"] + assert bundle["createUpdaterArtifacts"] is True + assert bundle["externalBin"] == ["binaries/conductloop-server"] + assert bundle["macOS"]["minimumSystemVersion"] == "13.0" + assert "windows" not in bundle, "the Windows settings live in tauri.windows.conf.json" + linux = json.loads((TAURI / "tauri.linux.conf.json").read_text(encoding="utf-8")) + assert linux["bundle"]["targets"] == ["appimage", "deb"] + assert "windows" not in linux["bundle"] + + +def test_windows_builds_an_nsis_installer(): + assert MERGED["bundle"]["targets"] == ["nsis"] + # Per user: no administrator prompt, nothing written to Program Files. + assert MERGED["bundle"]["windows"]["nsis"]["installMode"] == "currentUser" + + +def test_windows_installs_webview2_when_it_is_missing(): + # WebView2 ships with Windows 11 and current Windows 10; the installer + # downloads the runtime on a machine that lacks it. + assert MERGED["bundle"]["windows"]["webviewInstallMode"]["type"] == "downloadBootstrapper" + + +def test_windows_has_no_updater_artifacts_and_so_needs_no_signing_key(): + assert MERGED["bundle"]["createUpdaterArtifacts"] is False + + +def test_windows_has_no_code_signing_configured(): + # No certificate exists: the installer is unsigned and SmartScreen warns. + windows = json.dumps(MERGED["bundle"]["windows"]).lower() + for key in ("certificatethumbprint", "signcommand", "timestampurl", "tsp", "digestalgorithm"): + assert key not in windows + + +def test_the_updater_plugin_config_is_kept(): + # The plugin is registered on every platform and fails to start without its + # config (pubkey is required), so the override must not drop it. + assert MERGED["plugins"] == BASE["plugins"] + + +def test_the_merged_config_keeps_the_sidecar_and_has_the_ico_icon(): + assert MERGED["bundle"]["externalBin"] == ["binaries/conductloop-server"] + assert "icons/icon.ico" in MERGED["bundle"]["icon"] + assert (TAURI / "icons" / "icon.ico").is_file() + + +def test_the_description_does_not_say_macos(): + assert "macOS" not in MERGED["bundle"]["shortDescription"] + + +# -- the updater is off on Windows ----------------------------------------------- + +def test_the_startup_update_check_runs_on_macos_only(): + lib = (TAURI / "src" / "lib.rs").read_text(encoding="utf-8") + assert 'if !cfg!(debug_assertions) && cfg!(target_os = "macos") {' in lib + assert "updates::check_after_startup(app.handle());" in lib + # The menu item "Check for Updates…" is macOS-only too. + assert 'if i == 0 && cfg!(target_os = "macos") {' in lib + + +def test_the_updater_endpoint_is_still_the_macos_one(): + # Windows must never read the macOS manifest as its own. + assert BASE["plugins"]["updater"]["endpoints"] == [ + "https://github.com/EVMCOD/conductloop-releases/releases/latest/download/update-macos-aarch64.json" + ] + + +# -- the sidecar spec -------------------------------------------------------------- + +def test_the_frozen_server_runs_in_utf8_mode_on_windows_only(): + spec = (ROOT / "desktop" / "conductloop_server.spec").read_text(encoding="utf-8") + assert 'options = [("X utf8", None, "OPTION")] if sys.platform == "win32" else []' in spec + exe = spec[spec.index("EXE("):] + assert re.search(r"a\.scripts,\s*options,\s*a\.binaries", exe) + + +# -- the script ----------------------------------------------------------------------- + +def test_the_script_puts_the_sidecar_where_tauri_expects_it(): + code = script_code() + assert f'$Triple = "{TRIPLE}"' in code + # externalBin "binaries/conductloop-server" + "-.exe" is what Tauri looks for. + assert MERGED["bundle"]["externalBin"] == ["binaries/conductloop-server"] + assert 'binaries\\conductloop-server-$Triple.exe' in code + assert 'dist\\conductloop-server.exe' in code + assert "--bundles $BundleArg" in code + assert '[string[]]$Bundles = @("nsis")' in code + + +def test_the_script_signs_and_publishes_nothing(): + code = script_code() + assert "Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY " in code + assert "Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" in code + for word in ("gh release", "gh api", "git push", "curl", "Invoke-WebRequest", "Invoke-RestMethod", + "signtool", "Set-AuthenticodeSignature", "codesign", "notarytool", "release-desktop"): + assert word not in code + + +def test_the_script_is_plain_ascii(): + # Windows PowerShell 5.1 reads a script without a byte order mark as ANSI. + SCRIPT.read_bytes().decode("ascii") + + +def test_the_macos_release_script_is_not_wired_to_the_windows_one(): + assert "build-windows" not in (ROOT / "scripts" / "release-desktop.sh").read_text(encoding="utf-8") + + +def test_the_linux_script_is_not_wired_to_the_windows_one(): + assert "build-windows" not in (ROOT / "scripts" / "build-linux.sh").read_text(encoding="utf-8") + + +def _run(*args): + return subprocess.run([POWERSHELL, "-NoProfile", "-NonInteractive", "-File", str(SCRIPT), *args], + capture_output=True, text=True, encoding="utf-8", cwd=ROOT, timeout=120) + + +@needs_powershell +def test_the_script_parses_without_errors(): + command = ("$e = $null; $t = $null; " + f"[void][System.Management.Automation.Language.Parser]::ParseFile('{SCRIPT}', [ref]$t, [ref]$e); " + "if ($e.Count) { $e | ForEach-Object { $_.ToString() }; exit 1 }") + r = subprocess.run([POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, r.stdout + r.stderr + + +@needs_powershell +def test_help_works_anywhere_and_prints_the_header(): + r = _run("-Help") + assert r.returncode == 0, r.stderr + assert r.stdout.startswith("Build the Windows installer") + assert "-SidecarOnly" in r.stdout and "-SkipSidecar" in r.stdout and "-Bundles" in r.stdout + assert "SmartScreen" in r.stdout + + +@needs_powershell +@pytest.mark.parametrize("args,message", [ + (["-Bundles", "dmg"], "-Bundles must be nsis, msi or nsis,msi"), + (["-Bundles", "nsis;calc"], "-Bundles must be nsis, msi or nsis,msi"), + (["-SidecarOnly", "-SkipSidecar"], "exclude each other"), +]) +def test_bad_options_are_refused_before_anything_is_built(args, message): + # A checkout where the script has already built the sidecar is fine: a refusal + # must leave that file exactly as it was (present or absent), not require absence. + sidecar = TAURI / "binaries" / f"conductloop-server-{TRIPLE}.exe" + + def state(): + return (sidecar.stat().st_mtime_ns, sidecar.stat().st_size) if sidecar.exists() else None + + before = state() + r = _run(*args) + assert r.returncode == 1 + assert message in r.stderr + assert state() == before + + +@needs_powershell +@pytest.mark.skipif(sys.platform == "win32", reason="on Windows the script would start building") +@pytest.mark.parametrize("args", [[], ["-Bundles", "nsis,msi"], ["-Bundles", "msi"]]) +def test_it_refuses_to_run_off_windows(args): + # (The options are valid, so the refusal is the OS check, not the option check.) + r = _run(*args) + assert r.returncode == 1 + assert "must run on 64-bit Windows" in r.stderr + + +def _in_powershell(bundles: str): + command = f"& '{SCRIPT}' -Bundles {bundles}; exit $LASTEXITCODE" + return subprocess.run([POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, text=True, encoding="utf-8", cwd=ROOT, timeout=120) + + +@needs_powershell +def test_bundles_given_as_a_powershell_list_are_understood(): + # `-Bundles nsis,msi` typed in PowerShell is an array, not a string: this is + # how the workflow calls the script (it failed to bind as a string). An + # invalid list is used so that nothing is built on Windows. + r = _in_powershell("nsis,dmg") + assert "Cannot process argument" not in r.stderr, r.stderr + assert r.returncode == 1 and "got 'nsis,dmg'" in r.stderr + + +@needs_powershell +@pytest.mark.skipif(sys.platform == "win32", reason="on Windows the script would start building") +def test_a_valid_powershell_list_gets_past_the_option_check(): + r = _in_powershell("nsis,msi") + assert r.returncode == 1 and "must run on 64-bit Windows" in r.stderr + + +# -- the docs -------------------------------------------------------------------------- + +def test_the_docs_tell_about_smartscreen_and_webview2(): + doc = (ROOT / "docs" / "PORTABILITY.md").read_text(encoding="utf-8") + assert "### Windows status" in doc + section = doc[doc.index("### Windows status"):] + for text in ("SmartScreen", "More info", "Run anyway", "WebView2", "scripts/build-windows.ps1", + "tauri.windows.conf.json"): + assert text in section, text