build: add checked Windows desktop archive packaging
This commit is contained in:
1 parent
99c423c0fd
commit
d9336db1d5
5 files changed
+187
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user