Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
511dbc6
feat(desktop): milestone 1 of the console, a window that runs a search
RobbinBouwmeester Aug 30, 2026
0c22017
feat(engine): machine-readable doctor and config schema, for the console
RobbinBouwmeester Aug 30, 2026
24ae604
feat(desktop): milestones 2 and 3, component installation and a gener…
RobbinBouwmeester Aug 30, 2026
f66fcca
feat(release): build the desktop installers for Windows and Linux
RobbinBouwmeester Aug 30, 2026
dd2de5d
fix(desktop): compile the schema and requirements in, and fix the bun…
RobbinBouwmeester Aug 30, 2026
dfbc6ab
feat: peak-cap and disk pre-flight, and an AppImage-safe resource lookup
RobbinBouwmeester Aug 30, 2026
a4c29df
feat(desktop): run history, and fix the Linux engine build in the rel…
RobbinBouwmeester Aug 30, 2026
5e30e71
ci: build and test the desktop application
RobbinBouwmeester Aug 30, 2026
56f72db
fix(ci): pip-audit --strict fails on CPU torch, which is not a finding
RobbinBouwmeester Aug 30, 2026
595d533
fix(desktop): never group-kill a process group that is our own
RobbinBouwmeester Aug 30, 2026
a160c3c
fix(release): the AppImage needs a GNU engine, because patchelf break…
RobbinBouwmeester Aug 30, 2026
428cdbe
fix(release): actually switch the AppImage engine to GNU
RobbinBouwmeester Aug 30, 2026
54f14a5
test(desktop): stop the process-kill test running in shared CI
RobbinBouwmeester Aug 30, 2026
f1917c1
fix(desktop): the development fallback paths were one directory too high
RobbinBouwmeester Aug 30, 2026
e8c97f1
Merge branch 'main' into feat/desktop-console
RobbinBouwmeester Sep 4, 2026
6e314b0
Merge branch 'main' into feat/desktop-console
RobbinBouwmeester Sep 4, 2026
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
57 changes: 53 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,17 @@ jobs:
run: |
set -euo pipefail
python -m pip install --quiet pip-audit
# --strict fails on a package pip-audit cannot resolve, rather than passing
# over it in silence, which would make a clean report meaningless.
pip-audit --strict --progress-spinner off
# NOT --strict, and the reason is specific rather than a shrug. --strict
# fails when a package cannot be resolved on PyPI at all, and `torch` is
# installed from the PyTorch index as `2.12.1+cpu`, a local version that by
# construction does not exist on PyPI. That is not a finding, it is how CPU
# torch is distributed, and failing on it would train everyone to ignore
# this job. Real advisories still fail the run.
#
# The gap this leaves is torch itself. Watch
# https://github.com/pytorch/pytorch/security/advisories directly; nothing
# here can audit a wheel PyPI has never seen.
pip-audit --progress-spinner off

# The exact package set the audit above ran against. The specifications pin the
# tools that matter (mokapot, ms2pip, deeplc, torch) and leave the scientific
Expand Down Expand Up @@ -338,6 +346,44 @@ jobs:
python -m pip install --quiet pytest
python -m pytest tests/python -q -rs

# The desktop application. A separate Cargo workspace, so nothing above builds it,
# and without this job it could break while every other check stayed green.
#
# It is not independent of the engine either: the settings schema and both
# requirement files are compiled into it with `include_str!`, so a change to
# `config.rs` that regenerates `configs/config-schema.json` reaches this crate.
# That coupling is what makes the job worth its minutes.
desktop:
name: desktop app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

# Tauri links WebKitGTK, whose development headers the runner does not carry.
- name: Install the build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf

- name: Format
working-directory: desktop/src-tauri
run: cargo fmt --check

- name: Clippy
working-directory: desktop/src-tauri
run: cargo clippy --all-targets -- -D warnings

# Library tests only. The end-to-end tests skip themselves without an engine
# binary, which would make this job look like it covered more than it does; a
# real bundle is exercised by the release rehearsal instead.
- name: Unit tests
working-directory: desktop/src-tauri
run: cargo test --lib

# The frontend has no build step, so this is its whole check.
- name: Frontend agrees with the backend
run: python ci/check_desktop_ui.py

smoke-cross-platform:
name: cross-platform byte equality
runs-on: ubuntu-latest
Expand Down Expand Up @@ -434,7 +480,10 @@ jobs:
# docs/24 is generated from config.rs, so a new field or a changed default
# must land with its documentation or CI fails. A reference nobody
# regenerates is worse than none, because it reads as current.
- name: Config reference is current
# One parse of config.rs produces two artifacts: the reference document a
# person reads, and configs/config-schema.json, which the desktop settings
# editor renders its form from. They go stale together, so --check covers both.
- name: Config reference and schema are current
run: python ci/gen_config_reference.py --check

# The release binary is statically linked, so it carries 173 third-party crates
Expand Down
143 changes: 143 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,146 @@ jobs:
rust/mumdia/dist/*.sha256
if-no-files-found: error
retention-days: 7

# The desktop application, built for the two platforms it targets.
#
# A separate job rather than steps inside `release`: it needs Node-free but
# webview-ful build dependencies on Linux, its own cargo workspace, and it must not
# slow down or fail the engine archives, which are the primary artifact. macOS is
# deliberately absent -- an unsigned bundle is blocked outright by Gatekeeper, so
# shipping one would be worse than shipping none.
desktop:
name: desktop ${{ matrix.target }}
needs: [validate-tag]
if: always() && contains(fromJSON('["success", "skipped"]'), needs.validate-tag.result)
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# GNU, not musl, and measured rather than assumed. The engine's own
# release archives are musl and stay musl; inside an AppImage they do not
# survive. `linuxdeploy` runs `patchelf` over every ELF binary it bundles,
# and a static-pie musl binary comes out with a `RUNPATH [$ORIGIN]` entry
# injected into it and segfaults immediately. Verified by extracting a
# built AppImage and running the engine inside it; `uv`, dynamically
# linked, survived the same treatment untouched.
#
# Nothing is lost. musl would buy portability only if the bundle had no
# other glibc floor, and the Tauri host links WebKitGTK, so the
# application sets that floor whatever the engine is built against.
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
engine_exe: ""
bundle_glob: "desktop/target/release/bundle/appimage/*.AppImage"
- os: windows-latest
target: x86_64-pc-windows-msvc
engine_exe: ".exe"
bundle_glob: "desktop/target/release/bundle/msi/*.msi"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

# Tauri needs the platform webview headers at build time on Linux. The
# produced AppImage carries what it needs at runtime.
- name: Install the Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libssl-dev

# Inside `rust/mumdia`, not at the repository root. That directory pins its
# toolchain with `rust-toolchain.toml`, and `rustup target add` adds the target
# to whichever toolchain is active where it runs. From the root it added musl
# to the DEFAULT toolchain while the build used the pinned one, which then
# failed with "can't find crate for `core`" and advice to run the command that
# had just succeeded.
- name: Add the engine target
working-directory: rust/mumdia
run: rustup target add ${{ matrix.target }}

# The application ships the engine it was built alongside. Building both from
# one checkout is what makes a version mismatch impossible.
- name: Build the engine
working-directory: rust/mumdia
run: cargo build --release --locked --target ${{ matrix.target }} --bin mumdia

# Everything the application expects to find beside itself. `binaries/` is
# where `engine.rs` and `components.rs` look, and `uv` is what installs the
# analysis components without conda.
- name: Stage the bundled binaries
shell: bash
run: |
set -euo pipefail
mkdir -p desktop/src-tauri/binaries
cp "rust/mumdia/target/${{ matrix.target }}/release/mumdia${{ matrix.engine_exe }}" \
desktop/src-tauri/binaries/
# uv publishes static binaries per platform; take the pinned release rather
# than whatever `latest` happens to be on the day of a build.
UV_VERSION=0.10.6
case "${{ runner.os }}" in
Linux) UV_ASSET="uv-x86_64-unknown-linux-gnu.tar.gz" ;;
Windows) UV_ASSET="uv-x86_64-pc-windows-msvc.zip" ;;
esac
curl -fsSL -o uv-asset \
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}"
case "$UV_ASSET" in
*.tar.gz) tar xzf uv-asset --strip-components=1 -C desktop/src-tauri/binaries ;;
*.zip) python -c "import sys,zipfile;zipfile.ZipFile('uv-asset').extractall('desktop/src-tauri/binaries')" ;;
esac
ls -la desktop/src-tauri/binaries

# The bundler that produces the .msi and .AppImage. Installed rather than
# vendored, and pinned, so a bundler change arrives as a reviewed version bump.
- name: Install the Tauri bundler
run: cargo install tauri-cli --version "^2" --locked

- name: Build the installer
working-directory: desktop
run: cargo tauri build

- name: Collect the installers
shell: bash
run: |
set -euo pipefail
mkdir -p desktop/dist
shopt -s nullglob
found=(${{ matrix.bundle_glob }})
shopt -u nullglob
if [ ${#found[@]} -eq 0 ]; then
echo "::error::the bundler produced no installer"
find desktop/target/release/bundle -maxdepth 2 -type f || true
exit 1
fi
for f in "${found[@]}"; do
cp "$f" desktop/dist/
done
cd desktop/dist
for f in *; do
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$f" > "$f.sha256"
else
shasum -a 256 "$f" > "$f.sha256"
fi
done
ls -la

- name: Upload to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
with:
files: |
desktop/dist/*
generate_release_notes: true

- name: Upload installers as a workflow artifact (rehearsal only)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-dryrun-${{ matrix.target }}
path: desktop/dist/*
if-no-files-found: error
retention-days: 7
73 changes: 73 additions & 0 deletions ci/check_desktop_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Check that the desktop frontend and its Rust backend agree.

The interface has no build step and no framework, which keeps Node out of the
release pipeline but also means nothing catches a typo in an element id or a command
name. Those are exactly the mistakes that survive review and fail on a user's
machine, so they are checked here instead:

- every `$("id")` the frontend looks up exists in `index.html`;
- every `invoke("name")` it calls is registered in `generate_handler!`;
- every registered command is called by something, so a command that lost its caller
is noticed rather than left as dead weight.

Usage:
python ci/check_desktop_ui.py
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
HTML = ROOT / "desktop" / "ui" / "index.html"
JS = ROOT / "desktop" / "ui" / "app.js"
MAIN = ROOT / "desktop" / "src-tauri" / "src" / "main.rs"


def main() -> int:
for f in (HTML, JS, MAIN):
if not f.is_file():
print(f"missing {f.relative_to(ROOT).as_posix()}", file=sys.stderr)
return 1

html = HTML.read_text(encoding="utf-8")
js = JS.read_text(encoding="utf-8")
rust = MAIN.read_text(encoding="utf-8")

problems: list[str] = []

ids = set(re.findall(r'id="([^"]+)"', html))
used = set(re.findall(r'\$\("([^"]+)"\)', js))
if missing := sorted(used - ids):
problems.append(f"element ids used by app.js but absent from index.html: {missing}")

called = set(re.findall(r'invoke\("([^"]+)"', js))
handler = re.search(r"generate_handler!\[(.*?)\]", rust, re.S)
if handler is None:
problems.append("no generate_handler! block found in main.rs")
registered: set[str] = set()
else:
registered = {x.strip() for x in handler.group(1).split(",") if x.strip()}

if unknown := sorted(called - registered):
problems.append(f"commands called by app.js but not registered in Rust: {unknown}")
if unused := sorted(registered - called):
problems.append(f"commands registered in Rust but never called: {unused}")

if problems:
for p in problems:
print(p, file=sys.stderr)
return 1

print(
f"desktop frontend ok: {len(ids)} element ids, "
f"{len(called)} commands, both directions agree."
)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading