build: add checked Windows desktop archive packaging

This commit is contained in:
karl committed 2026-10-12 21:36:17 +02:00
1 parent 99c423c0fd
commit d9336db1d5
5 files changed
+187

No files matched your search

+26
View File
@@ -108,6 +108,32 @@ signing/notarization requires Apple tooling and credentials. See the
## CI and packaging gates ## CI and packaging gates
### Windows archive for source installations
After staging the CPU worker and media helpers, build an embedded-frontend
desktop binary and collect its runtime files:
```powershell
Set-Location apps/desktop
npm run tauri -- build --no-bundle
Set-Location ../..
python tools/package_windows.py
```
The generated `.searchi/packages/searchi-windows.zip` contains the desktop,
matching-architecture Google worker, FFmpeg/FFprobe and notices. It excludes
model caches, local settings and indexes. `PACKAGE.json` records SHA-256 for
each included file. Extract the entire archive before launching `searchi.exe`.
First launch uses normal verified model setup. WebView2 and the applicable
Microsoft C++ runtime remain Windows prerequisites.
This is an unsigned development archive, not a production installer. Native
launch, clean-machine dependencies, upgrades and signatures must still be
verified. The packager rejects mixed architectures and preserves a previous
archive if validation fails. It does not certify GPU dependency packaging.
### CI status and installer gates
Gitea Actions are disabled for this repository at the owner's request after Gitea Actions are disabled for this repository at the owner's request after
repeated failure emails (2026-10-12). Existing workflows remain as references: repeated failure emails (2026-10-12). Existing workflows remain as references:
GitHub defines native OS matrices; Gitea targets its available Ubuntu runner. GitHub defines native OS matrices; Gitea targets its available Ubuntu runner.
+5
View File
@@ -49,6 +49,11 @@ Status: implementation started; no production release yet. Windows VM tests use
- [ ] Isolated extraction workers with timeouts and resource ceilings. - [ ] Isolated extraction workers with timeouts and resource ceilings.
## 5. Release gates ## 5. Release gates
- [x] Windows desktop archive packager includes explicit runtime/helper files
and notices, checks architecture and records per-file checksums; excludes
model caches and user data. Controlled packaging regression tests pass.
- [ ] Verify embedded-frontend Release archive launch outside the source tree,
clean-machine prerequisites, GPU dependencies, upgrades and signing.
- [x] Preflight native license and the complete offline Google model pair before - [x] Preflight native license and the complete offline Google model pair before
modifying staged resources; corrupted/missing assets preserve existing worker. modifying staged resources; corrupted/missing assets preserve existing worker.
- [ ] Atomic resource staging and interrupted-copy recovery for release bundles. - [ ] Atomic resource staging and interrupted-copy recovery for release bundles.
+7
View File
@@ -7,6 +7,13 @@ and `SEARCHI_FORCE_CPU=1`.
## Current integration checkpoint (2026-10-12) ## Current integration checkpoint (2026-10-12)
The Windows archive packager has two controlled Python regressions: complete
required file collection with checksum verification and user-settings exclusion,
and mismatched worker architecture preserving the previous archive. Both pass.
An actual CPU-only Tauri embedded-frontend Release build has been started;
archive creation, native launch outside the source tree and clean-machine
installation remain unverified until its completion and separate checks.
Offline staging now verifies the native license and every bundled Google asset Offline staging now verifies the native license and every bundled Google asset
before modifying resources. Two Python regressions verify missing-model rejection before modifying resources. Two Python regressions verify missing-model rejection
and same-size corruption in the second model leaving the existing staged worker and same-size corruption in the second model leaving the existing staged worker
+90
View File
@@ -0,0 +1,90 @@
"""Build a Windows desktop archive from already compiled, staged resources.
This packages an unsigned development build; it does not publish or certify
an installer. No model cache, user settings or local index enters the archive.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import struct
import tempfile
import zipfile
ROOT = Path(__file__).resolve().parents[1]
def pe_machine(path):
with path.open("rb") as stream:
header = stream.read(64)
if len(header) != 64 or header[:2] != b"MZ":
raise ValueError(f"Not a Windows executable: {path.name}")
offset = struct.unpack_from("<I", header, 60)[0]
if offset > path.stat().st_size - 6:
raise ValueError(f"Invalid executable header: {path.name}")
stream.seek(offset)
signature = stream.read(6)
if signature[:4] != b"PE\0\0":
raise ValueError(f"Invalid PE signature: {path.name}")
return struct.unpack_from("<H", signature, 4)[0]
def package(root, executable, output):
resources = root / "apps/desktop/src-tauri/resources"
selected = [(executable, "searchi.exe"), (root / "LICENSE", "LICENSE.txt")]
for name in ["searchi-gemma-worker.exe", "LLAMA-LICENSE.txt", "media-runtime/manifest.json",
"media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe"]:
selected.append((resources / name, name))
for directory in ["licenses", "media-runtime/licenses"]:
notices = list((resources / directory).rglob("*.txt"))
if not notices:
raise ValueError(f"Required notices are missing: {directory}")
selected.extend((path, path.relative_to(resources).as_posix()) for path in notices)
machine = pe_machine(executable)
architecture = {0x8664: "x86_64", 0xAA64: "aarch64"}.get(machine)
if architecture is None:
raise ValueError("Unsupported Windows architecture")
for path, name in selected:
if not path.is_file():
raise ValueError(f"Required package file is missing: {name}")
if path != executable and path != root / "LICENSE" and not path.resolve().is_relative_to(resources.resolve()):
raise ValueError("Resource resolves outside the staging directory")
if name.endswith(".exe") and pe_machine(path) != machine:
raise ValueError(f"Executable architecture differs: {name}")
records = []
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".partial", delete=False) as temporary:
temporary_path = Path(temporary.name)
try:
with zipfile.ZipFile(temporary_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path, name in selected:
with path.open("rb") as stream:
checksum = hashlib.file_digest(stream, "sha256").hexdigest()
archive.write(path, name)
records.append({"path": name, "bytes": path.stat().st_size, "sha256": checksum})
archive.writestr("PACKAGE.json", json.dumps({"platform": "windows", "architecture": architecture,
"status": "unsigned development package; clean-machine verification pending", "files": records}, indent=2))
archive.writestr("START-HERE.txt", "searchi — Find what matters. Locally.\n\n"
"Extract the entire folder, then open searchi.exe. Keep its helpers alongside it.\n"
"Windows needs WebView2 and the applicable Microsoft C++ runtime.\n"
"First launch downloads approximately 865 MB of verified Google model files.\n"
"Choose Add folder to index your own files; ordinary search stays local.\n"
"This is an unsigned development build, not a certified production release.\n")
os.replace(temporary_path, output)
finally:
temporary_path.unlink(missing_ok=True)
return architecture
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--executable", type=Path, default=ROOT / "apps/desktop/src-tauri/target/release/searchi-desktop.exe")
parser.add_argument("--output", type=Path, default=ROOT / ".searchi/packages/searchi-windows.zip")
args = parser.parse_args()
architecture = package(ROOT, args.executable, args.output)
print(f"Packaged Windows {architecture}: {args.output}")
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
import hashlib
import json
from pathlib import Path
import struct
import tempfile
import unittest
import zipfile
from package_windows import package
def executable(path, machine=0x8664):
path.parent.mkdir(parents=True, exist_ok=True)
data = bytearray(70)
data[:2] = b"MZ"
struct.pack_into("<I", data, 60, 64)
data[64:68] = b"PE\0\0"
struct.pack_into("<H", data, 68, machine)
path.write_bytes(data)
class PackageTests(unittest.TestCase):
def fixture(self, directory):
root = Path(directory)
resources = root / "apps/desktop/src-tauri/resources"
app = root / "build/app.exe"
executable(app)
(root / "LICENSE").write_text("application notice")
for name in ["searchi-gemma-worker.exe", "media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe"]:
executable(resources / name)
for name in ["LLAMA-LICENSE.txt", "licenses/font.txt", "media-runtime/licenses/LICENSE.txt", "media-runtime/manifest.json"]:
path = resources / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("notice")
(resources / "settings.json").write_text("private settings")
return root, resources, app, root / "archive.zip"
def test_complete_archive_excludes_settings_and_verifies_recorded_bytes(self):
with tempfile.TemporaryDirectory() as directory:
root, _, app, output = self.fixture(directory)
package(root, app, output)
with zipfile.ZipFile(output) as archive:
self.assertNotIn("settings.json", archive.namelist())
self.assertIn("media-runtime/bin/ffprobe.exe", archive.namelist())
for record in json.loads(archive.read("PACKAGE.json"))["files"]:
self.assertEqual(hashlib.sha256(archive.read(record["path"])).hexdigest(), record["sha256"])
def test_mixed_architecture_preserves_previous_archive(self):
with tempfile.TemporaryDirectory() as directory:
root, resources, app, output = self.fixture(directory)
output.write_bytes(b"previous package")
executable(resources / "searchi-gemma-worker.exe", 0xAA64)
with self.assertRaisesRegex(ValueError, "architecture differs"):
package(root, app, output)
self.assertEqual(output.read_bytes(), b"previous package")
if __name__ == "__main__":
unittest.main()