Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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…"
Expand Down
8 changes: 8 additions & 0 deletions desktop/conductloop_server.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
[],
Expand Down
7 changes: 4 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions desktop/src-tauri/tauri.windows.conf.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
50 changes: 49 additions & 1 deletion docs/PORTABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` + `-<triple>.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_<version>_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
Expand Down Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions scripts/build-windows.ps1
Original file line number Diff line number Diff line change
@@ -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 (-<triple>.exe).
2. Runs `tauri build --bundles <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()
}
5 changes: 3 additions & 2 deletions tests/test_linux_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion tests/test_linux_update_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading