From c06fb72a2345996c876edd73c0837269edda479e Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 14:18:34 -0700 Subject: [PATCH 01/13] Add Gemma 4 AI Studio provider for 1.2.5 --- .github/workflows/linux-release.yml | 5 +- .github/workflows/macos-release.yml | 2 +- .github/workflows/windows-release.yml | 3 +- README.md | 49 +++-- app.py | 276 +++++++++++++++++++++++--- backend_gemma.py | 19 +- backend_gemma_cloud.py | 161 +++++++++++++++ backend_mlx.py | 16 ++ packaging/build-deb.sh | 20 +- packaging/install-user.sh | 10 +- packaging/localtex-launcher | 72 +++++-- packaging/localtex.desktop | 2 +- packaging/macos/KevinTex.spec | 7 +- packaging/windows/kevintex.spec | 5 + provider_config.py | 118 +++++++++++ requirements-macos.txt | 1 + requirements-windows.txt | 1 + requirements.txt | 2 + run.sh | 2 +- snip.py | 3 +- static/index.html | 174 +++++++++++++++- test_backend_cleanup.py | 2 +- test_gemma_cloud.py | 130 ++++++++++++ 23 files changed, 990 insertions(+), 90 deletions(-) create mode 100644 backend_gemma_cloud.py create mode 100644 provider_config.py create mode 100644 test_gemma_cloud.py diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 3f118d3..cb136ff 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -40,7 +40,10 @@ jobs: run: | python3 -m pip install pillow fastapi python-multipart python3 -m unittest discover -v - python3 -m compileall -q app.py backend_gemma.py backend_mlx.py backend_vlm.py image_preprocessing.py + python3 -m compileall -q app.py backend_gemma.py backend_gemma_cloud.py backend_mlx.py backend_vlm.py provider_config.py image_preprocessing.py snip.py + bash -n packaging/build-deb.sh packaging/install-user.sh packaging/localtex-launcher run.sh + shellcheck packaging/build-deb.sh packaging/install-user.sh packaging/localtex-launcher packaging/macos/build.sh run.sh + sed -n '/ -KevinTex — Snip & Get, offline +KevinTex — Snip & Get @@ -342,6 +342,19 @@ .dlg-actions { display: flex; justify-content: flex-end; gap: 8px; } .confirm-danger { background: var(--danger); border-color: var(--danger); color: #fff; } .confirm-danger:hover { background: var(--danger-hover-fg); } + .provider-group[hidden] { display: none; } + .provider-status { margin: -2px 0 12px; font-size: 12px; color: var(--muted); } + .provider-controls { display: grid; gap: 8px; } + .provider-controls button { width: 100%; text-align: left; } + .provider-key { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 8px; } + .provider-key input { + width: 100%; min-width: 0; box-sizing: border-box; min-height: 40px; + padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; + background: var(--card); color: var(--text); font: inherit; + } + .provider-key input::placeholder { color: var(--muted); } + .provider-help { margin-top: 8px; font-size: 11px; color: var(--muted); } + .provider-help a { color: var(--accent); } .set-group { margin-bottom: 18px; } .set-group > label.title { display: block; font-size: 13px; font-weight: 600; margin-bottom: 8px; } .radio-row { display: flex; flex-wrap: wrap; gap: 6px 16px; } @@ -424,6 +437,17 @@ transition: width .4s ease; } .splash-sub { font-size: 12px; color: #8a94a8; min-height: 1em; } + .splash-setup { margin-top: 18px; text-align: left; } + .splash-setup[hidden] { display: none; } + .splash-setup > p { margin: 0 0 12px; color: #b9c3d6; font-size: 13px; line-height: 1.45; } + .setup-option { padding: 12px; border: 1px solid rgba(255,255,255,.15); border-radius: 10px; background: rgba(255,255,255,.05); } + .setup-option + .setup-option { margin-top: 9px; } + .setup-option button { width: 100%; padding: 9px 12px; border: 0; border-radius: 7px; cursor: pointer; background: #4b8bff; color: #fff; font-weight: 600; font-size: 13px; } + .setup-option button:hover { background: #6ea8ff; } + .setup-option small { display: block; margin-top: 7px; color: #9ca8bd; font-size: 11px; line-height: 1.4; } + .setup-option input { width: 100%; box-sizing: border-box; margin-bottom: 7px; padding: 9px 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 7px; background: rgba(0,0,0,.22); color: #fff; font: inherit; } + .setup-option input::placeholder { color: #8a94a8; } + .setup-link { display: inline-block; margin-top: 10px; color: #8eb8ff; font-size: 11px; } .splash-retry { margin-top: 16px; padding: 8px 18px; border-radius: 99px; cursor: pointer; background: #4b8bff; color: #fff; border: none; font-weight: 600; font-size: 13px; @@ -435,11 +459,24 @@
- +
KevinTex
Starting up…
+
@@ -541,7 +578,7 @@

KevinTex — Snip & Get
☁︎
Drag, paste (Ctrl+V) a formula image, or click here to browse - PNG / JPG / BMP / WEBP — runs 100% on this machine, free & unlimited + PNG / JPG / BMP / WEBP — local mode runs 100% on this machine
+
@@ -675,6 +724,7 @@

Clear conversion history?

processedImage: null, sourceView: "original", recognizing: false, + backend: null, settings: Object.assign({}, defaultSettings, loadLocalJson("localtex-settings", defaultSettings)), history: loadLocalJson("localtex-history", []), historyQuery: "", @@ -905,8 +955,9 @@

Clear conversion history?

state.latex = latex; $("#latex-out").value = latex; const mode = preprocessing?.mode; + const runtime = state.backend === "gemma-cloud" ? "via AI Studio" : "on-device"; $("#elapsed").textContent = elapsed != null - ? `${elapsed}s on-device${mode ? ` · sharpener ${mode}` : ""}` + ? `${elapsed}s ${runtime}${mode ? ` · sharpener ${mode}` : ""}` : ""; renderPreview(); } @@ -1508,6 +1559,82 @@

Clear conversion history?

// Settings dialog const dlg = $("#settings-dlg"); +const providerSettings = $("#provider-settings"); +const providerStatus = $("#provider-status"); +let providerSwitching = false; + +function describeProvider(p) { + if (!p || !p.supported) return "Provider selection is unavailable for this backend."; + if (p.mode === "cloud") { + return p.api_key_configured + ? `Google AI Studio API · ${p.cloud_model} · key ${p.api_key_hint}` + : "Google AI Studio API · API key required"; + } + if (p.mode === "local") { + return p.local_model_available + ? "Local Gemma 4 weights · already downloaded" + : "Local Gemma 4 weights · downloads on first use"; + } + return "Choose local weights or Google AI Studio"; +} + +async function refreshProviderSettings() { + try { + const response = await fetch("/api/provider"); + if (!response.ok) return; + const provider = await response.json(); + providerSettings.hidden = !provider.supported; + providerStatus.textContent = describeProvider(provider); + } catch {} +} + +async function chooseProvider(mode, apiKey = "") { + if (providerSwitching) return; + providerSwitching = true; + const payload = { mode }; + if (apiKey.trim()) payload.api_key = apiKey.trim(); + const localButton = $("#settings-local"); + const cloudButton = $("#settings-cloud"); + const setupLocal = $("#setup-local"); + const setupCloud = $("#setup-cloud"); + [localButton, cloudButton, setupLocal, setupCloud].forEach((button) => { if (button) button.disabled = true; }); + try { + const response = await fetch("/api/provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + if (dlg.open) dlg.close(); + _ready = false; + splashSetup.hidden = true; + $("#splash-spinner").hidden = false; + splashRetry.hidden = true; + showSplash(mode === "cloud" ? "Starting cloud model…" : "Preparing local model…"); + await pollStatus(); + startStatusPolling(); + } catch (error) { + toast(error.message || "Could not change provider"); + } finally { + providerSwitching = false; + [localButton, cloudButton, setupLocal, setupCloud].forEach((button) => { if (button) button.disabled = false; }); + $("#setup-api-key").value = ""; + $("#settings-api-key").value = ""; + refreshProviderSettings(); + } +} + +$("#setup-local").addEventListener("click", () => chooseProvider("local")); +$("#setup-cloud").addEventListener("click", () => chooseProvider("cloud", $("#setup-api-key").value)); +$("#settings-local").addEventListener("click", () => chooseProvider("local")); +$("#settings-cloud").addEventListener("click", () => chooseProvider("cloud", $("#settings-api-key").value)); +$("#setup-api-key").addEventListener("keydown", (event) => { + if (event.key === "Enter") chooseProvider("cloud", event.currentTarget.value); +}); +$("#settings-api-key").addEventListener("keydown", (event) => { + if (event.key === "Enter") chooseProvider("cloud", event.currentTarget.value); +}); applyTheme(); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if ((state.settings.theme || "system") === "system") applyTheme(); @@ -1524,6 +1651,7 @@

Clear conversion history?

dlg.querySelector(`input[name=autocopy][value="${state.settings.autocopy}"]`).checked = true; dlg.querySelector(`input[name=theme][value="${state.settings.theme}"]`).checked = true; dlg.showModal(); + refreshProviderSettings(); }); $("#dlg-close").addEventListener("click", () => dlg.close()); dlg.addEventListener("click", (e) => { if (e.target === dlg) dlg.close(); }); @@ -1544,6 +1672,8 @@

Clear conversion history?

const splashMsg = $("#splash-message"); const splashBar = $("#splash-bar"); const splashSub = $("#splash-sub"); +const splashSpinner = $("#splash-spinner"); +const splashSetup = $("#splash-setup"); const splashRetry = $("#splash-retry"); let _ready = false; let _statusTimer = null; @@ -1552,20 +1682,38 @@

Clear conversion history?

function hideSplash() { splash.classList.add("hidden"); } function fmtBackendLabel(s) { - return s === "pix2tex" ? "pix2tex" : (s === "lfm-vl" ? "LFM2.5-VL" : (s === "mlx" ? "Gemma 4 E2B · MLX" : "Gemma 4 E2B")); + return s === "pix2tex" ? "pix2tex" : (s === "lfm-vl" ? "LFM2.5-VL" : (s === "mlx" ? "Gemma 4 E2B · MLX" : (s === "gemma-cloud" ? "Gemma 4 · AI Studio" : "Gemma 4 E2B"))); } function applyStatus(s) { if (!s) return; + state.backend = s.backend || null; $("#voice-btn").hidden = !s.audio_supported; - const dev = s.device === "cuda" ? "GPU" : (s.device === "metal" ? "Apple Silicon" : "CPU"); + const dev = s.device === "cuda" ? "GPU" : (s.device === "metal" ? "Apple Silicon" : (s.device === "cloud" ? "Google AI Studio" : "CPU")); $("#device-badge").textContent = `${fmtBackendLabel(s.backend)} · ${dev}`; + document.title = s.backend === "gemma-cloud" ? "KevinTex — Snip & Get, AI Studio" : "KevinTex — Snip & Get"; + $("#drop-help").textContent = s.backend === "gemma-cloud" + ? "PNG / JPG / BMP / WEBP — images are sent to Google AI Studio" + : "PNG / JPG / BMP / WEBP — local mode runs 100% on this machine"; + if (s.phase === "setup" || s.setup_required) { + _ready = false; + showSplash("Choose how to run Gemma 4"); + splashSpinner.hidden = true; + splashSetup.hidden = false; + splashRetry.hidden = true; + splashBar.style.width = "0%"; + splashSub.textContent = "This choice can be changed later in Settings"; + return; + } + splashSpinner.hidden = false; + splashSetup.hidden = true; if (s.phase === "ready") { _ready = true; hideSplash(); return; } if (s.phase === "error") { splashMsg.textContent = "Model failed to load"; splashSub.textContent = s.error || ""; splashBar.style.width = "0%"; splashRetry.hidden = false; + splashSetup.hidden = !s.provider?.supported; return; } showSplash(); @@ -1573,14 +1721,18 @@

Clear conversion history?

splashBar.style.width = (s.progress || 0) + "%"; splashSub.textContent = s.phase === "downloading" ? "One-time setup — this won't repeat on future launches" - : ((s.backend === "gemma" || s.backend === "mlx") ? "Gemma 4 E2B-it · " + dev : dev); + : (s.backend === "gemma-cloud" ? "Hosted Gemma 4 · images are sent to Google" : ((s.backend === "gemma" || s.backend === "mlx") ? "Gemma 4 E2B-it · " + dev : dev)); splashRetry.hidden = true; } async function pollStatus() { try { const r = await fetch("/api/status"); - if (r.ok) { const s = await r.json(); applyStatus(s); if (s.phase === "ready") return true; } + if (r.ok) { + const s = await r.json(); + applyStatus(s); + if (s.phase === "ready" || s.phase === "error") return true; + } } catch {} return false; } @@ -1596,8 +1748,10 @@

Clear conversion history?

async function ensureReady() { if (_ready) return true; showSplash("Warming up…"); - while (!await pollStatus()) { await new Promise((r) => setTimeout(r, 1000)); } - return _ready; + while (true) { + if (await pollStatus()) return _ready; + await new Promise((r) => setTimeout(r, 1000)); + } } splashRetry.addEventListener("click", () => { diff --git a/test_backend_cleanup.py b/test_backend_cleanup.py index 250246f..f75755b 100644 --- a/test_backend_cleanup.py +++ b/test_backend_cleanup.py @@ -2,7 +2,7 @@ import unittest -from backend_vlm import _extract_markdown, _strip_meta_junk, _to_markdown_math +from backend_vlm import _extract_markdown, _to_markdown_math class BackendCleanupTests(unittest.TestCase): diff --git a/test_gemma_cloud.py b/test_gemma_cloud.py new file mode 100644 index 0000000..a2db28d --- /dev/null +++ b/test_gemma_cloud.py @@ -0,0 +1,130 @@ +"""Tests for the hosted Gemma provider and provider selection state.""" + +import asyncio +import os +import stat +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from PIL import Image + +import app +import backend_gemma_cloud +import provider_config + + +class ProviderConfigTests(unittest.TestCase): + def test_api_key_is_persisted_without_being_returned_by_provider_state(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "provider.json" + with patch.dict(os.environ, {"LOCALTEX_PROVIDER_CONFIG": str(config)}, clear=False): + provider_config.save_config("cloud", "AIza-test-key") + saved = provider_config.read_config() + self.assertEqual(saved["mode"], "cloud") + self.assertEqual(saved["api_key"], "AIza-test-key") + self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) + + with patch.object(app, "BACKEND", "gemma"): + state = app._provider_state() + self.assertTrue(state["api_key_configured"]) + self.assertNotIn("api_key", state) + + def test_first_run_requires_a_provider(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "provider.json" + with patch.dict( + os.environ, + {"LOCALTEX_PROVIDER_CONFIG": str(config), "GEMINI_API_KEY": ""}, + clear=False, + ), patch.object(app, "BACKEND", "gemma"): + self.assertTrue(app._setup_required()) + self.assertEqual(app.status()["phase"], "setup") + + def test_provider_switch_is_rejected_during_inference(self): + class Request: + async def json(self): + return {"mode": "local"} + + acquired = [] + try: + for _ in range(app.INFERENCE_CONCURRENCY): + self.assertTrue(app._inference_slots.acquire(blocking=False)) + acquired.append(True) + with patch.object(app, "BACKEND", "gemma"): + response = asyncio.run(app.configure_provider(Request())) + self.assertEqual(response.status_code, 409) + self.assertIn("recognition", response.body.decode()) + finally: + for _ in acquired: + app._inference_slots.release() + + def test_backend_cleanup_calls_close(self): + class Backend: + closed = False + + def close(self): + self.closed = True + + backend = Backend() + app._dispose_model(backend) + self.assertTrue(backend.closed) + + +class CloudBackendTests(unittest.TestCase): + def test_image_request_uses_gemma_api_and_keeps_image_before_prompt(self): + calls = {} + + class FakePart: + @classmethod + def from_bytes(cls, **kwargs): + calls["image"] = kwargs + return "image-part" + + class FakeThinkingConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeGenerateContentConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + Types = type( + "Types", + (), + { + "Part": FakePart, + "ThinkingConfig": FakeThinkingConfig, + "GenerateContentConfig": FakeGenerateContentConfig, + }, + ) + + class Models: + def generate_content(self, **kwargs): + calls["request"] = kwargs + return type("Response", (), {"text": "LATEX: $x^2$"})() + + backend = backend_gemma_cloud.GemmaCloudBackend.__new__( + backend_gemma_cloud.GemmaCloudBackend + ) + backend.types = Types + backend.client = type("Client", (), {"models": Models()})() + backend.default_thinking = False + import threading + + backend._inference_lock = threading.Lock() + + image = Image.new("RGB", (3, 3), "white") + try: + self.assertEqual(backend.recognize(image), "$x^2$") + finally: + image.close() + + self.assertEqual(calls["request"]["model"], "gemma-4-26b-a4b-it") + self.assertEqual(calls["request"]["contents"], ["image-part", backend_gemma_cloud.PROMPT]) + self.assertEqual(calls["image"]["mime_type"], "image/png") + + +if __name__ == "__main__": + unittest.main() From 09a8a1d44351c225063617ece58ca5e45d23eb2a Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 14:23:52 -0700 Subject: [PATCH 02/13] Make release tests Windows-safe --- test_gemma_cloud.py | 3 ++- test_performance_safety.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test_gemma_cloud.py b/test_gemma_cloud.py index a2db28d..0deadb7 100644 --- a/test_gemma_cloud.py +++ b/test_gemma_cloud.py @@ -24,7 +24,8 @@ def test_api_key_is_persisted_without_being_returned_by_provider_state(self): saved = provider_config.read_config() self.assertEqual(saved["mode"], "cloud") self.assertEqual(saved["api_key"], "AIza-test-key") - self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) + if os.name != "nt": + self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) with patch.object(app, "BACKEND", "gemma"): state = app._provider_state() diff --git a/test_performance_safety.py b/test_performance_safety.py index 0080f60..f90ac78 100644 --- a/test_performance_safety.py +++ b/test_performance_safety.py @@ -2,6 +2,8 @@ import asyncio import io +import os +import tempfile import threading import time import unittest @@ -104,11 +106,13 @@ def create_chat_completion(self, **kwargs): backend.default_thinking = False backend._inference_lock = threading.Lock() backend.llm = FakeLlama() - import tempfile - with tempfile.NamedTemporaryFile(suffix=".wav") as audio: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as audio: audio.write(b"RIFF" + b"\0" * 40) - audio.flush() - self.assertEqual(backend.recognize_audio(audio.name), "$x^2$") + audio_path = audio.name + try: + self.assertEqual(backend.recognize_audio(audio_path), "$x^2$") + finally: + os.unlink(audio_path) media = backend.llm.kwargs["messages"][0]["content"][0] self.assertEqual(media["type"], "image_url") self.assertTrue(media["image_url"]["url"].startswith("data:audio/wav;base64,")) From 938f3b2324b60476aebda444c20cb71cc689bd38 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 19:37:15 -0700 Subject: [PATCH 03/13] Fix macOS snipping and MLX downloads --- backend_mlx.py | 73 +++++++++++++++++++++++++----- packaging/macos/desktop_macos.py | 4 ++ snip.py | 77 ++++++++++++++++++++++++++++---- test_mlx_download.py | 45 +++++++++++++++++++ test_snip.py | 45 +++++++++++++++++++ 5 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 test_mlx_download.py create mode 100644 test_snip.py diff --git a/backend_mlx.py b/backend_mlx.py index 7a8fc16..fd6df9a 100644 --- a/backend_mlx.py +++ b/backend_mlx.py @@ -13,6 +13,13 @@ import time from pathlib import Path +# huggingface_hub reads these once, at import time. The bundled macOS app uses +# the stable HTTPS downloader because hf-xet CAS credentials can expire with a +# 401 in long, multi-gigabyte public-model downloads. Explicit user overrides +# still win. +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") +os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300") + from backend_vlm import AUDIO_PROMPT, PROMPT, THINKING_PROMPT, _extract_markdown log = logging.getLogger("kevintex") @@ -27,6 +34,14 @@ _LEGACY_MODEL_DIRS = ( "gemma-4-e2b-it-mlx-4bit", ) +_DOWNLOAD_PATTERNS = [ + "*.json", + "*.safetensors", + "*.model", + "*.txt", + "*.jinja", + "optiq/*", +] def _default_models_dir() -> Path: @@ -165,6 +180,51 @@ def model_files_present() -> bool: return _model_files_ready() +def _is_xet_authorization_error(exc: Exception) -> bool: + message = str(exc).lower() + return "401" in message and any( + marker in message for marker in ("xethub", "xet-bridge", "cas-bridge") + ) + + +def _friendly_download_error(exc: Exception) -> RuntimeError: + if _is_xet_authorization_error(exc): + return RuntimeError( + "Hugging Face rejected a temporary model-download credential (401). " + "KevinTex retried using the standard HTTPS downloader. Verify that " + "macOS Date & Time is set automatically, then relaunch; the partial " + "download is preserved and will resume. If it still fails, check that " + "your VPN or firewall allows huggingface.co." + ) + return RuntimeError(f"Could not download local MLX model {MODEL_ID}: {exc}") + + +def _download_model_snapshot(snapshot_download) -> None: + """Download the selected public model, refreshing a failed signed URL once.""" + for attempt in range(2): + try: + snapshot_download( + repo_id=MODEL_ID, + local_dir=MODEL_DIR, + allow_patterns=_DOWNLOAD_PATTERNS, + max_workers=4, + etag_timeout=30, + ) + return + except Exception as exc: + if not _is_xet_authorization_error(exc) or attempt == 1: + raise _friendly_download_error(exc) from exc + log.warning( + "Hugging Face Xet authorization failed; refreshing download URLs" + ) + set_status( + "downloading", + min(99, int(_download_size() / EXPECTED_BYTES * 100)), + "Refreshing Hugging Face download credentials…", + ) + time.sleep(1.0) + + def _ensure_model() -> Path: _purge_legacy_models() if _model_files_ready(): @@ -187,18 +247,7 @@ def monitor() -> None: try: from huggingface_hub import snapshot_download - snapshot_download( - repo_id=MODEL_ID, - local_dir=MODEL_DIR, - allow_patterns=[ - "*.json", - "*.safetensors", - "*.model", - "*.txt", - "*.jinja", - "optiq/*", - ], - ) + _download_model_snapshot(snapshot_download) finally: stop.set() thread.join(timeout=2) diff --git a/packaging/macos/desktop_macos.py b/packaging/macos/desktop_macos.py index baf1741..b16b7f4 100644 --- a/packaging/macos/desktop_macos.py +++ b/packaging/macos/desktop_macos.py @@ -24,6 +24,8 @@ def _configure_runtime() -> Path: os.environ.setdefault("LOCALTEX_DATA_DIR", str(data_dir)) os.environ.setdefault("LOCALTEX_MODELS_DIR", str(models_dir)) os.environ.setdefault("HF_HOME", str(cache_dir / "huggingface")) + os.environ.setdefault("HF_HUB_DISABLE_XET", "1") + os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300") os.environ["LOCALTEX_BACKEND"] = "mlx" return data_dir @@ -67,6 +69,8 @@ def _smoke_test() -> int: raise FileNotFoundError(f"Bundled frontend is missing: {index}") if app.BACKEND != "mlx": raise RuntimeError("macOS MLX backend was not selected") + if os.environ.get("HF_HUB_DISABLE_XET") != "1": + raise RuntimeError("macOS builds must use the stable Hugging Face downloader") from mlx_vlm import load as mlx_load # noqa: F401 from mlx_vlm.prompt_utils import apply_chat_template # noqa: F401 diff --git a/snip.py b/snip.py index 29f7927..3e88c10 100644 --- a/snip.py +++ b/snip.py @@ -11,12 +11,60 @@ Usage: snip.py """ +import math import sys -from PIL import ImageGrab, ImageTk +from PIL import Image, ImageGrab, ImageTk import tkinter as tk +def _display_size( + capture_size: tuple[int, int], + screen_size: tuple[int, int], + platform: str | None = None, +) -> tuple[int, int]: + """Choose the logical overlay size for a pixel-sized screen capture. + + macOS ImageGrab returns Retina pixels while Tk reports screen dimensions in + logical points. Showing the capture at its raw pixel dimensions therefore + zooms a 2x Retina image so that only its upper-left quadrant is visible. + """ + platform = sys.platform if platform is None else platform + screen_w, screen_h = screen_size + if platform == "darwin" and screen_w > 0 and screen_h > 0: + return screen_w, screen_h + return capture_size + + +def _capture_crop_box( + start: tuple[int, int], + end: tuple[int, int], + display_size: tuple[int, int], + capture_size: tuple[int, int], +) -> tuple[int, int, int, int]: + """Map a selection in logical display coordinates to capture pixels.""" + display_w, display_h = display_size + capture_w, capture_h = capture_size + if min(display_w, display_h, capture_w, capture_h) <= 0: + raise ValueError("screen dimensions must be positive") + + left, right = sorted((start[0], end[0])) + top, bottom = sorted((start[1], end[1])) + left = max(0, min(display_w, left)) + right = max(0, min(display_w, right)) + top = max(0, min(display_h, top)) + bottom = max(0, min(display_h, bottom)) + + scale_x = capture_w / display_w + scale_y = capture_h / display_h + return ( + max(0, math.floor(left * scale_x)), + max(0, math.floor(top * scale_y)), + min(capture_w, math.ceil(right * scale_x)), + min(capture_h, math.ceil(bottom * scale_y)), + ) + + def main(): if len(sys.argv) < 2: print("ERROR: missing output path", file=sys.stderr) @@ -29,16 +77,23 @@ def main(): print(f"ERROR: screen grab failed: {e}", file=sys.stderr) sys.exit(1) - w, h = full.size - root = tk.Tk() + screen_size = (root.winfo_screenwidth(), root.winfo_screenheight()) + display_size = _display_size(full.size, screen_size) + display_w, display_h = display_size + root.attributes("-fullscreen", True) root.configure(bg="black", cursor="crosshair") - root.geometry(f"{w}x{h}+0+0") - - canvas = tk.Canvas(root, width=w, height=h, highlightthickness=0) - canvas.pack() - bg = ImageTk.PhotoImage(full) + root.geometry(f"{display_w}x{display_h}+0+0") + + canvas = tk.Canvas( + root, width=display_w, height=display_h, highlightthickness=0 + ) + canvas.pack(fill=tk.BOTH, expand=True) + overlay = full + if full.size != display_size: + overlay = full.resize(display_size, Image.Resampling.LANCZOS) + bg = ImageTk.PhotoImage(overlay, master=canvas) canvas.create_image(0, 0, anchor="nw", image=bg) state = {"start": None, "rect": None, "done": False} @@ -69,8 +124,12 @@ def on_release(e): print("ERROR: region too small", file=sys.stderr) sys.exit(1) - crop = full.crop((left, top, right, bottom)) + crop_box = _capture_crop_box( + (left, top), (right, bottom), display_size, full.size + ) + crop = full.crop(crop_box) crop.save(out, "PNG") + crop.close() print(out) def on_esc(_e): diff --git a/test_mlx_download.py b/test_mlx_download.py new file mode 100644 index 0000000..e695824 --- /dev/null +++ b/test_mlx_download.py @@ -0,0 +1,45 @@ +"""Regression tests for resilient macOS MLX model downloads.""" + +import os +import unittest +from unittest.mock import Mock, patch + +import backend_mlx + + +class MLXDownloadTests(unittest.TestCase): + def test_xet_is_disabled_before_huggingface_import(self): + self.assertEqual(os.environ.get("HF_HUB_DISABLE_XET"), "1") + + def test_xet_401_refreshes_download_once(self): + download = Mock( + side_effect=[ + RuntimeError("401 Unauthorized from cas-bridge.xethub.hf.co"), + "/tmp/model", + ] + ) + with patch.object(backend_mlx.time, "sleep") as sleep: + backend_mlx._download_model_snapshot(download) + + self.assertEqual(download.call_count, 2) + sleep.assert_called_once_with(1.0) + for call in download.call_args_list: + self.assertEqual(call.kwargs["max_workers"], 4) + self.assertEqual(call.kwargs["etag_timeout"], 30) + + def test_persistent_xet_401_has_short_actionable_error(self): + download = Mock( + side_effect=RuntimeError( + "401 Unauthorized https://cas-bridge.xethub.hf.co/very-long-url" + ) + ) + with patch.object(backend_mlx.time, "sleep"): + with self.assertRaisesRegex(RuntimeError, "Date & Time") as raised: + backend_mlx._download_model_snapshot(download) + + self.assertEqual(download.call_count, 2) + self.assertNotIn("https://", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_snip.py b/test_snip.py new file mode 100644 index 0000000..d506573 --- /dev/null +++ b/test_snip.py @@ -0,0 +1,45 @@ +"""Screen-coordinate tests for the cross-platform snip overlay.""" + +import unittest + +from snip import _capture_crop_box, _display_size + + +class SnipCoordinateTests(unittest.TestCase): + def test_retina_capture_uses_logical_macos_screen_size(self): + self.assertEqual( + _display_size((3024, 1964), (1512, 982), platform="darwin"), + (1512, 982), + ) + + def test_retina_selection_maps_back_to_capture_pixels(self): + self.assertEqual( + _capture_crop_box( + (100, 50), + (300, 150), + display_size=(1512, 982), + capture_size=(3024, 1964), + ), + (200, 100, 600, 300), + ) + + def test_crop_coordinates_are_ordered_and_clamped(self): + self.assertEqual( + _capture_crop_box( + (900, 700), + (-10, -20), + display_size=(800, 600), + capture_size=(1600, 1200), + ), + (0, 0, 1600, 1200), + ) + + def test_non_macos_keeps_capture_dimensions(self): + self.assertEqual( + _display_size((1920, 1080), (1920, 1080), platform="linux"), + (1920, 1080), + ) + + +if __name__ == "__main__": + unittest.main() From dd4a538d2dd080df8be0456e3f63420eb0e11497 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 19:41:12 -0700 Subject: [PATCH 04/13] Add cross-platform acceleration for 1.2.6 --- .github/workflows/windows-release.yml | 26 +++- README.md | 41 ++++- app.py | 3 + backend_gemma.py | 10 +- desktop.py | 51 +++++-- packaging/build-deb.sh | 5 +- packaging/localtex-launcher | 173 +++++++++++++++++++--- packaging/windows/README.md | 21 ++- packaging/windows/build-llama-backend.ps1 | 73 +++++++++ packaging/windows/kevintex.spec | 6 +- requirements.txt | 2 + static/index.html | 11 +- test_desktop_acceleration.py | 54 +++++++ test_image_preprocessing.py | 10 +- test_performance_safety.py | 28 +++- 15 files changed, 448 insertions(+), 66 deletions(-) create mode 100644 packaging/windows/build-llama-backend.ps1 create mode 100644 test_desktop_acceleration.py diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index f72290f..60ba123 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -15,6 +15,7 @@ on: options: - cpu - cuda + - vulkan permissions: contents: write @@ -71,17 +72,26 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements-windows.txt - - name: Replace runtime with CUDA wheels - if: env.ACCELERATION == 'cuda' + - name: Install Vulkan SDK + if: env.ACCELERATION == 'vulkan' run: | - python -m pip uninstall -y llama-cpp-python torch - python -m pip install torch --index-url https://download.pytorch.org/whl/cu124 - python -m pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 - Set-Content -Path packaging/windows/cuda_enabled.txt -Value "CUDA build" + choco install vulkan-sdk -y --no-progress + $sdk = Get-ChildItem "C:\VulkanSDK" -Directory | Sort-Object Name -Descending | Select-Object -First 1 + if (-not $sdk) { throw "Vulkan SDK installation was not found." } + "VULKAN_SDK=$($sdk.FullName)" >> $env:GITHUB_ENV + "$($sdk.FullName)\Bin" >> $env:GITHUB_PATH + + - name: Select llama.cpp acceleration backend + run: | + & ./packaging/windows/build-llama-backend.ps1 -Acceleration $env:ACCELERATION - name: Run source checks run: | python -m py_compile desktop.py packaging/windows/generate_icon.py packaging/windows/generate_version_info.py + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile("packaging/windows/build-llama-backend.ps1", [ref]$tokens, [ref]$parseErrors) + if ($parseErrors) { throw ($parseErrors | Out-String) } python -c "import ast, pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in ('app.py', 'backend_gemma.py', 'backend_gemma_cloud.py', 'backend_mlx.py', 'backend_vlm.py', 'provider_config.py', 'image_preprocessing.py', 'snip.py')]" python -m unittest discover -v python packaging/windows/generate_version_info.py $env:RELEASE_TAG @@ -103,7 +113,7 @@ jobs: - name: Create versioned archive run: | - $suffix = if ($env:ACCELERATION -eq "cuda") { "-cuda" } else { "" } + $suffix = if ($env:ACCELERATION -eq "cpu") { "" } else { "-$env:ACCELERATION" } if ($env:ACCELERATION -eq "cuda") { # GitHub release assets are capped below 2 GiB. The CUDA runtime is # large, so publish standard 7-Zip multi-volume files that extract @@ -113,7 +123,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "7-Zip packaging failed." } "ARCHIVE_GLOB=$archive.*" >> $env:GITHUB_ENV } else { - $archive = "KevinTex-$env:VERSION-windows-x64.zip" + $archive = "KevinTex-$env:VERSION-windows-x64$suffix.zip" Compress-Archive -Path "dist/KevinTex/*" -DestinationPath $archive "ARCHIVE_GLOB=$archive" >> $env:GITHUB_ENV } diff --git a/README.md b/README.md index bb27b5e..50abdad 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ API key. - **Recognize again** using the current source image and active Thinking setting - **Download `.tex`** output and keyboard shortcuts (`Ctrl/Cmd+K`, `Ctrl/Cmd+S`) - **Native desktop builds** for Ubuntu, Windows, and Apple Silicon macOS -- **Hardware acceleration** with CUDA on Linux/Windows and MLX/Metal on Apple Silicon +- **Hardware acceleration** with NVIDIA CUDA, AMD ROCm/HIP, Intel/AMD + Vulkan, Intel SYCL, and Apple MLX/Metal; optimized CPU builds remain available - **Switchable backend** — Gemma is the default; `LOCALTEX_BACKEND=lfm-vl` or `LOCALTEX_BACKEND=pix2tex` selects an alternative local backend. The macOS application selects `LOCALTEX_BACKEND=mlx` automatically. @@ -52,9 +53,11 @@ API key. Prebuilt packages are attached to each [GitHub release](https://github.com/EV3KevinDEV/KevinTex/releases): -- **Ubuntu:** `kevintex_1.2.5_all.deb` -- **Windows:** portable `KevinTex-1.2.5-windows-x64.zip` containing `KevinTex.exe` -- **Apple Silicon macOS:** `KevinTex-1.2.5-macOS-arm64.dmg` or `.zip` +- **Ubuntu:** `kevintex_1.2.6_all.deb` +- **Windows:** portable `KevinTex-1.2.6-windows-x64.zip` containing `KevinTex.exe` +- **Windows GPU:** `KevinTex-1.2.6-windows-x64-cuda.7z.001` or + `KevinTex-1.2.6-windows-x64-vulkan.zip` +- **Apple Silicon macOS:** `KevinTex-1.2.6-macOS-arm64.dmg` or `.zip` The Windows and macOS applications open in a native window. Local model weights are not bundled; if you choose the local provider, they download to the current @@ -69,7 +72,7 @@ Two options: ```bash packaging/build-deb.sh -sudo apt install ./dist/kevintex_1.2.5_all.deb +sudo apt install ./dist/kevintex_1.2.6_all.deb ``` **Current user only (no root):** @@ -86,6 +89,32 @@ setup screen. The local choice downloads PyTorch/llama.cpp model weights; the AI Studio choice asks for a key from and does not download model weights. +### Linux and Windows hardware support + +| Hardware | Recommended backend | Linux | Windows | +| --- | --- | --- | --- | +| Intel or AMD CPU | CPU | Automatic fallback | Standard ZIP | +| NVIDIA GPU | CUDA 12.4 | Automatic when the driver is detected | CUDA archive | +| AMD GPU | ROCm/HIP | Automatic when ROCm is installed | Local HIP SDK build | +| Intel or AMD GPU | Vulkan | Automatic with Vulkan build tools | Vulkan ZIP | +| Intel GPU | SYCL/oneAPI | Explicit local build | Explicit local build | + +The Linux launcher auto-detects a usable backend and stores the choice under +`~/.local/share/localtex/acceleration`. Override it for the next launch with: + +```bash +KEVINTEX_ACCELERATION=cpu kevintex +KEVINTEX_ACCELERATION=rocm kevintex +KEVINTEX_ACCELERATION=vulkan kevintex +KEVINTEX_ACCELERATION=sycl kevintex +``` + +ROCm requires the AMD ROCm SDK and access to `/dev/kfd`. Vulkan source builds +need `build-essential cmake libvulkan-dev glslc spirv-headers`. SYCL requires +Intel oneAPI (`icx` and `icpx`). Changing the override rebuilds only the +llama.cpp Python backend; downloaded model weights remain untouched. Hardware +and driver support still follows each vendor's compatibility list. + The app window's **Snip** button (computer-with-+ icon) opens a fullscreen region selector: drag a box around a formula and it's converted immediately — the LaTeX lands in your clipboard if auto-copy is on. No external screenshot @@ -146,6 +175,8 @@ The following optional environment variables tune local inference: - `LOCALTEX_N_GPU_LAYERS` — llama.cpp GPU offload (`-1` means all) - `LOCALTEX_N_BATCH` — llama.cpp prompt batch size - `LOCALTEX_N_THREADS` — CPU inference threads +- `KEVINTEX_ACCELERATION=auto|cpu|cuda|rocm|vulkan|sycl` — select the Linux + llama.cpp runtime (`auto` is the default) - `LOCALTEX_MAX_UPLOAD_BYTES` — upload cap (default 20 MiB) - `LOCALTEX_INFERENCE_QUEUE_TIMEOUT` — wait before returning busy - `LOCALTEX_PROVIDER=local|cloud` — optionally select the Gemma provider before startup diff --git a/app.py b/app.py index 1d87e72..296be10 100644 --- a/app.py +++ b/app.py @@ -278,6 +278,9 @@ def _device_label() -> str: return "cloud" if active_backend == "mlx": return "metal" + acceleration = os.environ.get("LOCALTEX_ACCELERATION", "").strip().lower() + if acceleration in {"cpu", "cuda", "rocm", "vulkan", "sycl"}: + return acceleration try: import torch diff --git a/backend_gemma.py b/backend_gemma.py index e1c153b..a28dd8a 100644 --- a/backend_gemma.py +++ b/backend_gemma.py @@ -5,7 +5,7 @@ offload. Outputs the same Mathpix/SimpleTex-style Markdown+math as the LFM backend (prose as plain text, inline math in `$...$`, display math in `$$...$$`) and reuses the exact prompt text and `_to_markdown_math()` cleanup from -`backend_vlm`. Runs entirely on-device (GPU). No cloud. +`backend_vlm`. Runs entirely on-device using the selected CPU/GPU backend. """ from __future__ import annotations @@ -223,8 +223,12 @@ def __init__(self, thinking: bool | None = None): self.mmproj_path = mmproj_path t0 = time.time() - set_status("loading", 0, "Loading model onto GPU…") - log.info("Loading Gemma 4 E2B-it (Q4_K_M + mmproj) via llama.cpp…") + acceleration = os.environ.get("LOCALTEX_ACCELERATION", "GPU").upper() + set_status("loading", 0, f"Loading model with {acceleration}…") + log.info( + "Loading Gemma 4 E2B-it (Q4_K_M + mmproj) via llama.cpp (%s)…", + acceleration, + ) handler = Gemma4ChatHandler( clip_model_path=mmproj_path, verbose=False, diff --git a/desktop.py b/desktop.py index c0b082e..3003da2 100644 --- a/desktop.py +++ b/desktop.py @@ -38,25 +38,51 @@ def _configure_user_paths() -> Path: return data_dir -def _configure_llama_backend() -> None: - """Make the shipped build CPU-safe while allowing custom CUDA builds.""" - bundled_cuda_marker = ( - Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) / "cuda_enabled.txt" +def _bundled_acceleration() -> str: + """Return the packaged or explicitly requested llama.cpp backend.""" + bundle_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) + marker = bundle_dir / "acceleration.txt" + if marker.is_file(): + try: + normalized = marker.read_text(encoding="utf-8").strip().lower() + except (OSError, UnicodeError): + normalized = "" + if normalized in {"cpu", "cuda", "rocm", "vulkan", "sycl"}: + return normalized + + # Compatibility with the 1.2.5 CUDA package. A bundled marker must win over + # ambient environment variables so its native DLLs and offload mode cannot + # be mismatched accidentally. + if (bundle_dir / "cuda_enabled.txt").is_file(): + return "cuda" + + requested = os.environ.get("KEVINTEX_ACCELERATION") or os.environ.get( + "LOCALTEX_ACCELERATION" ) - cuda_requested = bundled_cuda_marker.is_file() or os.environ.get( - "KEVINTEX_CUDA", "" - ).lower() in { - "1", - "true", - "yes", - } - if cuda_requested: + if requested: + normalized = requested.strip().lower() + if normalized in {"cpu", "cuda", "rocm", "vulkan", "sycl"}: + return normalized + if os.environ.get("KEVINTEX_CUDA", "").lower() in {"1", "true", "yes"}: + return "cuda" + return "cpu" + + +def _configure_llama_backend() -> None: + """Configure GPU offload to match the native llama.cpp library.""" + acceleration = _bundled_acceleration() + os.environ["LOCALTEX_ACCELERATION"] = acceleration + if acceleration != "cpu": return + os.environ.setdefault("LOCALTEX_N_GPU_LAYERS", "0") + import backend_gemma import llama_cpp.llama_chat_format as chat_format backend_gemma.N_GPU_LAYERS = 0 + if getattr(chat_format, "_kevintex_cpu_patch", False): + return original = chat_format.Gemma4ChatHandler def cpu_chat_handler(*args, **kwargs): @@ -64,6 +90,7 @@ def cpu_chat_handler(*args, **kwargs): return original(*args, **kwargs) chat_format.Gemma4ChatHandler = cpu_chat_handler + chat_format._kevintex_cpu_patch = True def _free_loopback_port() -> int: diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 7950328..e756cc1 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -2,7 +2,7 @@ # Build kevintex__all.deb from the project sources. set -euo pipefail -VERSION="${KEVINTEX_VERSION:-1.2.5}" +VERSION="${KEVINTEX_VERSION:-1.2.6}" if [[ ! "$VERSION" =~ ^[0-9]+([.][0-9]+){1,3}$ ]]; then echo "KEVINTEX_VERSION must look like 1.2.3" >&2 exit 2 @@ -50,7 +50,8 @@ Description: Formula-image to LaTeX converter (Snip & Get) no external screenshot utility required. . On first launch the app creates a Python environment. The local - provider downloads PyTorch, llama.cpp and model weights (~4.1 GB); + provider installs an auto-detected CPU, CUDA, ROCm, Vulkan, or SYCL + llama.cpp runtime and downloads model weights (~4.1 GB); the cloud provider uses a Google AI Studio API key instead. LOCALTEX_BACKEND=lfm-vl or =pix2tex selects alternative backends. EOF diff --git a/packaging/localtex-launcher b/packaging/localtex-launcher index 51f6f50..a7d8172 100755 --- a/packaging/localtex-launcher +++ b/packaging/localtex-launcher @@ -1,7 +1,7 @@ #!/usr/bin/env bash # KevinTex desktop launcher: bootstraps the Python environment on first run, # starts the local server, and opens the app in a native window. -set -u +set -uo pipefail APP_DIR="${KEVINTEX_APP_DIR:-${LOCALTEX_APP_DIR:-}}" if [ -z "$APP_DIR" ]; then @@ -20,6 +20,7 @@ URL="http://127.0.0.1:$PORT" LOG="$DATA_DIR/server.log" MODELS_DIR="$DATA_DIR/models" CACHE_DIR="$DATA_DIR/cache" +ACCELERATION_FILE="$DATA_DIR/acceleration" mkdir -p "$DATA_DIR" if [ ! -e "$MODELS_DIR" ] && [ -d "$APP_DIR/models" ] && [ -w "$APP_DIR" ]; then @@ -30,6 +31,41 @@ export LOCALTEX_DATA_DIR="$DATA_DIR" export LOCALTEX_MODELS_DIR="$MODELS_DIR" export HF_HOME="$CACHE_DIR/huggingface" +normalize_acceleration() { + case "${1,,}" in + auto|cpu|cuda|rocm|vulkan|sycl) printf '%s\n' "${1,,}" ;; + *) + echo "Unsupported acceleration '$1'. Use auto, cpu, cuda, rocm, vulkan, or sycl." >&2 + return 2 + ;; + esac +} + +detect_acceleration() { + if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + printf '%s\n' cuda + elif { command -v hipconfig >/dev/null 2>&1 || [ -x /opt/rocm/bin/hipconfig ]; } && [ -e /dev/kfd ]; then + printf '%s\n' rocm + elif command -v glslc >/dev/null 2>&1 && [ -r /usr/include/vulkan/vulkan.h ] && compgen -G '/dev/dri/renderD*' >/dev/null; then + # Vulkan is the portable path for Intel and AMD graphics. SYCL remains + # available as an explicit opt-in for systems with oneAPI installed. + printf '%s\n' vulkan + else + printf '%s\n' cpu + fi +} + +REQUESTED_ACCELERATION="$(normalize_acceleration "${KEVINTEX_ACCELERATION:-${LOCALTEX_ACCELERATION:-auto}}")" || exit $? +if [ "$REQUESTED_ACCELERATION" = auto ]; then + ACCELERATION="$(detect_acceleration)" +else + ACCELERATION="$REQUESTED_ACCELERATION" +fi +export LOCALTEX_ACCELERATION="$ACCELERATION" +if [ "$ACCELERATION" = cpu ]; then + export LOCALTEX_N_GPU_LAYERS="${LOCALTEX_N_GPU_LAYERS:-0}" +fi + notify() { if command -v notify-send >/dev/null; then notify-send -i kevintex "KevinTex" "$1" || true @@ -68,32 +104,129 @@ stop_server() { trap stop_server EXIT INT TERM -# ---------- first-run bootstrap ---------- +# ---------- Python/runtime bootstrap ---------- +check_acceleration_prerequisites() { + case "$ACCELERATION" in + rocm) + if ! command -v hipconfig >/dev/null 2>&1 && [ ! -x /opt/rocm/bin/hipconfig ]; then + echo "ROCm was selected, but hipconfig was not found. Install the AMD ROCm SDK first." >&2 + return 1 + fi + ;; + vulkan) + if ! command -v cmake >/dev/null 2>&1 || ! command -v glslc >/dev/null 2>&1 || [ ! -r /usr/include/vulkan/vulkan.h ]; then + echo "Vulkan was selected, but its build tools are missing." >&2 + echo "Ubuntu/Debian: sudo apt install build-essential cmake libvulkan-dev glslc spirv-headers" >&2 + return 1 + fi + ;; + sycl) + if [ -r /opt/intel/oneapi/setvars.sh ]; then + # shellcheck disable=SC1091 + source /opt/intel/oneapi/setvars.sh >/dev/null 2>&1 + fi + if ! command -v icx >/dev/null 2>&1 || ! command -v icpx >/dev/null 2>&1; then + echo "SYCL was selected, but Intel oneAPI compilers (icx/icpx) were not found." >&2 + return 1 + fi + ;; + esac +} + +install_llama_backend() { + check_acceleration_prerequisites || return + case "$ACCELERATION" in + cpu) + "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir \ + --only-binary llama-cpp-python \ + "llama-cpp-python>=0.3.34" \ + --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu || return + ;; + cuda) + "$VENV/bin/pip" install --upgrade torch torchvision \ + --index-url https://download.pytorch.org/whl/cu124 || return + "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir \ + --only-binary llama-cpp-python \ + "llama-cpp-python>=0.3.34" \ + --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 || return + ;; + rocm) + CMAKE_ARGS="-DGGML_HIP=ON" FORCE_CMAKE=1 \ + "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir \ + --no-binary llama-cpp-python \ + "llama-cpp-python>=0.3.34" || return + ;; + vulkan) + CMAKE_ARGS="-DGGML_VULKAN=ON" FORCE_CMAKE=1 \ + "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir \ + --no-binary llama-cpp-python \ + "llama-cpp-python>=0.3.34" || return + ;; + sycl) + CMAKE_ARGS="-DGGML_SYCL=ON -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx" FORCE_CMAKE=1 \ + "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir \ + --no-binary llama-cpp-python \ + "llama-cpp-python>=0.3.34" || return + ;; + esac +} + +configure_llama_backend() { + install_llama_backend || return + printf '%s\n' "$ACCELERATION" > "$ACCELERATION_FILE" || return +} + +bootstrap_environment() { + check_acceleration_prerequisites || return + python3 -m venv "$VENV" || return + "$VENV/bin/pip" install --upgrade pip || return + if [ "$ACCELERATION" = cuda ]; then + "$VENV/bin/pip" install torch torchvision \ + --index-url https://download.pytorch.org/whl/cu124 || return + else + "$VENV/bin/pip" install torch torchvision || return + fi + install_llama_backend || return + "$VENV/bin/pip" install -r "$APP_DIR/requirements.txt" || return + printf '%s\n' "$ACCELERATION" > "$ACCELERATION_FILE" || return + echo + echo "Setup complete ($ACCELERATION backend). You can close this window." +} + +INSTALLED_ACCELERATION="$(tr -d '[:space:]' < "$ACCELERATION_FILE" 2>/dev/null || true)" +NEEDS_BOOTSTRAP=0 if [ ! -x "$VENV/bin/uvicorn" ]; then - notify "First-run setup: installing the Python environment (PyTorch + llama.cpp). The AI model weights download inside the app on first launch." - BOOTSTRAP="set -e -python3 -m venv '$VENV' -'$VENV/bin/pip' install --upgrade pip -'$VENV/bin/pip' install torch torchvision --index-url https://download.pytorch.org/whl/cu121 -# Default backend is Gemma 4 E2B-it via llama.cpp. Install the CUDA wheel -# explicitly (PyPI default is CPU-only); fall back to cu122/cu124 if needed. -'$VENV/bin/pip' install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121 || \ -'$VENV/bin/pip' install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu122 || \ -'$VENV/bin/pip' install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 -'$VENV/bin/pip' install -r '$APP_DIR/requirements.txt' -echo; echo 'Setup complete. You can close this window.'" + NEEDS_BOOTSTRAP=1 +elif [ "$INSTALLED_ACCELERATION" != "$ACCELERATION" ]; then + NEEDS_BOOTSTRAP=2 +fi + +if [ "$NEEDS_BOOTSTRAP" -ne 0 ]; then + if [ "$NEEDS_BOOTSTRAP" -eq 1 ]; then + notify "First-run setup: installing the $ACCELERATION runtime. Model weights download inside the app if you choose local mode." + else + notify "Switching llama.cpp from ${INSTALLED_ACCELERATION:-an older build} to $ACCELERATION." + fi if command -v zenity >/dev/null; then - bash -c "$BOOTSTRAP" > "$DATA_DIR/setup.log" 2>&1 & + if [ "$NEEDS_BOOTSTRAP" -eq 1 ]; then + bootstrap_environment > "$DATA_DIR/setup.log" 2>&1 & + else + configure_llama_backend > "$DATA_DIR/setup.log" 2>&1 & + fi SETUP_PID=$! - ( while kill -0 $SETUP_PID 2>/dev/null; do echo "# Installing… (see $DATA_DIR/setup.log)"; sleep 2; done; echo 100 ) | \ + ( while kill -0 "$SETUP_PID" 2>/dev/null; do echo "# Installing $ACCELERATION runtime… (see $DATA_DIR/setup.log)"; sleep 2; done; echo 100 ) | \ zenity --progress --pulsate --no-cancel --auto-close \ --title="KevinTex first-run setup" \ - --text="Downloading PyTorch + llama.cpp + model environment (one time)…" 2>/dev/null - wait $SETUP_PID || { zenity --error --text="Setup failed. See $DATA_DIR/setup.log" 2>/dev/null; exit 1; } + --text="Installing the $ACCELERATION inference runtime…" 2>/dev/null + wait "$SETUP_PID" || { zenity --error --text="Setup failed. See $DATA_DIR/setup.log" 2>/dev/null; exit 1; } else - bash -c "$BOOTSTRAP" 2>&1 | tee "$DATA_DIR/setup.log" || exit 1 + if [ "$NEEDS_BOOTSTRAP" -eq 1 ]; then + bootstrap_environment 2>&1 | tee "$DATA_DIR/setup.log" || exit 1 + else + configure_llama_backend 2>&1 | tee "$DATA_DIR/setup.log" || exit 1 + fi fi - notify "Setup complete." + notify "Setup complete: $ACCELERATION runtime." fi # ---------- start server ---------- diff --git a/packaging/windows/README.md b/packaging/windows/README.md index a333d69..d721881 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -5,15 +5,15 @@ pywebview, an embedded loopback Uvicorn server, and PyInstaller's `onedir` layout. Extract the ZIP and run `KevinTex.exe`; no separate Python installation or browser launch is required. -The default artifact uses llama.cpp's CPU wheel. A manually dispatched workflow -can select the best-effort CUDA 12.4 flavor, which is marked at build time and -requires a compatible NVIDIA driver. Source launches can opt into an installed -CUDA llama.cpp runtime with `KEVINTEX_CUDA=1`. +The default artifact uses llama.cpp's optimized x64 CPU wheel. The release +workflow can also produce CUDA 12.4 for NVIDIA GPUs and Vulkan for compatible +Intel and AMD GPUs. Each build contains an `acceleration.txt` marker so the app +uses the matching native runtime and reports it in the status badge. The CUDA runtime is larger than GitHub's single-file release limit, so that flavor is published as numbered `.7z.001`, `.7z.002`, … volumes. Download every part into one folder and open `.7z.001` with 7-Zip. The standard CPU build -remains a single ZIP. +remains a single ZIP. The Vulkan flavor is also a single ZIP. On first launch, the Gemma GGUF files (about 4.1 GB) download to: @@ -24,11 +24,20 @@ On first launch, the Gemma GGUF files (about 4.1 GB) download to: Hugging Face cache data is stored under `%LOCALAPPDATA%\KevinTex\cache`. Weights are deliberately absent from the ZIP. -To build locally from a Windows PowerShell prompt: +To build locally from a Windows PowerShell prompt, install the normal +dependencies and then select a native backend before running PyInstaller: ```powershell py -3.11 -m pip install -r requirements-windows.txt +& .\packaging\windows\build-llama-backend.ps1 -Acceleration cpu -Python py py packaging/windows/generate_icon.py py packaging/windows/generate_version_info.py v0.0.0 pyinstaller --clean --noconfirm packaging/windows/kevintex.spec ``` + +Valid backend names are `cpu`, `cuda`, `vulkan`, `rocm`, and `sycl`. Vulkan +requires the LunarG Vulkan SDK. ROCm requires AMD's Windows HIP SDK. SYCL must +be built from an Intel oneAPI command prompt. HIP and oneAPI are local build +modes because those vendor SDKs are not installed on standard GitHub-hosted +Windows runners. Vulkan is the simplest portable GPU option for most supported +Intel and AMD Windows systems. diff --git a/packaging/windows/build-llama-backend.ps1 b/packaging/windows/build-llama-backend.ps1 new file mode 100644 index 0000000..5217d9f --- /dev/null +++ b/packaging/windows/build-llama-backend.ps1 @@ -0,0 +1,73 @@ +param( + [ValidateSet("cpu", "cuda", "rocm", "vulkan", "sycl")] + [string]$Acceleration = "cpu", + [string]$Python = "python" +) + +$ErrorActionPreference = "Stop" +$Acceleration = $Acceleration.ToLowerInvariant() +$marker = Join-Path $PSScriptRoot "acceleration.txt" + +function Invoke-Pip { + & $Python -m pip @args + if ($LASTEXITCODE -ne 0) { + throw "pip failed while installing the $Acceleration llama.cpp backend." + } +} + +Write-Host "Installing llama.cpp backend: $Acceleration" + +switch ($Acceleration) { + "cpu" { + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` + --only-binary llama-cpp-python ` + "llama-cpp-python>=0.3.34" ` + --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu + } + "cuda" { + Invoke-Pip install --upgrade torch ` + --index-url https://download.pytorch.org/whl/cu124 + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` + --only-binary llama-cpp-python ` + "llama-cpp-python>=0.3.34" ` + --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 + } + "vulkan" { + if (-not (Get-Command glslc.exe -ErrorAction SilentlyContinue) -and -not $env:VULKAN_SDK) { + throw "Vulkan SDK not found. Install it from vulkan.lunarg.com and reopen PowerShell." + } + Invoke-Pip install --upgrade cmake ninja + $env:CMAKE_ARGS = "-DGGML_VULKAN=ON" + $env:FORCE_CMAKE = "1" + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` + --no-binary llama-cpp-python ` + "llama-cpp-python>=0.3.34" + } + "rocm" { + if (-not (Get-Command hipconfig.exe -ErrorAction SilentlyContinue) -and -not $env:HIP_PATH) { + throw "AMD HIP SDK not found. Install the Windows HIP SDK and reopen PowerShell." + } + Invoke-Pip install --upgrade cmake ninja + $env:CMAKE_GENERATOR = "Ninja" + $env:CMAKE_ARGS = "-DGGML_HIP=ON -DCMAKE_BUILD_TYPE=Release" + $env:FORCE_CMAKE = "1" + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` + --no-binary llama-cpp-python ` + "llama-cpp-python>=0.3.34" + } + "sycl" { + if (-not (Get-Command icx.exe -ErrorAction SilentlyContinue) -or -not (Get-Command icx-cl.exe -ErrorAction SilentlyContinue)) { + throw "Intel oneAPI compiler environment not found. Run this from an Intel oneAPI command prompt." + } + Invoke-Pip install --upgrade cmake ninja + $env:CMAKE_GENERATOR = "Ninja" + $env:CMAKE_ARGS = "-DGGML_SYCL=ON -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icx" + $env:FORCE_CMAKE = "1" + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` + --no-binary llama-cpp-python ` + "llama-cpp-python>=0.3.34" + } +} + +Set-Content -Path $marker -Value $Acceleration -Encoding ascii +Write-Host "Wrote $marker. Build KevinTex with PyInstaller next." diff --git a/packaging/windows/kevintex.spec b/packaging/windows/kevintex.spec index f14bfc1..7aa7644 100644 --- a/packaging/windows/kevintex.spec +++ b/packaging/windows/kevintex.spec @@ -12,9 +12,9 @@ datas = [ (str(project / "static"), "static"), (str(project / "snip.py"), "."), ] -cuda_marker = project / "packaging" / "windows" / "cuda_enabled.txt" -if cuda_marker.exists(): - datas.append((str(cuda_marker), ".")) +acceleration_marker = project / "packaging" / "windows" / "acceleration.txt" +if acceleration_marker.exists(): + datas.append((str(acceleration_marker), ".")) binaries = [] hiddenimports = [ "backend_gemma", diff --git a/requirements.txt b/requirements.txt index 0c88ace..b29fffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,8 @@ torchvision # (try cu122 / cu124 indexes if cu121 has no matching wheel). # The CUDA wheel needs torch's pip-bundled NVIDIA runtime libs, which # backend_gemma.py preloads via ctypes at import time -- no system CUDA needed. +# The packaged launcher can also build llama.cpp for ROCm/HIP, Vulkan, Intel +# SYCL, or optimized CPU with KEVINTEX_ACCELERATION. llama-cpp-python>=0.3.34 huggingface_hub # Optional hosted provider: Gemma 4 through Google AI Studio / Gemini API. diff --git a/static/index.html b/static/index.html index 7cc7239..2334c38 100644 --- a/static/index.html +++ b/static/index.html @@ -1689,7 +1689,16 @@

Clear conversion history?

if (!s) return; state.backend = s.backend || null; $("#voice-btn").hidden = !s.audio_supported; - const dev = s.device === "cuda" ? "GPU" : (s.device === "metal" ? "Apple Silicon" : (s.device === "cloud" ? "Google AI Studio" : "CPU")); + const deviceLabels = { + cuda: "NVIDIA GPU", + rocm: "AMD GPU · ROCm", + vulkan: "GPU · Vulkan", + sycl: "Intel GPU · SYCL", + metal: "Apple Silicon", + cloud: "Google AI Studio", + cpu: "CPU", + }; + const dev = deviceLabels[s.device] || "CPU"; $("#device-badge").textContent = `${fmtBackendLabel(s.backend)} · ${dev}`; document.title = s.backend === "gemma-cloud" ? "KevinTex — Snip & Get, AI Studio" : "KevinTex — Snip & Get"; $("#drop-help").textContent = s.backend === "gemma-cloud" diff --git a/test_desktop_acceleration.py b/test_desktop_acceleration.py new file mode 100644 index 0000000..286da37 --- /dev/null +++ b/test_desktop_acceleration.py @@ -0,0 +1,54 @@ +"""Windows launcher acceleration-selection tests.""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import desktop + + +class DesktopAccelerationTests(unittest.TestCase): + def test_cpu_is_safe_default(self): + with tempfile.TemporaryDirectory() as bundle, patch.object( + sys, "_MEIPASS", bundle, create=True + ), patch.dict(os.environ, {}, clear=True): + self.assertEqual(desktop._bundled_acceleration(), "cpu") + + def test_packaged_acceleration_marker_is_used(self): + with tempfile.TemporaryDirectory() as bundle: + Path(bundle, "acceleration.txt").write_text("vulkan\n", encoding="utf-8") + with patch.object(sys, "_MEIPASS", bundle, create=True), patch.dict( + os.environ, {}, clear=True + ): + self.assertEqual(desktop._bundled_acceleration(), "vulkan") + + def test_packaged_marker_wins_over_environment_override(self): + with tempfile.TemporaryDirectory() as bundle: + Path(bundle, "acceleration.txt").write_text("cpu\n", encoding="utf-8") + with patch.object(sys, "_MEIPASS", bundle, create=True), patch.dict( + os.environ, {"KEVINTEX_ACCELERATION": "ROCM"}, clear=True + ): + self.assertEqual(desktop._bundled_acceleration(), "cpu") + + def test_environment_selects_a_source_build_without_marker(self): + with tempfile.TemporaryDirectory() as bundle, patch.object( + sys, "_MEIPASS", bundle, create=True + ), patch.dict( + os.environ, {"KEVINTEX_ACCELERATION": "ROCM"}, clear=True + ): + self.assertEqual(desktop._bundled_acceleration(), "rocm") + + def test_legacy_cuda_marker_remains_compatible(self): + with tempfile.TemporaryDirectory() as bundle: + Path(bundle, "cuda_enabled.txt").write_text("CUDA build", encoding="utf-8") + with patch.object(sys, "_MEIPASS", bundle, create=True), patch.dict( + os.environ, {}, clear=True + ): + self.assertEqual(desktop._bundled_acceleration(), "cuda") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_image_preprocessing.py b/test_image_preprocessing.py index f635274..e3f329d 100644 --- a/test_image_preprocessing.py +++ b/test_image_preprocessing.py @@ -2,6 +2,7 @@ import asyncio import io +import tempfile import unittest from PIL import Image, ImageDraw, ImageStat @@ -10,6 +11,13 @@ from image_preprocessing import preprocess_image, validate_options +def upload_file(data: bytes, filename: str) -> UploadFile: + file = tempfile.SpooledTemporaryFile() + file.write(data) + file.seek(0) + return UploadFile(file, filename=filename) + + class PreprocessingTests(unittest.TestCase): def formula_image(self, size=(320, 100)): image = Image.new("RGB", size, (224, 224, 224)) @@ -58,7 +66,7 @@ def test_preprocess_api_runs_without_ocr_model(self): source = self.formula_image() encoded = io.BytesIO() source.save(encoded, "PNG") - upload = UploadFile(io.BytesIO(encoded.getvalue()), filename="generated.png") + upload = upload_file(encoded.getvalue(), "generated.png") response = asyncio.run( preprocess_preview(upload, preprocess="auto", rotation=90, invert=False) ) diff --git a/test_performance_safety.py b/test_performance_safety.py index f90ac78..eaf3469 100644 --- a/test_performance_safety.py +++ b/test_performance_safety.py @@ -1,7 +1,6 @@ """Concurrency, backpressure, and resource-limit tests for KevinTex.""" import asyncio -import io import os import tempfile import threading @@ -19,6 +18,13 @@ from pathlib import Path +def upload_file(data: bytes, filename: str) -> UploadFile: + file = tempfile.SpooledTemporaryFile() + file.write(data) + file.seek(0) + return UploadFile(file, filename=filename) + + class ModelInitializationTests(unittest.TestCase): def test_generic_backend_load_error_reaches_status(self): previous_backend = app.BACKEND @@ -78,6 +84,18 @@ def test_mlx_backend_is_selected_directly(self): app._model = previous_model app.BACKEND = previous_backend + def test_native_acceleration_label_uses_launcher_selection(self): + previous_backend = app.BACKEND + app.BACKEND = "gemma" + try: + for acceleration in ("cpu", "cuda", "rocm", "vulkan", "sycl"): + with self.subTest(acceleration=acceleration), patch.dict( + os.environ, {"LOCALTEX_ACCELERATION": acceleration} + ): + self.assertEqual(app._device_label(), acceleration) + finally: + app.BACKEND = previous_backend + def test_optiq_model_skips_audio_repair(self): with patch.object( backend_mlx, @@ -209,7 +227,7 @@ async def should_not_run(_request): def test_convert_rejects_oversized_upload_before_inference(self): previous_limit = app.MAX_UPLOAD_BYTES app.MAX_UPLOAD_BYTES = 8 - upload = UploadFile(io.BytesIO(b"x" * 9), filename="large.png") + upload = upload_file(b"x" * 9, "large.png") try: response = asyncio.run( app.convert( @@ -228,7 +246,7 @@ def test_convert_rejects_oversized_upload_before_inference(self): def test_reader_accepts_exact_limit(self): previous_limit = app.MAX_UPLOAD_BYTES app.MAX_UPLOAD_BYTES = 8 - upload = UploadFile(io.BytesIO(b"x" * 8), filename="exact.bin") + upload = upload_file(b"x" * 8, "exact.bin") try: raw = asyncio.run(app._read_upload(upload)) finally: @@ -256,7 +274,7 @@ def load(self): def test_voice_is_rejected_on_backend_without_audio(self): previous_backend = app.BACKEND app.BACKEND = "pix2tex" - upload = UploadFile(io.BytesIO(b"not audio"), filename="voice.wav") + upload = upload_file(b"not audio", "voice.wav") try: response = asyncio.run(app.voice(upload, thinking=False)) finally: @@ -266,7 +284,7 @@ def test_voice_is_rejected_on_backend_without_audio(self): def test_voice_validates_wav_before_inference(self): previous_backend = app.BACKEND app.BACKEND = "mlx" - upload = UploadFile(io.BytesIO(b"x" * 64), filename="voice.wav") + upload = upload_file(b"x" * 64, "voice.wav") try: response = asyncio.run(app.voice(upload, thinking=False)) finally: From 14ed2d43cf1f5fd8a0185d330ef194ef278b0472 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 19:53:27 -0700 Subject: [PATCH 05/13] Verify packaged Windows GPU runtimes --- .github/workflows/windows-release.yml | 19 +++++++++++++++++++ packaging/windows/build-llama-backend.ps1 | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 60ba123..b435107 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -85,6 +85,25 @@ jobs: run: | & ./packaging/windows/build-llama-backend.ps1 -Acceleration $env:ACCELERATION + - name: Verify native acceleration runtime + run: | + if ($env:ACCELERATION -eq "cuda") { + python -c "import torch; assert torch.version.cuda, 'CPU PyTorch wheel remained in CUDA build'; print('PyTorch CUDA', torch.version.cuda)" + } + @' + import os + if os.environ["ACCELERATION"] == "cuda": + import backend_gemma + backend_gemma._preload_cuda_libs() + import llama_cpp + from llama_cpp import llama_cpp as native + info = native.llama_print_system_info().decode("utf-8", "replace") + print(info) + expected = {"cuda": "CUDA", "vulkan": "VULKAN"}.get(os.environ["ACCELERATION"]) + if expected and expected not in info.upper(): + raise RuntimeError(f"{expected} support is missing from the packaged llama.cpp runtime") + '@ | python - + - name: Run source checks run: | python -m py_compile desktop.py packaging/windows/generate_icon.py packaging/windows/generate_version_info.py diff --git a/packaging/windows/build-llama-backend.ps1 b/packaging/windows/build-llama-backend.ps1 index 5217d9f..cdab23c 100644 --- a/packaging/windows/build-llama-backend.ps1 +++ b/packaging/windows/build-llama-backend.ps1 @@ -25,7 +25,7 @@ switch ($Acceleration) { --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu } "cuda" { - Invoke-Pip install --upgrade torch ` + Invoke-Pip install --upgrade --force-reinstall --no-cache-dir torch ` --index-url https://download.pytorch.org/whl/cu124 Invoke-Pip install --upgrade --force-reinstall --no-cache-dir ` --only-binary llama-cpp-python ` From 131ae65e155d28a1d86299eb9cab689846e456e3 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 20:00:17 -0700 Subject: [PATCH 06/13] Fix Windows CUDA runtime loading --- backend_gemma.py | 36 ++++++++++++++++++++++++++--------- test_cuda_runtime.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 test_cuda_runtime.py diff --git a/backend_gemma.py b/backend_gemma.py index a28dd8a..70d2b87 100644 --- a/backend_gemma.py +++ b/backend_gemma.py @@ -97,24 +97,42 @@ def model_files_present() -> bool: ) +_CUDA_DLL_DIR_HANDLES = [] + + def _preload_cuda_libs() -> None: - """Make torch's pip-bundled NVIDIA CUDA libs resolvable to libllama.so. + """Make torch's pip-bundled NVIDIA CUDA libs resolvable to llama.cpp. - The prebuilt cu121 `llama-cpp-python` wheel links against libcudart.so.12 / - libcublas.so.12 etc. which aren't on the default ld path. Loading them with - RTLD_GLOBAL before importing `llama_cpp` makes the symbols available to the - dlopen of libllama.so. Best-effort: silently skip if torch/nvidia isn't - present (CPU-only fallback). + CUDA wheels link against runtime libraries that are not always on the + process search path. On Windows, PyTorch stores those DLLs in ``torch/lib``; + keep an ``add_dll_directory`` handle alive for the process lifetime. On + Linux, load the pip-bundled NVIDIA libraries globally before importing + ``llama_cpp``. Best-effort: silently skip if torch/CUDA is not present. """ - import ctypes - import glob - try: import torch # noqa: F401 -- ensure torch's nvidia pip pkgs exist except Exception: return + if os.name == "nt": + torch_lib = os.path.join(os.path.dirname(torch.__file__), "lib") + if not os.path.isdir(torch_lib): + return + path_entries = os.environ.get("PATH", "").split(os.pathsep) + if torch_lib not in path_entries: + os.environ["PATH"] = torch_lib + os.pathsep + os.environ.get("PATH", "") + add_dll_directory = getattr(os, "add_dll_directory", None) + if add_dll_directory is not None: + try: + _CUDA_DLL_DIR_HANDLES.append(add_dll_directory(torch_lib)) + except OSError: + pass + return + # torch >=2 ships CUDA runtime libs under site-packages/nvidia//lib/. + import ctypes + import glob + try: site_pkg = os.path.dirname(os.path.dirname(torch.__file__)) except Exception: diff --git a/test_cuda_runtime.py b/test_cuda_runtime.py new file mode 100644 index 0000000..1a27ed7 --- /dev/null +++ b/test_cuda_runtime.py @@ -0,0 +1,45 @@ +"""CUDA runtime discovery tests.""" + +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +import backend_gemma + + +class CudaRuntimeTests(unittest.TestCase): + def test_windows_torch_lib_is_kept_on_dll_search_path(self): + with tempfile.TemporaryDirectory() as root: + torch_dir = Path(root, "torch") + torch_lib = torch_dir / "lib" + torch_lib.mkdir(parents=True) + fake_torch = types.SimpleNamespace(__file__=str(torch_dir / "__init__.py")) + dll_handle = object() + + backend_gemma._CUDA_DLL_DIR_HANDLES.clear() + with ( + patch.dict(sys.modules, {"torch": fake_torch}), + patch.object(backend_gemma.os, "name", "nt"), + patch.object( + backend_gemma.os, + "add_dll_directory", + return_value=dll_handle, + create=True, + ) as add_dll_directory, + patch.dict(os.environ, {"PATH": "existing"}, clear=True), + ): + backend_gemma._preload_cuda_libs() + + add_dll_directory.assert_called_once_with(str(torch_lib)) + self.assertEqual(backend_gemma._CUDA_DLL_DIR_HANDLES, [dll_handle]) + self.assertEqual( + os.environ["PATH"], f"{torch_lib}{os.pathsep}existing" + ) + + +if __name__ == "__main__": + unittest.main() From fa2ab36d4bebe079949a06a96f766711ee4cca29 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 20:09:44 -0700 Subject: [PATCH 07/13] Verify CUDA builds without a GPU runner --- .github/workflows/windows-release.yml | 51 +++++++++++++++++++++------ 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index b435107..0f79d7a 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -85,23 +85,45 @@ jobs: run: | & ./packaging/windows/build-llama-backend.ps1 -Acceleration $env:ACCELERATION - - name: Verify native acceleration runtime + - name: Verify acceleration runtime run: | if ($env:ACCELERATION -eq "cuda") { python -c "import torch; assert torch.version.cuda, 'CPU PyTorch wheel remained in CUDA build'; print('PyTorch CUDA', torch.version.cuda)" } @' import os - if os.environ["ACCELERATION"] == "cuda": - import backend_gemma - backend_gemma._preload_cuda_libs() - import llama_cpp - from llama_cpp import llama_cpp as native - info = native.llama_print_system_info().decode("utf-8", "replace") - print(info) - expected = {"cuda": "CUDA", "vulkan": "VULKAN"}.get(os.environ["ACCELERATION"]) - if expected and expected not in info.upper(): - raise RuntimeError(f"{expected} support is missing from the packaged llama.cpp runtime") + from pathlib import Path + + acceleration = os.environ["ACCELERATION"] + if acceleration == "cuda": + import importlib.util + import torch + + llama_spec = importlib.util.find_spec("llama_cpp") + if llama_spec is None or not llama_spec.submodule_search_locations: + raise ModuleNotFoundError("llama_cpp package is missing") + llama_lib = Path(next(iter(llama_spec.submodule_search_locations))) / "lib" + cuda_backend = llama_lib / "ggml-cuda.dll" + if not cuda_backend.is_file(): + raise FileNotFoundError("CUDA llama.cpp backend DLL is missing") + imports = cuda_backend.read_bytes().lower() + for dependency in (b"cudart64_12.dll", b"cublas64_12.dll", b"nvcuda.dll"): + if dependency not in imports: + raise RuntimeError(f"{dependency.decode()} is not linked by ggml-cuda.dll") + torch_lib = Path(torch.__file__).parent / "lib" + for dependency in ("cudart64_12.dll", "cublas64_12.dll"): + if not (torch_lib / dependency).is_file(): + raise FileNotFoundError(f"PyTorch CUDA runtime is missing {dependency}") + print(f"Verified CUDA backend and runtime DLLs in {llama_lib}") + else: + import llama_cpp + from llama_cpp import llama_cpp as native + + info = native.llama_print_system_info().decode("utf-8", "replace") + print(info) + expected = {"vulkan": "VULKAN"}.get(acceleration) + if expected and expected not in info.upper(): + raise RuntimeError(f"{expected} support is missing from the packaged llama.cpp runtime") '@ | python - - name: Run source checks @@ -129,6 +151,13 @@ jobs: if (-not (Test-Path "dist/KevinTex/_internal/static/index.html")) { throw "Packaged frontend data is missing." } + if ($env:ACCELERATION -eq "cuda") { + foreach ($dependency in @("ggml-cuda.dll", "cudart64_12.dll", "cublas64_12.dll")) { + if (-not (Get-ChildItem "dist/KevinTex" -Recurse -Filter $dependency -File)) { + throw "Packaged CUDA build is missing $dependency." + } + } + } - name: Create versioned archive run: | From 37b5338b3629e81af75bdefc4a9e827ff4442735 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Mon, 13 Jul 2026 20:11:26 -0700 Subject: [PATCH 08/13] Verify Vulkan builds without a GPU runner --- .github/workflows/windows-release.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 0f79d7a..7f4f179 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -95,14 +95,16 @@ jobs: from pathlib import Path acceleration = os.environ["ACCELERATION"] - if acceleration == "cuda": + if acceleration in {"cuda", "vulkan"}: import importlib.util - import torch llama_spec = importlib.util.find_spec("llama_cpp") if llama_spec is None or not llama_spec.submodule_search_locations: raise ModuleNotFoundError("llama_cpp package is missing") llama_lib = Path(next(iter(llama_spec.submodule_search_locations))) / "lib" + if acceleration == "cuda": + import torch + cuda_backend = llama_lib / "ggml-cuda.dll" if not cuda_backend.is_file(): raise FileNotFoundError("CUDA llama.cpp backend DLL is missing") @@ -115,15 +117,19 @@ jobs: if not (torch_lib / dependency).is_file(): raise FileNotFoundError(f"PyTorch CUDA runtime is missing {dependency}") print(f"Verified CUDA backend and runtime DLLs in {llama_lib}") + elif acceleration == "vulkan": + vulkan_backend = llama_lib / "ggml-vulkan.dll" + if not vulkan_backend.is_file(): + raise FileNotFoundError("Vulkan llama.cpp backend DLL is missing") + if b"vulkan-1.dll" not in vulkan_backend.read_bytes().lower(): + raise RuntimeError("ggml-vulkan.dll is not linked to the Vulkan loader") + print(f"Verified Vulkan backend DLL in {llama_lib}") else: import llama_cpp from llama_cpp import llama_cpp as native info = native.llama_print_system_info().decode("utf-8", "replace") print(info) - expected = {"vulkan": "VULKAN"}.get(acceleration) - if expected and expected not in info.upper(): - raise RuntimeError(f"{expected} support is missing from the packaged llama.cpp runtime") '@ | python - - name: Run source checks @@ -158,6 +164,11 @@ jobs: } } } + if ($env:ACCELERATION -eq "vulkan") { + if (-not (Get-ChildItem "dist/KevinTex" -Recurse -Filter "ggml-vulkan.dll" -File)) { + throw "Packaged Vulkan build is missing ggml-vulkan.dll." + } + } - name: Create versioned archive run: | From 4f6d370ac66e7ec5bd2ed78e7418487322d4f577 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Wed, 15 Jul 2026 08:54:23 -0700 Subject: [PATCH 09/13] Add cloud Voice-to-LaTeX --- README.md | 13 ++++-- app.py | 21 +++++----- backend_gemma_cloud.py | 81 ++++++++++++++++++++++++++++++-------- static/index.html | 17 +++++--- test_gemma_cloud.py | 70 ++++++++++++++++++++++++++++++++ test_performance_safety.py | 15 +++++++ 6 files changed, 182 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 50abdad..01c3a67 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ API key. fraction/root/integral/matrix starters. Drawings go directly to local Gemma. - **Voice-to-LaTeX** — dictate a formula through the microphone; Gemma turns spoken math into editable Markdown + LaTeX entirely on-device through MLX on - Apple Silicon and llama.cpp on Windows/Linux. + Apple Silicon and llama.cpp on Windows/Linux, or through the audio-capable + Gemini model when Google AI Studio is selected. - **Image preparation tools** — rotate before OCR, invert dark screenshots, and compare the original with the exact processed image sent to recognition. - **Live KaTeX preview** (bundled locally — the app works with no internet at all) @@ -46,7 +47,8 @@ API key. application selects `LOCALTEX_BACKEND=mlx` automatically. - **Local or cloud Gemma** — choose **Download model weights** or **Use Google AI Studio API** on first launch. Change the choice later in Settings. The - hosted option uses `gemma-4-26b-a4b-it`; images are sent to Google and usage + hosted option uses `gemma-4-26b-a4b-it` for images and `gemini-3.5-flash` + for voice. Cloud images and voice recordings are sent to Google, and usage follows your AI Studio quota/billing. ## Install @@ -160,9 +162,11 @@ The same fields are accepted by `POST /api/convert`; `/api/snip` accepts them as query parameters. Conversion responses include the applied preprocessing metadata. -`POST /api/voice` accepts a 16 kHz mono WAV file in the `audio` multipart field +`POST /api/voice` accepts a short mono WAV file in the `audio` multipart field and an optional `thinking` boolean. It is available with local Gemma backends -and is hidden while the hosted AI Studio provider is active. +and the hosted AI Studio provider. Cloud voice requests use Gemini's documented +[inline audio input](https://ai.google.dev/gemini-api/docs/generate-content/audio) +and are limited to 14 MiB to stay below its encoded request limit. ## Performance and resource controls @@ -181,6 +185,7 @@ The following optional environment variables tune local inference: - `LOCALTEX_INFERENCE_QUEUE_TIMEOUT` — wait before returning busy - `LOCALTEX_PROVIDER=local|cloud` — optionally select the Gemma provider before startup - `GEMINI_API_KEY` — optionally provide the Google AI Studio key through the environment +- `LOCALTEX_GEMINI_AUDIO_MODEL` — override the cloud voice model (default `gemini-3.5-flash`) The Settings UI stores a key in the per-user `provider.json` file with user-only permissions where supported. The key is not stored in browser diff --git a/app.py b/app.py index 296be10..23c5cb1 100644 --- a/app.py +++ b/app.py @@ -3,8 +3,9 @@ A local, free, unlimited SimpleTex-style app. The default backend is Google's Gemma 4 E2B-it multimodal model (Q4_K_M GGUF + vision projector) run via llama.cpp entirely on this machine. Users can also select the hosted Gemma 4 -26B A4B model through Google AI Studio. Set LOCALTEX_BACKEND=mlx for Apple -Silicon MLX acceleration, +26B A4B model through Google AI Studio; cloud voice transcription uses an +audio-capable Gemini model through the same API key. Set LOCALTEX_BACKEND=mlx +for Apple Silicon MLX acceleration, LOCALTEX_BACKEND=lfm-vl for the Liquid AI LFM2.5-VL backend, or LOCALTEX_BACKEND=pix2tex for the smaller pix2tex model. """ @@ -32,6 +33,7 @@ APP_DIR = os.path.dirname(os.path.abspath(__file__)) BACKEND = os.environ.get("LOCALTEX_BACKEND", "gemma").lower() _GEMMA_BACKENDS = {"gemma", "mlx", "gemma-cloud"} +_AUDIO_BACKENDS = {"gemma", "mlx", "gemma-cloud"} _PROVIDER_MODES = {"local", "cloud"} # Default thinking mode for the VLM backend (see backend_vlm). 0/1 via env. THINKING_DEFAULT = os.environ.get("LOCALTEX_THINKING", "0") in ("1", "true", "True", "yes") @@ -116,6 +118,9 @@ def _provider_state() -> dict: "cloud_model": os.environ.get( "LOCALTEX_GEMMA_CLOUD_MODEL", "gemma-4-26b-a4b-it" ), + "cloud_audio_model": os.environ.get( + "LOCALTEX_GEMINI_AUDIO_MODEL", "gemini-3.5-flash" + ), } @@ -310,9 +315,7 @@ def _run_audio(audio_path: str, thinking: bool | None): try: backend = get_model() if not hasattr(backend, "recognize_audio"): - raise RuntimeError( - "Voice-to-LaTeX requires the Apple Silicon MLX backend." - ) + raise RuntimeError("The active provider does not support Voice-to-LaTeX.") return backend.recognize_audio(audio_path, thinking=thinking) finally: _inference_slots.release() @@ -477,9 +480,9 @@ async def voice( thinking: bool | None = Form(None), ): """Convert a short WAV recording of spoken mathematics to LaTeX.""" - if _active_backend() not in ("gemma", "mlx"): + if _active_backend() not in _AUDIO_BACKENDS: return JSONResponse( - {"error": "Voice-to-LaTeX requires local Gemma or MLX."}, + {"error": "Voice-to-LaTeX requires a Gemma or AI Studio provider."}, status_code=501, ) try: @@ -604,7 +607,7 @@ def health(): "backend": active_backend, "model_loaded": _model is not None, "thinking_default": THINKING_DEFAULT, - "audio_supported": active_backend in ("gemma", "mlx"), + "audio_supported": active_backend in _AUDIO_BACKENDS, "setup_required": _setup_required(), } @@ -638,7 +641,7 @@ def status(): s["backend"] = active_backend s["device"] = _device_label() s["model_loaded"] = model_ready() - s["audio_supported"] = active_backend in ("gemma", "mlx") + s["audio_supported"] = active_backend in _AUDIO_BACKENDS s["setup_required"] = False s["provider"] = _provider_state() return s diff --git a/backend_gemma_cloud.py b/backend_gemma_cloud.py index a8826e9..0f80746 100644 --- a/backend_gemma_cloud.py +++ b/backend_gemma_cloud.py @@ -9,6 +9,7 @@ import time from backend_vlm import ( + AUDIO_PROMPT, PROMPT, THINKING_PROMPT, _extract_markdown, @@ -20,9 +21,16 @@ # Google currently exposes these Gemma 4 models through the Gemini API. The # A4B model is the smaller hosted option and supports image input for OCR. MODEL_ID = os.environ.get("LOCALTEX_GEMMA_CLOUD_MODEL", "gemma-4-26b-a4b-it") +# Hosted Gemma 4 image models do not accept audio. Google AI Studio's +# audio-capable Gemini model uses the same API key and SDK client. +AUDIO_MODEL_ID = os.environ.get( + "LOCALTEX_GEMINI_AUDIO_MODEL", "gemini-3.5-flash" +) MAX_TOKENS_FAST = 1024 MAX_TOKENS_THINKING = 3072 REQUEST_TIMEOUT_MS = 120_000 +# Inline media requests must stay below 20 MB after transport encoding. +MAX_INLINE_AUDIO_BYTES = 14 * 1024 * 1024 _STATUS = {"phase": "init", "progress": 0, "message": "Starting…", "error": None} _status_lock = threading.Lock() @@ -65,7 +73,7 @@ def _response_text(response) -> str: class GemmaCloudBackend: - """Google AI Studio-backed Gemma 4 image OCR backend.""" + """Google AI Studio-backed image OCR and voice transcription backend.""" def __init__(self, api_key: str | None = None): api_key = (api_key or os.environ.get("GEMINI_API_KEY", "")).strip() @@ -102,11 +110,21 @@ def close(self) -> None: if callable(close): close() + def _generation_config(self, think: bool): + max_tokens = MAX_TOKENS_THINKING if think else MAX_TOKENS_FAST + return self.types.GenerateContentConfig( + max_output_tokens=max_tokens, + temperature=0.05, + top_p=0.9, + thinking_config=self.types.ThinkingConfig( + thinking_level="high" if think else "minimal" + ), + ) + def recognize(self, img, thinking: bool | None = None) -> str: """Recognize a formula image through the hosted Gemma 4 model.""" think = self.default_thinking if thinking is None else bool(thinking) prompt = THINKING_PROMPT if think else PROMPT - max_tokens = MAX_TOKENS_THINKING if think else MAX_TOKENS_FAST with io.BytesIO() as buffer: img.save(buffer, format="PNG") @@ -114,14 +132,7 @@ def recognize(self, img, thinking: bool | None = None) -> str: data=buffer.getvalue(), mime_type="image/png" ) - config = self.types.GenerateContentConfig( - max_output_tokens=max_tokens, - temperature=0.05, - top_p=0.9, - thinking_config=self.types.ThinkingConfig( - thinking_level="high" if think else "minimal" - ), - ) + config = self._generation_config(think) started = time.perf_counter() with self._inference_lock: response = self.client.models.generate_content( @@ -143,14 +154,52 @@ def recognize(self, img, thinking: bool | None = None) -> str: ) return _extract_markdown(content) - # The hosted 26B and 31B Gemma API models are image-capable, but not the - # audio-capable E2B/E4B/12B variants. Keep the existing voice button - # hidden while this backend is active. def recognize_audio(self, audio_path: str, thinking: bool | None = None) -> str: - raise RuntimeError( - "Voice-to-LaTeX is available with local Gemma or MLX, not the " - "hosted Gemma 4 model." + """Convert spoken mathematics through Google AI Studio's audio model.""" + think = self.default_thinking if thinking is None else bool(thinking) + try: + audio_size = os.path.getsize(audio_path) + except OSError as exc: + raise RuntimeError(f"Could not read the voice recording: {exc}") from exc + if audio_size > MAX_INLINE_AUDIO_BYTES: + raise RuntimeError( + "The voice recording is too large for cloud transcription " + "(14 MiB maximum)." + ) + try: + with open(audio_path, "rb") as handle: + audio_bytes = handle.read() + except OSError as exc: + raise RuntimeError(f"Could not read the voice recording: {exc}") from exc + if not audio_bytes: + raise RuntimeError("The voice recording is empty.") + + audio_part = self.types.Part.from_bytes( + data=audio_bytes, + mime_type="audio/wav", + ) + config = self._generation_config(think) + started = time.perf_counter() + with self._inference_lock: + response = self.client.models.generate_content( + model=AUDIO_MODEL_ID, + contents=[AUDIO_PROMPT, audio_part], + config=config, + ) + elapsed = time.perf_counter() - started + content = _response_text(response) + if not content.strip(): + raise RuntimeError( + "Google AI Studio returned an empty voice transcription. " + "Check model access and try again." + ) + log.info( + "Cloud Gemini transcribed %d audio bytes in %.2fs (thinking=%s)", + len(audio_bytes), + elapsed, + think, ) + return _extract_markdown(content) def load(api_key: str | None = None) -> GemmaCloudBackend: diff --git a/static/index.html b/static/index.html index 2334c38..932af87 100644 --- a/static/index.html +++ b/static/index.html @@ -465,7 +465,7 @@
-
Get a Google AI Studio API key ↗ · The key stays in the local app config.
+
Get a Google AI Studio API key ↗ · The key stays in the local app config. Cloud images and voice recordings are sent to Google.
@@ -1566,8 +1566,9 @@

Clear conversion history?

function describeProvider(p) { if (!p || !p.supported) return "Provider selection is unavailable for this backend."; if (p.mode === "cloud") { + const audioModel = p.cloud_audio_model ? ` · voice ${p.cloud_audio_model}` : ""; return p.api_key_configured - ? `Google AI Studio API · ${p.cloud_model} · key ${p.api_key_hint}` + ? `Google AI Studio API · ${p.cloud_model}${audioModel} · key ${p.api_key_hint}` : "Google AI Studio API · API key required"; } if (p.mode === "local") { @@ -1688,7 +1689,11 @@

Clear conversion history?

function applyStatus(s) { if (!s) return; state.backend = s.backend || null; - $("#voice-btn").hidden = !s.audio_supported; + const voiceButton = $("#voice-btn"); + voiceButton.hidden = !s.audio_supported; + voiceButton.title = s.backend === "gemma-cloud" + ? "Dictate a formula — the recording is sent to Google AI Studio" + : "Dictate a formula"; const deviceLabels = { cuda: "NVIDIA GPU", rocm: "AMD GPU · ROCm", @@ -1702,7 +1707,7 @@

Clear conversion history?

$("#device-badge").textContent = `${fmtBackendLabel(s.backend)} · ${dev}`; document.title = s.backend === "gemma-cloud" ? "KevinTex — Snip & Get, AI Studio" : "KevinTex — Snip & Get"; $("#drop-help").textContent = s.backend === "gemma-cloud" - ? "PNG / JPG / BMP / WEBP — images are sent to Google AI Studio" + ? "PNG / JPG / BMP / WEBP — images and voice recordings are sent to Google AI Studio" : "PNG / JPG / BMP / WEBP — local mode runs 100% on this machine"; if (s.phase === "setup" || s.setup_required) { _ready = false; @@ -1730,7 +1735,7 @@

Clear conversion history?

splashBar.style.width = (s.progress || 0) + "%"; splashSub.textContent = s.phase === "downloading" ? "One-time setup — this won't repeat on future launches" - : (s.backend === "gemma-cloud" ? "Hosted Gemma 4 · images are sent to Google" : ((s.backend === "gemma" || s.backend === "mlx") ? "Gemma 4 E2B-it · " + dev : dev)); + : (s.backend === "gemma-cloud" ? "Hosted models · images and voice recordings are sent to Google" : ((s.backend === "gemma" || s.backend === "mlx") ? "Gemma 4 E2B-it · " + dev : dev)); splashRetry.hidden = true; } diff --git a/test_gemma_cloud.py b/test_gemma_cloud.py index 0deadb7..ac136a4 100644 --- a/test_gemma_cloud.py +++ b/test_gemma_cloud.py @@ -126,6 +126,76 @@ def generate_content(self, **kwargs): self.assertEqual(calls["request"]["contents"], ["image-part", backend_gemma_cloud.PROMPT]) self.assertEqual(calls["image"]["mime_type"], "image/png") + def test_audio_request_uses_gemini_inline_wav_input(self): + calls = {} + + class FakePart: + @classmethod + def from_bytes(cls, **kwargs): + calls["audio"] = kwargs + return "audio-part" + + class FakeThinkingConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeGenerateContentConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + Types = type( + "Types", + (), + { + "Part": FakePart, + "ThinkingConfig": FakeThinkingConfig, + "GenerateContentConfig": FakeGenerateContentConfig, + }, + ) + + class Models: + def generate_content(self, **kwargs): + calls["request"] = kwargs + return type("Response", (), {"text": "LATEX: $\\frac{1}{2}$"})() + + backend = backend_gemma_cloud.GemmaCloudBackend.__new__( + backend_gemma_cloud.GemmaCloudBackend + ) + backend.types = Types + backend.client = type("Client", (), {"models": Models()})() + backend.default_thinking = False + import threading + + backend._inference_lock = threading.Lock() + + with tempfile.NamedTemporaryFile(suffix=".wav") as audio: + audio.write(b"RIFF" + b"\0" * 36 + b"WAVEaudio") + audio.flush() + with patch.object( + backend_gemma_cloud, "AUDIO_MODEL_ID", "gemini-audio-test" + ): + self.assertEqual(backend.recognize_audio(audio.name), "$\\frac{1}{2}$") + + self.assertEqual(calls["request"]["model"], "gemini-audio-test") + self.assertEqual( + calls["request"]["contents"], + [backend_gemma_cloud.AUDIO_PROMPT, "audio-part"], + ) + self.assertEqual(calls["audio"]["mime_type"], "audio/wav") + self.assertTrue(calls["audio"]["data"].startswith(b"RIFF")) + + def test_oversized_audio_is_rejected_before_reading(self): + backend = backend_gemma_cloud.GemmaCloudBackend.__new__( + backend_gemma_cloud.GemmaCloudBackend + ) + backend.default_thinking = False + with tempfile.NamedTemporaryFile(suffix=".wav") as audio: + audio.write(b"12345") + audio.flush() + with patch.object(backend_gemma_cloud, "MAX_INLINE_AUDIO_BYTES", 4): + with self.assertRaisesRegex(RuntimeError, "14 MiB maximum"): + backend.recognize_audio(audio.name) + if __name__ == "__main__": unittest.main() diff --git a/test_performance_safety.py b/test_performance_safety.py index eaf3469..f5898b5 100644 --- a/test_performance_safety.py +++ b/test_performance_safety.py @@ -291,6 +291,21 @@ def test_voice_validates_wav_before_inference(self): app.BACKEND = previous_backend self.assertEqual(response.status_code, 400) + def test_cloud_provider_accepts_voice_uploads(self): + wav = b"RIFF" + (40).to_bytes(4, "little") + b"WAVE" + b"\0" * 36 + upload = upload_file(wav, "voice.wav") + with ( + patch.object(app, "BACKEND", "gemma-cloud"), + patch.object(app, "model_ready", return_value=True), + patch.object(app, "_run_audio", return_value="$x^2$") as run_audio, + ): + response = asyncio.run(app.voice(upload, thinking=False)) + health = app.health() + + self.assertEqual(response["latex"], "$x^2$") + self.assertTrue(health["audio_supported"]) + run_audio.assert_called_once() + if __name__ == "__main__": unittest.main() From bf6a49ddae9fd7348fb7a41ca5573b0a15c72661 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Wed, 15 Jul 2026 20:28:34 -0700 Subject: [PATCH 10/13] Release KevinTex 1.2.7 --- README.md | 12 ++++++------ packaging/build-deb.sh | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 01c3a67..4aa27a1 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ API key. Prebuilt packages are attached to each [GitHub release](https://github.com/EV3KevinDEV/KevinTex/releases): -- **Ubuntu:** `kevintex_1.2.6_all.deb` -- **Windows:** portable `KevinTex-1.2.6-windows-x64.zip` containing `KevinTex.exe` -- **Windows GPU:** `KevinTex-1.2.6-windows-x64-cuda.7z.001` or - `KevinTex-1.2.6-windows-x64-vulkan.zip` -- **Apple Silicon macOS:** `KevinTex-1.2.6-macOS-arm64.dmg` or `.zip` +- **Ubuntu:** `kevintex_1.2.7_all.deb` +- **Windows:** portable `KevinTex-1.2.7-windows-x64.zip` containing `KevinTex.exe` +- **Windows GPU:** `KevinTex-1.2.7-windows-x64-cuda.7z.001` or + `KevinTex-1.2.7-windows-x64-vulkan.zip` +- **Apple Silicon macOS:** `KevinTex-1.2.7-macOS-arm64.dmg` or `.zip` The Windows and macOS applications open in a native window. Local model weights are not bundled; if you choose the local provider, they download to the current @@ -74,7 +74,7 @@ Two options: ```bash packaging/build-deb.sh -sudo apt install ./dist/kevintex_1.2.6_all.deb +sudo apt install ./dist/kevintex_1.2.7_all.deb ``` **Current user only (no root):** diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index e756cc1..67eff4c 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -2,7 +2,7 @@ # Build kevintex__all.deb from the project sources. set -euo pipefail -VERSION="${KEVINTEX_VERSION:-1.2.6}" +VERSION="${KEVINTEX_VERSION:-1.2.7}" if [[ ! "$VERSION" =~ ^[0-9]+([.][0-9]+){1,3}$ ]]; then echo "KEVINTEX_VERSION must look like 1.2.3" >&2 exit 2 From 0575f6dca746f99b601ced7e3d3fa7c8d580acc8 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Wed, 15 Jul 2026 20:50:10 -0700 Subject: [PATCH 11/13] Release KevinTex 1.2.8 --- README.md | 17 ++--- packaging/build-deb.sh | 6 +- snip.py | 145 ++++++++++++++++++++++++++++++++++++++++- test_snip.py | 14 +++- 4 files changed, 167 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4aa27a1..1f078dd 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ API key. ## Features - **Snip button** (the computer-with-+ icon) — click it, drag a screen region, - and the capture is converted instantly. Fully self-contained (Pillow + - tkinter region selector); no `gnome-screenshot`/`flameshot`/portal needed. + and the capture is converted instantly. It uses Pillow + tkinter directly, + with optional `gnome-screenshot`, `grim`, `spectacle`, or `ffmpeg` fallbacks + when the desktop session blocks Pillow access. - **Paste (Ctrl+V), drag-and-drop, or browse** for a formula image (PNG/JPG/BMP/WEBP) - **Text sharpener** with persisted Off / Auto / Strong modes — conservative contrast normalization, smart upscaling, scan denoising, and edge sharpening @@ -55,11 +56,11 @@ API key. Prebuilt packages are attached to each [GitHub release](https://github.com/EV3KevinDEV/KevinTex/releases): -- **Ubuntu:** `kevintex_1.2.7_all.deb` -- **Windows:** portable `KevinTex-1.2.7-windows-x64.zip` containing `KevinTex.exe` -- **Windows GPU:** `KevinTex-1.2.7-windows-x64-cuda.7z.001` or - `KevinTex-1.2.7-windows-x64-vulkan.zip` -- **Apple Silicon macOS:** `KevinTex-1.2.7-macOS-arm64.dmg` or `.zip` +- **Ubuntu:** `kevintex_1.2.8_all.deb` +- **Windows:** portable `KevinTex-1.2.8-windows-x64.zip` containing `KevinTex.exe` +- **Windows GPU:** `KevinTex-1.2.8-windows-x64-cuda.7z.001` or + `KevinTex-1.2.8-windows-x64-vulkan.zip` +- **Apple Silicon macOS:** `KevinTex-1.2.8-macOS-arm64.dmg` or `.zip` The Windows and macOS applications open in a native window. Local model weights are not bundled; if you choose the local provider, they download to the current @@ -74,7 +75,7 @@ Two options: ```bash packaging/build-deb.sh -sudo apt install ./dist/kevintex_1.2.7_all.deb +sudo apt install ./dist/kevintex_1.2.8_all.deb ``` **Current user only (no root):** diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 67eff4c..51a09a7 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -2,7 +2,7 @@ # Build kevintex__all.deb from the project sources. set -euo pipefail -VERSION="${KEVINTEX_VERSION:-1.2.7}" +VERSION="${KEVINTEX_VERSION:-1.2.8}" if [[ ! "$VERSION" =~ ^[0-9]+([.][0-9]+){1,3}$ ]]; then echo "KEVINTEX_VERSION must look like 1.2.3" >&2 exit 2 @@ -46,8 +46,8 @@ Description: Formula-image to LaTeX converter (Snip & Get) Choose local Gemma 4 E2B-it weights through llama.cpp or the hosted Gemma 4 Google AI Studio provider on first launch. . - The snip tool is self-contained (Pillow + tkinter region selector), - no external screenshot utility required. + The snip tool uses Pillow and tkinter directly, with optional screenshot + backends as fallbacks when the desktop session blocks Pillow access. . On first launch the app creates a Python environment. The local provider installs an auto-detected CPU, CUDA, ROCm, Vulkan, or SYCL diff --git a/snip.py b/snip.py index 3e88c10..c3d181e 100644 --- a/snip.py +++ b/snip.py @@ -5,14 +5,20 @@ screenshot as the background, lets the user drag a selection rectangle, then crops and saves the selection as PNG. Prints the PNG path to stdout on success. -Works on X11 with no external screenshot tools (gnome-screenshot, flameshot, -maim, etc.) and no xdg-desktop-portal backend. Esc cancels. +Uses Pillow on X11 without requiring an external screenshot tool. If Pillow +cannot access the desktop, an installed Linux backend (such as gnome-screenshot, +grim, spectacle, or ffmpeg) is tried before failing. Esc cancels. Usage: snip.py """ import math +import os +import re +import shutil +import subprocess import sys +import tempfile from PIL import Image, ImageGrab, ImageTk import tkinter as tk @@ -65,6 +71,130 @@ def _capture_crop_box( ) +def _x11_screen_size() -> tuple[int, int] | None: + """Return the X11 root size when it can be queried without Tk.""" + if not os.environ.get("DISPLAY"): + return None + + for command in (("xrandr", "--current"), ("xdpyinfo",)): + if shutil.which(command[0]) is None: + continue + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + continue + if command[0] == "xrandr": + match = re.search(r"current\s+(\d+)\s*x\s*(\d+)", result.stdout) + else: + match = re.search(r"dimensions:\s*(\d+)x(\d+)\s+pixels", result.stdout) + if match: + return int(match.group(1)), int(match.group(2)) + return None + + +def _capture_with_command() -> Image.Image: + """Capture the desktop using an installed Linux screenshot backend. + + Pillow is the normal path. These fallbacks make the tool usable when the + Python process cannot access the desktop through Pillow, which is common + for Wayland sessions and some minimal X11 installations. + """ + candidates: list[tuple[str, list[str]]] = [] + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle: + path = handle.name + + try: + if shutil.which("gnome-screenshot"): + candidates.append(("gnome-screenshot", ["gnome-screenshot", "-f", path])) + if shutil.which("grim"): + candidates.append(("grim", ["grim", path])) + if shutil.which("spectacle"): + candidates.append( + ("spectacle", ["spectacle", "-b", "-n", "-o", path]) + ) + + screen_size = _x11_screen_size() + if shutil.which("ffmpeg") and screen_size: + width, height = screen_size + display = os.environ.get("DISPLAY", ":0") + candidates.append( + ( + "ffmpeg", + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + "x11grab", + "-video_size", + f"{width}x{height}", + "-i", + display, + "-frames:v", + "1", + "-y", + path, + ], + ) + ) + + errors: list[str] = [] + for name, command in candidates: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=20, + ) + except (OSError, subprocess.SubprocessError) as exc: + errors.append(f"{name}: {exc}") + continue + if result.returncode != 0: + detail = result.stderr.strip().splitlines()[-1:] or ["exit status %d" % result.returncode] + errors.append(f"{name}: {detail[0]}") + continue + try: + with Image.open(path) as image: + return image.convert("RGB") + except (OSError, ValueError) as exc: + errors.append(f"{name}: invalid image ({exc})") + + if candidates: + detail = "; ".join(errors) or "all backends failed" + else: + detail = "no Linux screenshot backend is installed" + raise RuntimeError(detail) + finally: + try: + os.remove(path) + except OSError: + pass + + +def _capture_screen() -> Image.Image: + """Capture the full desktop, with a useful fallback on Linux.""" + try: + return ImageGrab.grab().convert("RGB") + except Exception as pillow_error: + if not sys.platform.startswith("linux"): + raise RuntimeError(str(pillow_error)) from pillow_error + try: + return _capture_with_command() + except Exception as fallback_error: + raise RuntimeError( + f"Pillow capture failed ({pillow_error}); fallback failed ({fallback_error})" + ) from fallback_error + + def main(): if len(sys.argv) < 2: print("ERROR: missing output path", file=sys.stderr) @@ -72,7 +202,7 @@ def main(): out = sys.argv[1] try: - full = ImageGrab.grab().convert("RGB") + full = _capture_screen() except Exception as e: print(f"ERROR: screen grab failed: {e}", file=sys.stderr) sys.exit(1) @@ -83,8 +213,17 @@ def main(): display_w, display_h = display_size root.attributes("-fullscreen", True) + # The app is commonly running in a browser window. Make sure the selector + # is visible and receives the drag even when the window manager restores + # the browser as the active window after the POST is sent. + try: + root.attributes("-topmost", True) + except tk.TclError: + pass root.configure(bg="black", cursor="crosshair") root.geometry(f"{display_w}x{display_h}+0+0") + root.update_idletasks() + root.lift() canvas = tk.Canvas( root, width=display_w, height=display_h, highlightthickness=0 diff --git a/test_snip.py b/test_snip.py index d506573..9788c7d 100644 --- a/test_snip.py +++ b/test_snip.py @@ -1,8 +1,10 @@ """Screen-coordinate tests for the cross-platform snip overlay.""" import unittest +from unittest.mock import patch -from snip import _capture_crop_box, _display_size +from PIL import Image +from snip import _capture_crop_box, _capture_screen, _display_size class SnipCoordinateTests(unittest.TestCase): @@ -40,6 +42,16 @@ def test_non_macos_keeps_capture_dimensions(self): (1920, 1080), ) + def test_linux_capture_falls_back_when_pillow_cannot_access_desktop(self): + fallback = Image.new("RGB", (64, 32), "black") + with patch("snip.ImageGrab.grab", side_effect=OSError("desktop unavailable")): + with patch("snip._capture_with_command", return_value=fallback): + captured = _capture_screen() + try: + self.assertEqual(captured.size, (64, 32)) + finally: + captured.close() + if __name__ == "__main__": unittest.main() From 38de76653739f4675da77868a0e79b13b2a588f0 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Wed, 15 Jul 2026 20:53:05 -0700 Subject: [PATCH 12/13] Release KevinTex 1.2.9 --- README.md | 12 ++++++------ packaging/build-deb.sh | 2 +- test_snip.py | 9 ++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1f078dd..6d038d2 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ API key. Prebuilt packages are attached to each [GitHub release](https://github.com/EV3KevinDEV/KevinTex/releases): -- **Ubuntu:** `kevintex_1.2.8_all.deb` -- **Windows:** portable `KevinTex-1.2.8-windows-x64.zip` containing `KevinTex.exe` -- **Windows GPU:** `KevinTex-1.2.8-windows-x64-cuda.7z.001` or - `KevinTex-1.2.8-windows-x64-vulkan.zip` -- **Apple Silicon macOS:** `KevinTex-1.2.8-macOS-arm64.dmg` or `.zip` +- **Ubuntu:** `kevintex_1.2.9_all.deb` +- **Windows:** portable `KevinTex-1.2.9-windows-x64.zip` containing `KevinTex.exe` +- **Windows GPU:** `KevinTex-1.2.9-windows-x64-cuda.7z.001` or + `KevinTex-1.2.9-windows-x64-vulkan.zip` +- **Apple Silicon macOS:** `KevinTex-1.2.9-macOS-arm64.dmg` or `.zip` The Windows and macOS applications open in a native window. Local model weights are not bundled; if you choose the local provider, they download to the current @@ -75,7 +75,7 @@ Two options: ```bash packaging/build-deb.sh -sudo apt install ./dist/kevintex_1.2.8_all.deb +sudo apt install ./dist/kevintex_1.2.9_all.deb ``` **Current user only (no root):** diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 51a09a7..c261875 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -2,7 +2,7 @@ # Build kevintex__all.deb from the project sources. set -euo pipefail -VERSION="${KEVINTEX_VERSION:-1.2.8}" +VERSION="${KEVINTEX_VERSION:-1.2.9}" if [[ ! "$VERSION" =~ ^[0-9]+([.][0-9]+){1,3}$ ]]; then echo "KEVINTEX_VERSION must look like 1.2.3" >&2 exit 2 diff --git a/test_snip.py b/test_snip.py index 9788c7d..f617afb 100644 --- a/test_snip.py +++ b/test_snip.py @@ -44,9 +44,12 @@ def test_non_macos_keeps_capture_dimensions(self): def test_linux_capture_falls_back_when_pillow_cannot_access_desktop(self): fallback = Image.new("RGB", (64, 32), "black") - with patch("snip.ImageGrab.grab", side_effect=OSError("desktop unavailable")): - with patch("snip._capture_with_command", return_value=fallback): - captured = _capture_screen() + with patch("snip.sys.platform", "linux"): + with patch( + "snip.ImageGrab.grab", side_effect=OSError("desktop unavailable") + ): + with patch("snip._capture_with_command", return_value=fallback): + captured = _capture_screen() try: self.assertEqual(captured.size, (64, 32)) finally: From 667ebdaaa99e607f1784d4c1fcab6c059af7f538 Mon Sep 17 00:00:00 2001 From: EV3KevinDEV Date: Wed, 15 Jul 2026 21:00:07 -0700 Subject: [PATCH 13/13] Release KevinTex 1.2.10 --- .github/workflows/windows-release.yml | 4 +++ README.md | 12 ++++----- desktop.py | 8 ++++++ packaging/build-deb.sh | 2 +- packaging/windows/KevinTex.exe.config | 6 +++++ packaging/windows/README.md | 5 ++++ requirements-windows.txt | 6 +++-- test_desktop_acceleration.py | 37 +++++++++++++++++++++++++++ 8 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 packaging/windows/KevinTex.exe.config diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 7f4f179..7c11aa9 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -147,9 +147,13 @@ jobs: - name: Build native desktop application run: | pyinstaller --clean --noconfirm packaging/windows/kevintex.spec + Copy-Item -Force "packaging/windows/KevinTex.exe.config" "dist/KevinTex/KevinTex.exe.config" - name: Smoke-test packaged application run: | + if (-not (Test-Path "dist/KevinTex/KevinTex.exe.config")) { + throw "Packaged .NET application configuration is missing." + } $process = Start-Process -FilePath "dist/KevinTex/KevinTex.exe" -ArgumentList "--smoke-test" -PassThru -Wait if ($process.ExitCode -ne 0) { throw "Packaged application smoke test failed with exit code $($process.ExitCode)." diff --git a/README.md b/README.md index 6d038d2..27efa39 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ API key. Prebuilt packages are attached to each [GitHub release](https://github.com/EV3KevinDEV/KevinTex/releases): -- **Ubuntu:** `kevintex_1.2.9_all.deb` -- **Windows:** portable `KevinTex-1.2.9-windows-x64.zip` containing `KevinTex.exe` -- **Windows GPU:** `KevinTex-1.2.9-windows-x64-cuda.7z.001` or - `KevinTex-1.2.9-windows-x64-vulkan.zip` -- **Apple Silicon macOS:** `KevinTex-1.2.9-macOS-arm64.dmg` or `.zip` +- **Ubuntu:** `kevintex_1.2.10_all.deb` +- **Windows:** portable `KevinTex-1.2.10-windows-x64.zip` containing `KevinTex.exe` +- **Windows GPU:** `KevinTex-1.2.10-windows-x64-cuda.7z.001` or + `KevinTex-1.2.10-windows-x64-vulkan.zip` +- **Apple Silicon macOS:** `KevinTex-1.2.10-macOS-arm64.dmg` or `.zip` The Windows and macOS applications open in a native window. Local model weights are not bundled; if you choose the local provider, they download to the current @@ -75,7 +75,7 @@ Two options: ```bash packaging/build-deb.sh -sudo apt install ./dist/kevintex_1.2.9_all.deb +sudo apt install ./dist/kevintex_1.2.10_all.deb ``` **Current user only (no root):** diff --git a/desktop.py b/desktop.py index 3003da2..65a0c10 100644 --- a/desktop.py +++ b/desktop.py @@ -7,6 +7,7 @@ from __future__ import annotations +import importlib import os import socket import sys @@ -117,6 +118,12 @@ def server_thread_alive(server) -> bool: return not server.should_exit +def _verify_native_window_backend() -> None: + """Import the Windows GUI bridge so packaged smoke tests exercise .NET.""" + if sys.platform == "win32": + importlib.import_module("webview.platforms.winforms") + + def _run_snip_subprocess() -> bool: """Handle app.py relaunching the frozen executable for snip.py.""" if len(sys.argv) < 3 or Path(sys.argv[1]).name.lower() != "snip.py": @@ -130,6 +137,7 @@ def _run_snip_subprocess() -> bool: def _smoke_test() -> int: _configure_user_paths() + _verify_native_window_backend() _configure_llama_backend() import app diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index c261875..90c7fce 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -2,7 +2,7 @@ # Build kevintex__all.deb from the project sources. set -euo pipefail -VERSION="${KEVINTEX_VERSION:-1.2.9}" +VERSION="${KEVINTEX_VERSION:-1.2.10}" if [[ ! "$VERSION" =~ ^[0-9]+([.][0-9]+){1,3}$ ]]; then echo "KEVINTEX_VERSION must look like 1.2.3" >&2 exit 2 diff --git a/packaging/windows/KevinTex.exe.config b/packaging/windows/KevinTex.exe.config new file mode 100644 index 0000000..5aac7fd --- /dev/null +++ b/packaging/windows/KevinTex.exe.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/packaging/windows/README.md b/packaging/windows/README.md index d721881..4ea57d1 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -33,8 +33,13 @@ py -3.11 -m pip install -r requirements-windows.txt py packaging/windows/generate_icon.py py packaging/windows/generate_version_info.py v0.0.0 pyinstaller --clean --noconfirm packaging/windows/kevintex.spec +Copy-Item -Force packaging/windows/KevinTex.exe.config dist/KevinTex/KevinTex.exe.config ``` +The sidecar `.config` file allows the bundled Python.NET assembly to load when +Windows preserves the downloaded archive's web-origin security marker during +extraction. Keep it next to `KevinTex.exe` when distributing or moving the app. + Valid backend names are `cpu`, `cuda`, `vulkan`, `rocm`, and `sycl`. Vulkan requires the LunarG Vulkan SDK. ROCm requires AMD's Windows HIP SDK. SYCL must be built from an Intel oneAPI command prompt. HIP and oneAPI are local build diff --git a/requirements-windows.txt b/requirements-windows.txt index c2837a5..9b3a215 100644 --- a/requirements-windows.txt +++ b/requirements-windows.txt @@ -7,8 +7,10 @@ huggingface-hub google-genai>=2.11,<3 llama-cpp-python>=0.3.34 pillow -pyinstaller -pywebview +pyinstaller==6.21.0 +pywebview==6.2.1 +pythonnet==3.0.5 +clr-loader==0.2.7.post0 python-multipart torch uvicorn diff --git a/test_desktop_acceleration.py b/test_desktop_acceleration.py index 286da37..4ce4f0b 100644 --- a/test_desktop_acceleration.py +++ b/test_desktop_acceleration.py @@ -6,11 +6,48 @@ import unittest from pathlib import Path from unittest.mock import patch +from xml.etree import ElementTree import desktop class DesktopAccelerationTests(unittest.TestCase): + def test_windows_bundle_allows_web_marked_managed_assemblies(self): + config = Path(__file__).parent / "packaging/windows/KevinTex.exe.config" + setting = ElementTree.parse(config).find("./runtime/loadFromRemoteSources") + + self.assertIsNotNone(setting) + self.assertEqual(setting.attrib.get("enabled"), "true") + + def test_windows_interop_dependencies_are_reproducibly_pinned(self): + requirements = (Path(__file__).parent / "requirements-windows.txt").read_text( + encoding="utf-8" + ) + + for dependency in ( + "pyinstaller==6.21.0", + "pywebview==6.2.1", + "pythonnet==3.0.5", + "clr-loader==0.2.7.post0", + ): + self.assertIn(dependency, requirements) + + def test_windows_smoke_check_imports_winforms_backend(self): + with patch.object(desktop.sys, "platform", "win32"), patch( + "desktop.importlib.import_module" + ) as import_module: + desktop._verify_native_window_backend() + + import_module.assert_called_once_with("webview.platforms.winforms") + + def test_non_windows_smoke_check_skips_winforms_backend(self): + with patch.object(desktop.sys, "platform", "linux"), patch( + "desktop.importlib.import_module" + ) as import_module: + desktop._verify_native_window_backend() + + import_module.assert_not_called() + def test_cpu_is_safe_default(self): with tempfile.TemporaryDirectory() as bundle, patch.object( sys, "_MEIPASS", bundle, create=True