Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
1
src/desktop_app/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
Always use the shared theme under `src/desktop_app/themes.py`.
|
||||
53
src/desktop_app/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Jarvis Desktop App - System Tray Application
|
||||
|
||||
A cross-platform system tray app for controlling the Jarvis voice assistant.
|
||||
Supports Windows, Ubuntu (Linux), and macOS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Fix OpenBLAS threading crash in bundled apps
|
||||
# Must be set before numpy is imported (via faster-whisper, etc.)
|
||||
os.environ.setdefault('OPENBLAS_NUM_THREADS', '1')
|
||||
os.environ.setdefault('MKL_NUM_THREADS', '1')
|
||||
os.environ.setdefault('OMP_NUM_THREADS', '1')
|
||||
|
||||
# Re-export main for entry point
|
||||
from desktop_app.app import main
|
||||
|
||||
# Re-export commonly used components for backwards compatibility
|
||||
from desktop_app.app import (
|
||||
get_crash_paths,
|
||||
check_previous_crash,
|
||||
mark_session_started,
|
||||
mark_session_clean_exit,
|
||||
setup_crash_logging,
|
||||
show_crash_report_dialog,
|
||||
check_model_support,
|
||||
show_unsupported_model_dialog,
|
||||
acquire_single_instance_lock,
|
||||
JarvisSystemTray,
|
||||
LogViewerWindow,
|
||||
MemoryViewerWindow,
|
||||
LogSignals,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'main',
|
||||
'get_crash_paths',
|
||||
'check_previous_crash',
|
||||
'mark_session_started',
|
||||
'mark_session_clean_exit',
|
||||
'setup_crash_logging',
|
||||
'show_crash_report_dialog',
|
||||
'check_model_support',
|
||||
'show_unsupported_model_dialog',
|
||||
'acquire_single_instance_lock',
|
||||
'JarvisSystemTray',
|
||||
'LogViewerWindow',
|
||||
'MemoryViewerWindow',
|
||||
'LogSignals',
|
||||
]
|
||||
6
src/desktop_app/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for running desktop_app as a module: python -m desktop_app"""
|
||||
|
||||
from desktop_app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
2687
src/desktop_app/app.py
Normal file
159
src/desktop_app/cuda_recovery.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Recovery action for the GPU acceleration libraries on Windows.
|
||||
|
||||
The Inno Setup installer ships a PowerShell script (`install_cuda.ps1`) that
|
||||
downloads cuBLAS and cuDNN into `{app}\\cuda`. That step runs once during
|
||||
install and may fail silently — slow connections truncate the 643 MB cuDNN
|
||||
wheel, AV quarantines the unsigned engines DLL, the user dismisses a UAC
|
||||
prompt. When that happens the runtime probe in `jarvis.listening.listener`
|
||||
falls back to CPU and the only documented fix used to be "reinstall the app",
|
||||
which doesn't help because the `.cuda_installed` marker tricks the installer
|
||||
into skipping the CUDA step.
|
||||
|
||||
This module exposes a tray menu action that re-runs the installer script
|
||||
directly, with UAC elevation, so users can recover without touching the
|
||||
installer at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CudaRecoveryAction:
|
||||
label: str
|
||||
script_path: Path
|
||||
target_dir: Path
|
||||
executable: str
|
||||
arguments: list[str]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _has_nvidia_driver() -> bool:
|
||||
"""Match the Inno Setup HasNvidiaGPU check: nvcuda.dll in System32.
|
||||
|
||||
Cached because drivers don't appear or disappear during a process run.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
return Path(system_root, "System32", "nvcuda.dll").exists()
|
||||
|
||||
|
||||
def _powershell_executable() -> str:
|
||||
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
return str(
|
||||
Path(system_root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
)
|
||||
|
||||
|
||||
def cuda_recovery_action(install_root: Path) -> Optional[CudaRecoveryAction]:
|
||||
"""Return a recovery action if the host platform supports it.
|
||||
|
||||
`install_root` is the directory containing `install_cuda.ps1` (in
|
||||
bundled mode this is the directory next to the frozen executable).
|
||||
Returns `None` when:
|
||||
|
||||
- The platform isn't Windows.
|
||||
- No NVIDIA driver is detected (nothing to recover to).
|
||||
- The installer-bundled script is missing (dev runs from source).
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
if not _has_nvidia_driver():
|
||||
return None
|
||||
|
||||
script_path = Path(install_root) / "install_cuda.ps1"
|
||||
if not script_path.exists():
|
||||
return None
|
||||
|
||||
target_dir = Path(install_root) / "cuda"
|
||||
log_path = target_dir / "install.log"
|
||||
|
||||
arguments = [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script_path),
|
||||
"-TargetDir",
|
||||
str(target_dir),
|
||||
"-LogPath",
|
||||
str(log_path),
|
||||
]
|
||||
|
||||
return CudaRecoveryAction(
|
||||
label="🎮 Reinstall GPU libraries",
|
||||
script_path=script_path,
|
||||
target_dir=target_dir,
|
||||
executable=_powershell_executable(),
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
|
||||
def _shell_execute(hwnd: int, verb: str, file: str, params: str, directory: str, show: int) -> int:
|
||||
"""Thin wrapper over ShellExecuteW so tests can patch it without dragging in ctypes."""
|
||||
import ctypes
|
||||
|
||||
return int(
|
||||
ctypes.windll.shell32.ShellExecuteW(hwnd, verb, file, params, directory, show)
|
||||
)
|
||||
|
||||
|
||||
def _quote_arg(arg: str) -> str:
|
||||
"""Quote a single argument for ShellExecuteW's lpParameters string.
|
||||
|
||||
Windows argv parsing (CommandLineToArgvW) treats a backslash run only as
|
||||
an escape when it precedes a quote: 2n backslashes + " emits n
|
||||
backslashes and ends the quoted string; 2n+1 emits n + a literal ".
|
||||
A trailing backslash inside `"..."` therefore swallows the closing
|
||||
quote unless it is doubled. Doubling every trailing backslash is the
|
||||
canonical fix and is what argv parsers expect.
|
||||
"""
|
||||
if not arg:
|
||||
return '""'
|
||||
if not any(ch in arg for ch in (" ", "\t", '"')):
|
||||
return arg
|
||||
|
||||
out: list[str] = ['"']
|
||||
i = 0
|
||||
while i < len(arg):
|
||||
bs = 0
|
||||
while i < len(arg) and arg[i] == "\\":
|
||||
bs += 1
|
||||
i += 1
|
||||
if i == len(arg):
|
||||
out.append("\\" * (bs * 2))
|
||||
break
|
||||
if arg[i] == '"':
|
||||
out.append("\\" * (bs * 2 + 1))
|
||||
out.append('"')
|
||||
else:
|
||||
out.append("\\" * bs)
|
||||
out.append(arg[i])
|
||||
i += 1
|
||||
out.append('"')
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def run_action(action: CudaRecoveryAction) -> bool:
|
||||
"""Launch the recovery script with UAC elevation.
|
||||
|
||||
`install_cuda.ps1` writes into `Program Files\\Jarvis\\cuda`, which a
|
||||
standard user account cannot write to. ShellExecuteW with the `runas`
|
||||
verb triggers the UAC prompt; without it the script silently fails
|
||||
its first file write and the user is no better off than before.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
|
||||
params = " ".join(_quote_arg(a) for a in action.arguments)
|
||||
rc = _shell_execute(0, "runas", action.executable, params, str(action.target_dir.parent), 1)
|
||||
# ShellExecuteW returns >32 on success; <=32 means an error code (e.g.
|
||||
# SE_ERR_ACCESSDENIED 5 when the user dismisses the UAC prompt).
|
||||
return rc > 32
|
||||
287
src/desktop_app/desktop_app.spec.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Desktop App Specification
|
||||
|
||||
This document outlines the architecture and behavior of the Jarvis Desktop App - a cross-platform PyQt6 system tray application that provides a graphical interface for the Jarvis voice assistant.
|
||||
|
||||
## Overview
|
||||
|
||||
The desktop app is a **separate package** from the core `jarvis` module. It depends on `jarvis` for assistant functionality but `jarvis` has no knowledge of or dependency on the desktop app. This separation allows:
|
||||
|
||||
- Running Jarvis headless (CLI/daemon only)
|
||||
- Building alternative UIs (web, mobile) without modifying core logic
|
||||
- Keeping PyQt6 dependencies isolated from the core package
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
src/desktop_app/
|
||||
├── __init__.py # Package exports, main() entry point
|
||||
├── app.py # JarvisSystemTray, windows, startup flow
|
||||
├── splash_screen.py # Animated startup splash
|
||||
├── setup_wizard.py # First-run setup wizard
|
||||
├── settings_window.py # Auto-generated settings UI from config metadata
|
||||
├── face_widget.py # Animated face visualization
|
||||
├── themes.py # Qt stylesheets and color palette
|
||||
├── diary_dialog.py # End-of-session diary update dialog
|
||||
├── memory_viewer.py # Flask-based memory browser
|
||||
├── updater.py # Update checking logic
|
||||
├── update_dialog.py # Update notification dialogs
|
||||
└── desktop_assets/ # Icons and images
|
||||
```
|
||||
|
||||
## Startup Flow
|
||||
|
||||
The startup sequence ensures a smooth user experience even when dependencies (like Ollama) aren't ready.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Launch App] --> B[Single Instance Check]
|
||||
B -->|Already Running| B2[Show Conflict Dialog]
|
||||
B2 -->|User: Exit| Z[Exit]
|
||||
B2 -->|User: Kill Existing| B3[Terminate Old Instance]
|
||||
B3 --> B4[Retry Lock]
|
||||
B4 -->|Failed| Z
|
||||
B4 -->|OK| C
|
||||
B -->|OK| C[Show Splash Screen]
|
||||
C --> D{Setup Completed Before?}
|
||||
D -->|No| E[Show Setup Wizard]
|
||||
D -->|Yes| F{Ollama Running?}
|
||||
E --> F
|
||||
F -->|No| G[Auto-Start Ollama]
|
||||
G --> H[Wait for Ollama]
|
||||
H --> I{Started?}
|
||||
I -->|No, Timeout| J[Continue Anyway]
|
||||
I -->|Yes| K[Check Model Support]
|
||||
F -->|Yes| K
|
||||
J --> K
|
||||
K -->|Unsupported| L[Show Warning Dialog]
|
||||
K -->|OK| M[Initialize Tray]
|
||||
L --> M
|
||||
M --> N[Start Daemon Thread]
|
||||
N --> O[Close Splash]
|
||||
O --> P[Enter Qt Event Loop]
|
||||
```
|
||||
|
||||
### Key Startup Features
|
||||
|
||||
1. **Splash Screen**: Shows immediately to provide visual feedback while loading
|
||||
2. **Ollama Auto-Start**: If Ollama isn't running, automatically starts it (up to 15s wait)
|
||||
3. **Single Instance Lock**: Prevents multiple copies from running simultaneously. If another instance is detected, shows a dialog offering to close the existing instance and start fresh.
|
||||
4. **Crash Detection**: Detects previous crashes and offers to submit bug reports
|
||||
|
||||
## Main Components
|
||||
|
||||
### JarvisSystemTray
|
||||
|
||||
The central controller that manages:
|
||||
|
||||
- **System tray icon** with context menu
|
||||
- **Daemon lifecycle** (start/stop the Jarvis voice assistant)
|
||||
- **Window management** (log viewer, memory viewer, face window)
|
||||
- **Update checking** on startup and on-demand
|
||||
|
||||
### Windows
|
||||
|
||||
| Window | Purpose |
|
||||
|--------|---------|
|
||||
| **LogViewerWindow** | Real-time log output from the daemon, with "Report Issue" button |
|
||||
| **MemoryViewerWindow** | Web-based memory browser (Flask server) |
|
||||
| **FaceWindow** | Animated face that reacts to speaking state |
|
||||
| **SettingsWindow** | Auto-generated config editor with tabbed categories |
|
||||
| **SetupWizard** | First-run configuration (Ollama, models, profile) |
|
||||
| **DictationHistoryWindow** | Scrollable list of past dictations with copy/delete/clear actions |
|
||||
|
||||
### Tray Menu: GPU Library Recovery (Windows)
|
||||
|
||||
`cuda_recovery.py` exposes the `🎮 Reinstall GPU libraries` action. The tray adds it only when running on Windows, an NVIDIA driver is detected (`%SystemRoot%\System32\nvcuda.dll` exists), and the bundled `install_cuda.ps1` script is on disk. Clicking it confirms with the user, then re-runs `install_cuda.ps1` via `ShellExecuteW` with the `runas` verb so UAC elevates the process before it writes into `Program Files\Jarvis\cuda`. This is the only user-facing recovery path when the original Inno Setup install of cuBLAS/cuDNN fails — the installer's own task fires once per install and the script's marker file used to make subsequent reinstalls skip the CUDA step. The runtime probe in `jarvis.listening.listener._print_cuda_unavailable_hint` points users at this action by name when it falls back to CPU.
|
||||
|
||||
The Inno Setup script also runs a `VerifyCudaInstall` hook after the CUDA download task completes. The hook checks for the `.cuda_installed` marker (which `install_cuda.ps1` only writes after every expected DLL is present and SHA-verified) and surfaces a `MsgBox` pointing at `{app}\cuda\install.log` and the tray recovery action when the marker is missing. This is what makes a hidden install failure visible to the user instead of letting the installer report success on a half-installed CUDA tree.
|
||||
|
||||
### DictationHistoryWindow Behaviour
|
||||
|
||||
- **Backing store**: File-backed via `DictationHistory` (`src/jarvis/dictation/history.py`); entries are newest-first with `id`, `text`, `timestamp`, `duration`. Disk is the source of truth — the window must not assume its in-memory instance is authoritative.
|
||||
- **Hidden windows are inert**: Signals from the dictation engine must not mutate the widget tree while the window is hidden; pending entries are surfaced on next open instead. The engine persists entries regardless, so no data is lost.
|
||||
- **On show, reload from disk and rebuild**: The window reads disk state on every show, because the daemon may be in a separate process (subprocess mode) or may have recorded entries while the window was hidden (bundled mode). In-memory state alone is not trusted.
|
||||
- **While visible, poll for external writes**: A short interval timer watches the history file's mtime and reloads on change so subprocess-mode dictations appear without requiring a re-open.
|
||||
- **Rebuilds replace the container**: `_reload()` builds a fresh list container and installs it into the scroll area via `takeWidget()` + `setWidget()`; the previous container is hidden and `deleteLater()`'d. This atomic swap sidesteps every class of orphan-during-paint issue that surgical layout edits invite.
|
||||
- **Reload deferred off showEvent**: `showEvent` schedules the rebuild via `QTimer.singleShot(0, ...)` rather than mutating the widget tree inline, so the first paint pass sees a stable tree.
|
||||
- **No emoji codepoints in `strftime` format strings**: On Windows with the bundled Python 3.11, `datetime.strftime` routes through the C locale encoder and raises `UnicodeEncodeError` on non-BMP codepoints (e.g. 📅). When that exception escapes a Qt slot invocation, Qt6Core triggers a fast-fail (0xc0000409) and the whole app dies. Build timestamp labels by interpolating emoji outside `strftime`.
|
||||
|
||||
### LogViewerWindow Features
|
||||
|
||||
- Real-time log streaming from daemon
|
||||
- Monospace font for readability (JetBrains Mono on macOS, Consolas elsewhere)
|
||||
- **Report Issue button**: Opens GitHub issue with:
|
||||
- Pre-filled bug report template
|
||||
- Auto-redacted log contents (emails, tokens, JWTs, passwords, etc.)
|
||||
- Logs in collapsible `<details>` section
|
||||
- Version and platform info
|
||||
- Log truncation preserves the init section (everything up to the last `─`×50 separator) + recent tail (most useful for debugging); middle lines are truncated
|
||||
|
||||
### Splash Screen
|
||||
|
||||
Animated loading screen shown during startup with:
|
||||
|
||||
- Pulsing orb animation (matches theme colors)
|
||||
- Status text updates ("Checking Ollama...", "Starting daemon...")
|
||||
- Frameless, centered, always-on-top
|
||||
|
||||
## Daemon Integration
|
||||
|
||||
The desktop app runs the Jarvis daemon in a **QThread** (bundled mode) or **subprocess** (development mode).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Desktop App (Main Thread) │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Qt Event Loop │ │
|
||||
│ │ - Tray icon interactions │ │
|
||||
│ │ - Window management │ │
|
||||
│ │ - Signal/slot communication │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ signals │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ DaemonThread (QThread) │ │
|
||||
│ │ - Runs jarvis.daemon.main() │ │
|
||||
│ │ - Captures stdout/stderr │ │
|
||||
│ │ - Emits logs to LogViewer │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Daemon Callbacks
|
||||
|
||||
The desktop app registers callbacks with the daemon for:
|
||||
|
||||
- **Diary updates**: Shows DiaryUpdateDialog when session ends
|
||||
- **Clean shutdown**: Ensures graceful exit with diary save
|
||||
|
||||
#### Bundled Mode (QThread)
|
||||
|
||||
In bundled mode, the daemon runs in the same process, so callbacks can be set directly via `set_diary_update_callbacks()`. The DiaryUpdateDialog receives:
|
||||
- `on_chunks`: List of conversation chunks being summarized
|
||||
- `on_token`: Streaming tokens as the diary is generated
|
||||
- `on_status`: Status messages ("Writing diary entry...")
|
||||
- `on_complete`: Completion signal (success/failure)
|
||||
|
||||
#### Subprocess Mode (Development)
|
||||
|
||||
In subprocess mode, the daemon runs as a separate process. IPC is achieved via stdout:
|
||||
- Daemon emits JSON events prefixed with `__DIARY__:` (e.g., `__DIARY__:{"type":"token","data":"Hello"}`)
|
||||
- Desktop app intercepts these lines from the log stream
|
||||
- DiaryUpdateDialog's `process_log_line()` parses and emits signals
|
||||
- Same UI experience as bundled mode
|
||||
|
||||
## Theme System
|
||||
|
||||
All UI components use a consistent dark theme defined in `themes.py`:
|
||||
|
||||
```python
|
||||
COLORS = {
|
||||
"bg_primary": "#09090b", # Deep space black
|
||||
"bg_secondary": "#18181b", # Slightly lighter
|
||||
"accent_primary": "#f59e0b", # Amber
|
||||
"accent_secondary": "#fbbf24", # Lighter amber
|
||||
"text_primary": "#fafafa", # White
|
||||
"text_secondary": "#a1a1aa", # Muted
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Components use `JARVIS_THEME_STYLESHEET` for consistent styling across all dialogs and windows.
|
||||
|
||||
## Update System
|
||||
|
||||
The desktop app includes an auto-update mechanism:
|
||||
|
||||
1. **Check**: Queries GitHub releases API for newer versions
|
||||
2. **Notify**: Shows dialog with changelog and download option
|
||||
3. **Download**: Downloads new installer with progress bar
|
||||
4. **Install**: Platform-specific installation (see below)
|
||||
|
||||
Updates are only available in bundled mode (PyInstaller builds).
|
||||
|
||||
### Platform-Specific Update Installation
|
||||
|
||||
| Platform | Strategy |
|
||||
|----------|----------|
|
||||
| **macOS** | Extracts the update zip with `ditto -x -k` (Python's `zipfile` drops the symlinks Qt/Qt WebEngine frameworks rely on, producing a bundle macOS refuses to launch with "Jarvis.app can't be opened"; the release workflow creates the zip with the matching `ditto -c -k --keepParent`). Falls back to `zipfile.extractall` only when `/usr/bin/ditto` is missing — i.e. unit tests on Linux CI; production macOS always ships ditto, so the fallback never runs in the field. Then creates a shell script that waits for the current process (by PID via `kill -0`) to exit, moves the old `.app` aside to `Jarvis.app.backup` (one-generation rollback), moves the new bundle in, strips `com.apple.quarantine` so Gatekeeper doesn't re-prompt on unsigned builds, re-registers the swapped bundle with `lsregister -f` (LaunchServices caches the old inode across the `mv` and a bare `open` silently no-ops otherwise), relaunches with `open -n`, and falls back to execing the bundle's inner binary via `nohup` if `open` fails. Script output is captured to `~/Library/Logs/Jarvis/updater.log` (size-capped) so detached failures leave a diagnostic trail. The executable name is read from the new bundle's `CFBundleExecutable`, not hardcoded. No Finder/AppleScript automation. Pattern mirrors Squirrel.Mac's `ShipIt` helper. |
|
||||
| **Windows** | Creates a batch script that waits for the current process (by PID via `tasklist`) to exit, then runs the Inno Setup installer with `/SILENT` so the installer's own progress window provides visual feedback during install, then relaunches the upgraded exe. Rollback is handled by Inno Setup's own in-session rollback + retained uninstaller data. |
|
||||
| **Linux** | Creates a shell script that waits for the current process (by PID via `kill -0`) to exit, moves the old directory to `Jarvis.backup` for rollback, moves the new directory in, and relaunches |
|
||||
|
||||
### Update Flow (Windows/Linux)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Current App
|
||||
participant Batch as Batch Script
|
||||
participant New as New App
|
||||
|
||||
App->>App: Download update zip
|
||||
App->>App: Save diary (pre-install callback)
|
||||
App->>App: Extract to temp dir
|
||||
App->>App: Create batch script (with current PID)
|
||||
App->>App: Save asset ID to track update
|
||||
App->>Batch: Launch batch script
|
||||
App->>App: Exit quickly (diary already saved)
|
||||
Batch->>Batch: Wait for PID to exit (tasklist loop)
|
||||
Batch->>Batch: Delete old executable
|
||||
Batch->>Batch: Move new executable in place
|
||||
Batch->>New: Launch new app
|
||||
Batch->>Batch: Clean up temp directory
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Diary is saved before update installation**: The `pre_install_callback` mechanism ensures the diary is saved before the update process begins, so no data is lost
|
||||
- **Asset ID tracking**: For develop channel updates (where version stays "latest"), we track the GitHub asset ID to detect new builds
|
||||
- **Robust Windows update**: The batch script waits for the actual process to exit (by PID) rather than using a fixed timeout, ensuring the update doesn't fail due to slow shutdown
|
||||
- **Visible Windows install progress**: The Inno Setup installer runs with `/SILENT` (not `/VERYSILENT`) so its own progress window is visible while the install runs — bridging the gap between the download dialog closing and the new app launching, which would otherwise look like a hang
|
||||
- **Quarantine stripping (macOS)**: The shell script runs `xattr -dr com.apple.quarantine` on the newly-installed bundle. Builds are unsigned (ad-hoc signing breaks Qt WebEngine's symlinks — see `release.yml`), so without this step Gatekeeper may re-trigger the "unidentified developer" prompt on every update
|
||||
- **One-generation rollback (macOS, Linux)**: The previous `.app` / directory is moved aside to `<name>.backup` rather than deleted outright, so a user can restore the prior version manually if the new one fails to launch. The backup from the previous update is cleared before creating a new one, so at most one backup exists on disk at a time. This is a simplified version of Squirrel's versioned-folder rollback — enough safety for a single-bundle install, without the architectural overhead
|
||||
|
||||
## Memory Viewer
|
||||
|
||||
A Flask-based web interface for browsing conversation history:
|
||||
|
||||
- Runs on `localhost:5050`
|
||||
- **Bundled mode**: Flask runs in a daemon thread
|
||||
- **Development mode**: Flask runs as subprocess
|
||||
- Opens in embedded QWebEngineView or system browser (macOS fallback)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Crash Detection
|
||||
|
||||
1. On startup, creates a `.crash_marker` file
|
||||
2. On clean exit, removes the marker
|
||||
3. On next startup, if marker exists → previous session crashed
|
||||
4. Offers to submit crash report to GitHub Issues
|
||||
|
||||
### Fallbacks
|
||||
|
||||
- **No Ollama**: Shows setup wizard or auto-starts
|
||||
- **No WebEngine**: Opens memory viewer in system browser
|
||||
- **Model not supported**: Warning dialog with option to change
|
||||
- **Update failed**: Error dialog with details
|
||||
|
||||
## Platform-Specific Behavior
|
||||
|
||||
| Feature | macOS | Windows | Linux |
|
||||
|---------|-------|---------|-------|
|
||||
| Tray icon | Native menu bar | System tray | System tray |
|
||||
| Ollama start | `open -a Ollama` | `ollama serve` (hidden) | `ollama serve` |
|
||||
| Crash logs | `~/Library/Logs/Jarvis` | `%LOCALAPPDATA%\Jarvis` | `~/.jarvis` |
|
||||
| Memory viewer | System browser* | Embedded WebEngine | Embedded WebEngine |
|
||||
|
||||
*macOS bundled apps use system browser due to QtWebEngine sandbox issues.
|
||||
|
||||
## File Locations
|
||||
|
||||
| File | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| Config | `~/.config/jarvis/` | `%APPDATA%\jarvis\` | `~/.config/jarvis/` |
|
||||
| Database | `~/.local/share/jarvis/` | `%LOCALAPPDATA%\jarvis\` | `~/.local/share/jarvis/` |
|
||||
| Crash logs | `~/Library/Logs/Jarvis/` | `%LOCALAPPDATA%\Jarvis\` | `~/.jarvis/` |
|
||||
| Instance lock | `~/Library/Application Support/Jarvis/` | `%LOCALAPPDATA%\Jarvis\` | `~/.jarvis/` |
|
||||
92
src/desktop_app/desktop_assets/generate_icons.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Generate simple icons for the Jarvis desktop app.
|
||||
This creates idle and listening state icons.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def create_icon(color: str, filename: str, size: int = 256) -> None:
|
||||
"""Create a simple circular icon with a 'J' letter."""
|
||||
# Create image with transparency
|
||||
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw circle
|
||||
margin = size // 8
|
||||
draw.ellipse(
|
||||
[(margin, margin), (size - margin, size - margin)],
|
||||
fill=color,
|
||||
outline=None
|
||||
)
|
||||
|
||||
# Draw letter J
|
||||
try:
|
||||
# Try to use a nice font
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size // 2)
|
||||
except OSError:
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", size // 2)
|
||||
except OSError:
|
||||
# Fallback to default
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = "J"
|
||||
# Get text bounding box
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Center the text
|
||||
x = (size - text_width) // 2 - bbox[0]
|
||||
y = (size - text_height) // 2 - bbox[1]
|
||||
|
||||
draw.text((x, y), text, fill='white', font=font)
|
||||
|
||||
# Save in multiple sizes for better cross-platform support
|
||||
img.save(filename)
|
||||
|
||||
# Also save smaller versions
|
||||
for icon_size in [16, 32, 48, 64, 128]:
|
||||
resized = img.resize((icon_size, icon_size), Image.Resampling.LANCZOS)
|
||||
resized.save(filename.replace('.png', f'_{icon_size}.png'))
|
||||
|
||||
# Create .ico file for Windows (multiple sizes in one file)
|
||||
ico_sizes = [16, 32, 48, 64, 128, 256]
|
||||
ico_images = [img.resize((s, s), Image.Resampling.LANCZOS) for s in ico_sizes]
|
||||
ico_filename = filename.replace('.png', '.ico')
|
||||
# Save ICO with multiple sizes - PIL handles multi-size ICO via append_images
|
||||
ico_images[-1].save(
|
||||
ico_filename,
|
||||
format='ICO',
|
||||
append_images=ico_images[:-1]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Fix Windows console encoding for emojis
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
# Try to set UTF-8 encoding for Windows console
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get the directory where this script is located
|
||||
script_dir = Path(__file__).parent
|
||||
|
||||
# Create idle icon (gray)
|
||||
create_icon('#9E9E9E', str(script_dir / 'icon_idle.png'))
|
||||
print("Created icon_idle.png")
|
||||
|
||||
# Create listening icon (green)
|
||||
create_icon('#4CAF50', str(script_dir / 'icon_listening.png'))
|
||||
print("Created icon_listening.png")
|
||||
|
||||
print("\nIcon generation complete!")
|
||||
|
||||
BIN
src/desktop_app/desktop_assets/icon_idle.ico
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/desktop_app/desktop_assets/icon_idle.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/desktop_app/desktop_assets/icon_idle_128.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src/desktop_app/desktop_assets/icon_idle_16.png
Normal file
|
After Width: | Height: | Size: 680 B |
BIN
src/desktop_app/desktop_assets/icon_idle_32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/desktop_app/desktop_assets/icon_idle_48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/desktop_app/desktop_assets/icon_idle_64.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening.ico
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening_128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening_16.png
Normal file
|
After Width: | Height: | Size: 686 B |
BIN
src/desktop_app/desktop_assets/icon_listening_32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening_48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/desktop_app/desktop_assets/icon_listening_64.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
228
src/desktop_app/diary_dialog.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Diary update dialog shown during shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QTextEdit, QProgressBar, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from .themes import JARVIS_THEME_STYLESHEET, COLORS
|
||||
|
||||
# IPC protocol prefix - must match daemon.py
|
||||
DIARY_IPC_PREFIX = "__DIARY__:"
|
||||
|
||||
|
||||
class DiarySignals(QObject):
|
||||
"""Signals for diary update progress."""
|
||||
# Emitted when a new token is received from LLM
|
||||
token_received = pyqtSignal(str)
|
||||
# Emitted when status changes (e.g., "Analyzing conversations...")
|
||||
status_changed = pyqtSignal(str)
|
||||
# Emitted when conversation chunks are available
|
||||
chunks_received = pyqtSignal(list)
|
||||
# Emitted when the diary update completes
|
||||
completed = pyqtSignal(bool) # True = success, False = failed/skipped
|
||||
|
||||
|
||||
class DiaryUpdateDialog(QDialog):
|
||||
"""
|
||||
Dialog shown during shutdown diary update.
|
||||
|
||||
Shows:
|
||||
- The conversation chunks being processed
|
||||
- Live streaming of the diary entry being written
|
||||
- Progress indication
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.signals = DiarySignals()
|
||||
self._setup_ui()
|
||||
self._connect_signals()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
self.setWindowTitle("Saving Your Diary")
|
||||
self.setMinimumSize(550, 450)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Dialog |
|
||||
Qt.WindowType.CustomizeWindowHint |
|
||||
Qt.WindowType.WindowTitleHint
|
||||
)
|
||||
|
||||
# Apply the shared Jarvis theme
|
||||
self.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
|
||||
# Title
|
||||
title = QLabel("Updating Your Diary")
|
||||
title.setObjectName("title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel("Preparing to save...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setObjectName("subtitle")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress bar (indeterminate)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setFixedHeight(6)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Conversations section
|
||||
conv_label = QLabel("Today's Conversations")
|
||||
conv_label.setObjectName("section_title")
|
||||
layout.addWidget(conv_label)
|
||||
|
||||
self.conversations_text = QTextEdit()
|
||||
self.conversations_text.setReadOnly(True)
|
||||
self.conversations_text.setMaximumHeight(100)
|
||||
self.conversations_text.setPlaceholderText("Loading conversations...")
|
||||
layout.addWidget(self.conversations_text)
|
||||
|
||||
# Diary entry section
|
||||
diary_label = QLabel("Diary Entry")
|
||||
diary_label.setObjectName("section_title")
|
||||
layout.addWidget(diary_label)
|
||||
|
||||
self.diary_text = QTextEdit()
|
||||
self.diary_text.setReadOnly(True)
|
||||
self.diary_text.setPlaceholderText("Writing diary entry...")
|
||||
layout.addWidget(self.diary_text, stretch=1)
|
||||
|
||||
# Hint at bottom
|
||||
hint = QLabel("Please wait while Jarvis saves your conversations...")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint.setObjectName("subtitle")
|
||||
layout.addWidget(hint)
|
||||
|
||||
def _connect_signals(self):
|
||||
"""Connect internal signals."""
|
||||
self.signals.token_received.connect(self._on_token)
|
||||
self.signals.status_changed.connect(self._on_status_changed)
|
||||
self.signals.chunks_received.connect(self._on_chunks_received)
|
||||
self.signals.completed.connect(self._on_completed)
|
||||
|
||||
def _on_chunks_received(self, chunks: list):
|
||||
"""Handle receiving conversation chunks."""
|
||||
self.set_conversations(chunks)
|
||||
|
||||
def _on_token(self, token: str):
|
||||
"""Handle receiving a token from the LLM."""
|
||||
# Append token to diary text
|
||||
cursor = self.diary_text.textCursor()
|
||||
cursor.movePosition(cursor.MoveOperation.End)
|
||||
cursor.insertText(token)
|
||||
self.diary_text.setTextCursor(cursor)
|
||||
# Auto-scroll to bottom
|
||||
scrollbar = self.diary_text.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _on_status_changed(self, status: str):
|
||||
"""Handle status change."""
|
||||
self.status_label.setText(status)
|
||||
|
||||
def _on_completed(self, success: bool):
|
||||
"""Handle completion."""
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(100)
|
||||
if success:
|
||||
self.status_label.setText("Diary saved successfully!")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['success']};")
|
||||
else:
|
||||
self.status_label.setText("No new entries to save")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['text_muted']};")
|
||||
# Clear placeholders if nothing was populated
|
||||
if not self.conversations_text.toPlainText():
|
||||
self.conversations_text.setPlainText("(No conversations to save)")
|
||||
if not self.diary_text.toPlainText():
|
||||
self.diary_text.setPlainText("(Nothing to write)")
|
||||
|
||||
def set_conversations(self, chunks: List[str]):
|
||||
"""Set the conversation chunks being processed."""
|
||||
if not chunks:
|
||||
self.conversations_text.setPlainText("(No conversations to save)")
|
||||
return
|
||||
|
||||
# Format chunks nicely
|
||||
formatted = []
|
||||
for i, chunk in enumerate(chunks[-5:], 1): # Show last 5 chunks
|
||||
# Truncate long chunks
|
||||
preview = chunk[:200] + "..." if len(chunk) > 200 else chunk
|
||||
# Clean up whitespace
|
||||
preview = " ".join(preview.split())
|
||||
formatted.append(f"{i}. {preview}")
|
||||
|
||||
self.conversations_text.setPlainText("\n\n".join(formatted))
|
||||
|
||||
def set_diary_content(self, content: str):
|
||||
"""Set the diary content (for non-streaming updates)."""
|
||||
self.diary_text.setPlainText(content)
|
||||
|
||||
def append_diary_token(self, token: str):
|
||||
"""Append a token to the diary content (for streaming)."""
|
||||
self.signals.token_received.emit(token)
|
||||
|
||||
def set_status(self, status: str):
|
||||
"""Update the status message."""
|
||||
self.signals.status_changed.emit(status)
|
||||
|
||||
def mark_completed(self, success: bool = True):
|
||||
"""Mark the update as completed."""
|
||||
self.signals.completed.emit(success)
|
||||
|
||||
def process_log_line(self, line: str) -> bool:
|
||||
"""
|
||||
Process a log line, checking if it contains an IPC event.
|
||||
|
||||
Used in subprocess mode where the daemon emits diary events via stdout.
|
||||
|
||||
Args:
|
||||
line: A log line from the daemon
|
||||
|
||||
Returns:
|
||||
True if the line was an IPC event and was processed, False otherwise
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line.startswith(DIARY_IPC_PREFIX):
|
||||
return False
|
||||
|
||||
try:
|
||||
import json
|
||||
json_str = line[len(DIARY_IPC_PREFIX):]
|
||||
event = json.loads(json_str)
|
||||
event_type = event.get("type")
|
||||
data = event.get("data")
|
||||
|
||||
if event_type == "chunks":
|
||||
self.signals.chunks_received.emit(data)
|
||||
elif event_type == "token":
|
||||
self.signals.token_received.emit(data)
|
||||
elif event_type == "status":
|
||||
self.signals.status_changed.emit(data)
|
||||
elif event_type == "complete":
|
||||
self.signals.completed.emit(data)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def set_subprocess_mode(self):
|
||||
"""
|
||||
Configure dialog for subprocess mode.
|
||||
|
||||
In subprocess mode, the daemon emits IPC events via stdout which are
|
||||
intercepted and forwarded to this dialog via process_log_line().
|
||||
"""
|
||||
# Initial state - will be updated when IPC events arrive
|
||||
self.conversations_text.setPlaceholderText("Waiting for daemon...")
|
||||
self.diary_text.setPlaceholderText("Waiting for diary generation...")
|
||||
410
src/desktop_app/dictation_history.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
🎙️ Dictation History Window
|
||||
|
||||
Displays past dictation results in a scrollable list with copy and delete
|
||||
actions. Follows the same visual pattern as the Log Viewer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QScrollArea, QFrame, QApplication,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from desktop_app.themes import JARVIS_THEME_STYLESHEET, COLORS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals for thread-safe updates from the dictation engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DictationHistorySignals(QObject):
|
||||
"""Signals emitted when a new dictation entry arrives."""
|
||||
new_entry = pyqtSignal(dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual history card widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CARD_STYLE = f"""
|
||||
QFrame#dictation_card {{
|
||||
background-color: {COLORS['bg_card']};
|
||||
border: 1px solid {COLORS['border']};
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}}
|
||||
QFrame#dictation_card:hover {{
|
||||
border-color: {COLORS['accent_primary']};
|
||||
}}
|
||||
"""
|
||||
|
||||
_BTN_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #27272a;
|
||||
color: #fafafa;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3f3f46;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
"""
|
||||
|
||||
_DELETE_BTN_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #27272a;
|
||||
color: #ef4444;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3f3f46;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class _DictationCard(QFrame):
|
||||
"""A single dictation history entry."""
|
||||
|
||||
deleted = pyqtSignal(str) # entry ID
|
||||
|
||||
def __init__(self, entry: Dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
self._entry = entry
|
||||
self.setObjectName("dictation_card")
|
||||
self.setStyleSheet(_CARD_STYLE)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Top row: timestamp + duration
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(12)
|
||||
|
||||
ts = entry.get("timestamp", 0)
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
# Keep emojis out of strftime: on Windows with the bundled Python
|
||||
# 3.11, strftime routes through the C locale encoder which can't
|
||||
# encode non-BMP codepoints and raises UnicodeEncodeError. When
|
||||
# that exception bubbles through a Qt slot invocation it triggers
|
||||
# a Qt6Core fast-fail (0xc0000409) rather than a catchable error.
|
||||
time_label = QLabel(f"📅 {dt.strftime('%Y-%m-%d')} 🕐 {dt.strftime('%H:%M:%S')}")
|
||||
time_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 12px;")
|
||||
top_row.addWidget(time_label)
|
||||
|
||||
duration = entry.get("duration", 0)
|
||||
if duration > 0:
|
||||
dur_label = QLabel(f"⏱️ {duration:.1f}s")
|
||||
dur_label.setStyleSheet(f"color: {COLORS['text_muted']}; font-size: 12px;")
|
||||
top_row.addWidget(dur_label)
|
||||
|
||||
top_row.addStretch()
|
||||
layout.addLayout(top_row)
|
||||
|
||||
# Text content
|
||||
text = entry.get("text", "")
|
||||
text_label = QLabel(text)
|
||||
text_label.setWordWrap(True)
|
||||
text_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
text_label.setStyleSheet(
|
||||
f"color: {COLORS['text_primary']}; font-size: 14px; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(text_label)
|
||||
|
||||
# Action buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(8)
|
||||
btn_row.addStretch()
|
||||
|
||||
copy_btn = QPushButton("📋 Copy")
|
||||
copy_btn.setStyleSheet(_BTN_STYLE)
|
||||
copy_btn.setToolTip("Copy text to clipboard")
|
||||
copy_btn.clicked.connect(lambda: self._copy_text(text))
|
||||
btn_row.addWidget(copy_btn)
|
||||
|
||||
delete_btn = QPushButton("🗑️ Delete")
|
||||
delete_btn.setStyleSheet(_DELETE_BTN_STYLE)
|
||||
delete_btn.setToolTip("Remove this entry")
|
||||
delete_btn.clicked.connect(self._delete)
|
||||
btn_row.addWidget(delete_btn)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _copy_text(self, text: str) -> None:
|
||||
clipboard = QApplication.clipboard()
|
||||
if clipboard:
|
||||
clipboard.setText(text)
|
||||
|
||||
def _delete(self) -> None:
|
||||
self.deleted.emit(self._entry["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main window
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DictationHistoryWindow(QMainWindow):
|
||||
"""Window showing all past dictation entries with copy/delete actions."""
|
||||
|
||||
def __init__(self, history=None):
|
||||
super().__init__()
|
||||
self._history = history # DictationHistory instance (set later via set_history)
|
||||
self.signals = DictationHistorySignals()
|
||||
self.signals.new_entry.connect(self._on_new_entry)
|
||||
|
||||
self.setWindowTitle("🎙️ Dictation History")
|
||||
self.setGeometry(100, 100, 700, 600)
|
||||
self.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout(central)
|
||||
root_layout.setContentsMargins(16, 16, 16, 16)
|
||||
root_layout.setSpacing(12)
|
||||
|
||||
# Header
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 8)
|
||||
header_layout.setSpacing(12)
|
||||
|
||||
title_section = QWidget()
|
||||
title_layout = QVBoxLayout(title_section)
|
||||
title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
title_layout.setSpacing(4)
|
||||
|
||||
title = QLabel("🎙️ Dictation History")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 20px; font-weight: 600; color: {COLORS['accent_secondary']};"
|
||||
)
|
||||
title_layout.addWidget(title)
|
||||
|
||||
self._subtitle = QLabel("No dictations yet")
|
||||
self._subtitle.setObjectName("subtitle")
|
||||
title_layout.addWidget(self._subtitle)
|
||||
|
||||
header_layout.addWidget(title_section)
|
||||
header_layout.addStretch()
|
||||
|
||||
# Clear all button
|
||||
clear_btn = QPushButton("🗑️ Clear All")
|
||||
clear_btn.setToolTip("Delete all dictation history")
|
||||
clear_btn.setStyleSheet(_DELETE_BTN_STYLE)
|
||||
clear_btn.clicked.connect(self._clear_all)
|
||||
header_layout.addWidget(clear_btn)
|
||||
|
||||
root_layout.addWidget(header)
|
||||
|
||||
# Scrollable list of cards
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
self._scroll.setStyleSheet(
|
||||
f"QScrollArea {{ border: none; background: {COLORS['bg_primary']}; }}"
|
||||
)
|
||||
|
||||
# Start with an empty container; _reload() swaps in a freshly built
|
||||
# widget each time (see spec).
|
||||
self._list_widget = self._build_list_widget([])
|
||||
self._scroll.setWidget(self._list_widget)
|
||||
self._list_layout = self._list_widget.layout()
|
||||
root_layout.addWidget(self._scroll)
|
||||
|
||||
# File-watch timer: poll the history file for changes so the window
|
||||
# updates even when the daemon runs in a separate process.
|
||||
self._last_file_mtime: float = 0.0
|
||||
self._file_watch_timer = QTimer(self)
|
||||
self._file_watch_timer.setInterval(1500) # 1.5 s
|
||||
self._file_watch_timer.timeout.connect(self._check_file_changed)
|
||||
# Timer starts/stops with window visibility (see showEvent/hideEvent)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_history(self, history) -> None:
|
||||
"""Set the DictationHistory backend and load existing entries."""
|
||||
self._history = history
|
||||
self._reload()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
"""Refresh the list each time the window is shown."""
|
||||
super().showEvent(event)
|
||||
# Defer the rebuild to the next event-loop tick. Mutating the widget
|
||||
# tree inside showEvent is re-entrant with Qt's first paint pass and
|
||||
# has triggered a Qt6Core fast-fail (0xc0000409) on Qt 6.11 Windows.
|
||||
# Running after showEvent returns lets the window complete its
|
||||
# initial layout/paint before we swap the list contents.
|
||||
QTimer.singleShot(0, self._refresh_from_disk_and_reload)
|
||||
self._last_file_mtime = self._get_history_file_mtime()
|
||||
self._file_watch_timer.start()
|
||||
|
||||
def _refresh_from_disk_and_reload(self) -> None:
|
||||
"""Pull fresh entries from disk, then rebuild."""
|
||||
if self._history is not None:
|
||||
self._history.reload_from_disk()
|
||||
self._reload()
|
||||
|
||||
def hideEvent(self, event) -> None:
|
||||
"""Stop polling when the window is hidden."""
|
||||
super().hideEvent(event)
|
||||
self._file_watch_timer.stop()
|
||||
|
||||
def _is_dictation_enabled(self) -> bool:
|
||||
"""Check whether dictation is enabled in config."""
|
||||
try:
|
||||
from jarvis.config import default_config_path, _load_json, get_default_config
|
||||
config = _load_json(default_config_path()) or {}
|
||||
defaults = get_default_config()
|
||||
return bool(config.get("dictation_enabled", defaults.get("dictation_enabled", True)))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _build_list_widget(self, entries: List[Dict[str, Any]]) -> QWidget:
|
||||
"""Build a fresh container widget populated for the given entries.
|
||||
|
||||
Returns a newly-constructed QWidget with its layout and children
|
||||
already in place. The caller atomically installs it into the
|
||||
scroll area, replacing the previous contents.
|
||||
"""
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
if not entries:
|
||||
if self._history is None or self._is_dictation_enabled():
|
||||
placeholder = self._make_empty_label()
|
||||
else:
|
||||
placeholder = QLabel(
|
||||
"Dictation mode is currently disabled.\n\n"
|
||||
"Enable it in Settings \u2192 Features \u2192 Dictation Mode."
|
||||
)
|
||||
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
placeholder.setStyleSheet(
|
||||
f"color: {COLORS['text_muted']}; font-size: 14px; padding: 40px;"
|
||||
)
|
||||
layout.addWidget(placeholder)
|
||||
else:
|
||||
for entry in entries:
|
||||
card = _DictationCard(entry)
|
||||
card.deleted.connect(self._on_delete)
|
||||
layout.addWidget(card)
|
||||
layout.addStretch()
|
||||
return container
|
||||
|
||||
def _reload(self) -> None:
|
||||
"""Rebuild the card list by atomically swapping the container.
|
||||
|
||||
Instead of mutating the existing layout (taking items out and
|
||||
scheduling deferred deletes), we build a completely new container
|
||||
and install it into the scroll area. ``QScrollArea.takeWidget()``
|
||||
returns the previous container, which we then hide and
|
||||
``deleteLater()``. This keeps the old widgets alive only as long
|
||||
as their deferred destruction takes, and they never receive any
|
||||
further paint/layout events because they are no longer in the
|
||||
visible tree.
|
||||
"""
|
||||
entries = self._history.get_all() if self._history is not None else []
|
||||
|
||||
new_container = self._build_list_widget(entries)
|
||||
old_container = self._scroll.takeWidget()
|
||||
self._scroll.setWidget(new_container)
|
||||
self._list_widget = new_container
|
||||
self._list_layout = new_container.layout()
|
||||
|
||||
if old_container is not None:
|
||||
old_container.hide()
|
||||
old_container.deleteLater()
|
||||
|
||||
if self._history is None or not entries:
|
||||
self._subtitle.setText("No dictations yet")
|
||||
else:
|
||||
self._subtitle.setText(f"{len(entries)} dictation(s)")
|
||||
|
||||
def _get_history_file_mtime(self) -> float:
|
||||
"""Return the mtime of the history JSON file, or 0 if missing."""
|
||||
try:
|
||||
from jarvis.dictation.history import _default_history_path
|
||||
p = _default_history_path()
|
||||
return p.stat().st_mtime if p.exists() else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _check_file_changed(self) -> None:
|
||||
"""Called by the timer — reload if the history file was modified."""
|
||||
mtime = self._get_history_file_mtime()
|
||||
if mtime > self._last_file_mtime:
|
||||
self._last_file_mtime = mtime
|
||||
# Re-read from disk via the public, lock-safe method
|
||||
if self._history is not None:
|
||||
self._history.reload_from_disk()
|
||||
self._reload()
|
||||
|
||||
def _make_empty_label(self) -> QLabel:
|
||||
label = QLabel("Hold your dictation hotkey to start.\nTranscriptions will appear here.")
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setStyleSheet(
|
||||
f"color: {COLORS['text_muted']}; font-size: 14px; padding: 40px;"
|
||||
)
|
||||
return label
|
||||
|
||||
def _on_new_entry(self, entry: dict) -> None:
|
||||
"""Slot: called (via signal) when a new dictation completes."""
|
||||
if self._history is None:
|
||||
return
|
||||
# Hidden windows are inert (see spec); showEvent rebuilds from
|
||||
# disk on next open, so the entry is not lost.
|
||||
if not self.isVisible():
|
||||
return
|
||||
# Full rebuild via the same code path as showEvent. Cheaper and
|
||||
# far safer than surgical layout edits.
|
||||
self._reload()
|
||||
|
||||
def _on_delete(self, entry_id: str) -> None:
|
||||
"""Delete a single entry."""
|
||||
if self._history:
|
||||
self._history.delete(entry_id)
|
||||
self._reload()
|
||||
|
||||
def _clear_all(self) -> None:
|
||||
"""Delete all entries after confirmation."""
|
||||
if self._history is None or self._history.count == 0:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Clear Dictation History",
|
||||
"Delete all dictation history entries?\nThis cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self._history.clear()
|
||||
self._reload()
|
||||
1095
src/desktop_app/face_widget.py
Normal file
186
src/desktop_app/mcp_catalogue.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
🔌 Curated catalogue of popular, verified MCP servers.
|
||||
|
||||
Shared between the setup wizard (quick picks) and settings window (full management).
|
||||
Each entry contains the config needed to add the server to config.json.
|
||||
|
||||
Selection criteria:
|
||||
- Must NOT duplicate Jarvis built-in tools (web search, page fetch, file ops,
|
||||
memory/recall, weather, screenshot/OCR, meals).
|
||||
- Wizard-featured entries must be zero-config (no API keys).
|
||||
- All entries must be from the official @modelcontextprotocol org or widely trusted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPEntry:
|
||||
"""A curated MCP server entry."""
|
||||
name: str # Config key / server name
|
||||
display_name: str # Human-readable name
|
||||
description: str # Short description of what it does
|
||||
command: str # Executable (e.g. "npx")
|
||||
args: List[str] # Command arguments
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
needs_api_key: bool = False # Requires user to supply an API key
|
||||
api_key_env_var: Optional[str] = None # Which env var holds the key
|
||||
api_key_hint: Optional[str] = None # Help text for obtaining the key
|
||||
wizard_featured: bool = False # Show in setup wizard quick picks
|
||||
category: str = "general" # Grouping for display
|
||||
|
||||
def to_config(self, extra_env: Optional[Dict[str, str]] = None) -> Dict:
|
||||
"""Convert to the config.json MCP entry format.
|
||||
|
||||
Args:
|
||||
extra_env: Additional env vars to merge (e.g. user-supplied API keys).
|
||||
Never mutates the entry's own env dict.
|
||||
"""
|
||||
cfg: Dict = {
|
||||
"transport": "stdio",
|
||||
"command": self.command,
|
||||
"args": list(self.args),
|
||||
}
|
||||
merged_env = {**self.env, **(extra_env or {})}
|
||||
if merged_env:
|
||||
cfg["env"] = merged_env
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalogue entries — order matters for display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CATALOGUE: List[MCPEntry] = [
|
||||
# -- Wizard-featured (zero-config, genuinely novel capabilities) --
|
||||
MCPEntry(
|
||||
name="chrome-devtools",
|
||||
display_name="🌐 Chrome Automation",
|
||||
description="Control Chrome by voice — navigate pages, fill forms, click buttons, "
|
||||
"inspect network traffic, and read console logs. Uses your existing Chrome installation",
|
||||
command="npx",
|
||||
args=["-y", "chrome-devtools-mcp@latest"],
|
||||
wizard_featured=True,
|
||||
category="automation",
|
||||
),
|
||||
MCPEntry(
|
||||
name="youtube-transcript",
|
||||
display_name="📺 YouTube Transcripts",
|
||||
description="Extract and summarise transcripts from any YouTube video — "
|
||||
"just paste a link and ask Jarvis about the content",
|
||||
command="npx",
|
||||
args=["-y", "@kimtaeyoon83/mcp-server-youtube-transcript"],
|
||||
wizard_featured=True,
|
||||
category="media",
|
||||
),
|
||||
MCPEntry(
|
||||
name="macos",
|
||||
display_name="🖥️ macOS Automation",
|
||||
description="Control your Mac by voice — run AppleScript and JavaScript automations "
|
||||
"to launch apps, manage windows, and automate system tasks",
|
||||
command="npx",
|
||||
args=["-y", "@steipete/macos-automator-mcp"],
|
||||
wizard_featured=True,
|
||||
category="automation",
|
||||
),
|
||||
|
||||
# -- Available in settings (may need API keys or extra config) --
|
||||
MCPEntry(
|
||||
name="github",
|
||||
display_name="🐙 GitHub",
|
||||
description="Manage repositories, issues, pull requests, and code search — "
|
||||
"your coding workflow from voice",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-github"],
|
||||
needs_api_key=True,
|
||||
api_key_env_var="GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
api_key_hint="Create a token at https://github.com/settings/tokens",
|
||||
category="dev",
|
||||
),
|
||||
MCPEntry(
|
||||
name="gitlab",
|
||||
display_name="🦊 GitLab",
|
||||
description="Manage GitLab projects, merge requests, issues, and pipelines",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-gitlab"],
|
||||
needs_api_key=True,
|
||||
api_key_env_var="GITLAB_PERSONAL_ACCESS_TOKEN",
|
||||
api_key_hint="Create a token at https://gitlab.com/-/user_settings/personal_access_tokens",
|
||||
category="dev",
|
||||
),
|
||||
MCPEntry(
|
||||
name="google-maps",
|
||||
display_name="🗺️ Google Maps",
|
||||
description="Directions, place search, distance calculations, and geocoding — "
|
||||
"real navigation and points of interest",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
needs_api_key=True,
|
||||
api_key_env_var="GOOGLE_MAPS_API_KEY",
|
||||
api_key_hint="Get a key at https://console.cloud.google.com/google/maps-apis",
|
||||
category="location",
|
||||
),
|
||||
MCPEntry(
|
||||
name="slack",
|
||||
display_name="💬 Slack",
|
||||
description="Read channels, send messages, search conversations, "
|
||||
"and manage your Slack workspace by voice",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-slack"],
|
||||
needs_api_key=True,
|
||||
api_key_env_var="SLACK_BOT_TOKEN",
|
||||
api_key_hint="Create a Slack app at https://api.slack.com/apps and add a Bot token",
|
||||
category="comms",
|
||||
),
|
||||
MCPEntry(
|
||||
name="spotify",
|
||||
display_name="🎵 Spotify",
|
||||
description="Control music playback, search tracks, manage playlists, "
|
||||
"and discover new music — all by voice",
|
||||
command="npx",
|
||||
args=["-y", "mcp-spotify"],
|
||||
needs_api_key=True,
|
||||
api_key_env_var="SPOTIFY_CLIENT_SECRET",
|
||||
api_key_hint="Create an app at https://developer.spotify.com/dashboard",
|
||||
category="media",
|
||||
),
|
||||
MCPEntry(
|
||||
name="sqlite",
|
||||
display_name="🗄️ SQLite",
|
||||
description="Query and manage SQLite databases — run SQL, inspect schemas, "
|
||||
"and analyse data hands-free",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-sqlite"],
|
||||
category="dev",
|
||||
),
|
||||
MCPEntry(
|
||||
name="whatsapp",
|
||||
display_name="💬 WhatsApp",
|
||||
description="Search chats, send messages, share media and voice notes — "
|
||||
"all locally via WhatsApp Web bridge (QR code auth)",
|
||||
command="uvx",
|
||||
args=["whatsapp-mcp-server"],
|
||||
api_key_hint="Requires Go, UV, and a one-time QR code scan. "
|
||||
"See https://github.com/lharries/whatsapp-mcp",
|
||||
category="comms",
|
||||
),
|
||||
MCPEntry(
|
||||
name="everything",
|
||||
display_name="🔍 Everything Search",
|
||||
description="Instant file search across your entire system using Voidtools Everything "
|
||||
"(Windows only)",
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-everything"],
|
||||
category="files",
|
||||
),
|
||||
]
|
||||
|
||||
CATALOGUE_BY_NAME: Dict[str, MCPEntry] = {e.name: e for e in CATALOGUE}
|
||||
|
||||
|
||||
def get_wizard_entries() -> List[MCPEntry]:
|
||||
"""Return only entries suitable for the setup wizard (no API key needed)."""
|
||||
return [e for e in CATALOGUE if e.wizard_featured]
|
||||
3825
src/desktop_app/memory_viewer.py
Normal file
35
src/desktop_app/paths.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Shared filesystem paths for the desktop app.
|
||||
|
||||
Centralising these avoids drift between modules (app.py, updater.py, etc.)
|
||||
that all need to agree on where logs and crash reports live.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_log_dir() -> Path:
|
||||
"""Return the platform-appropriate directory for Jarvis logs.
|
||||
|
||||
Falls back to a temp directory if the preferred location cannot be
|
||||
created (e.g. read-only home, permission denied) so callers never have
|
||||
to handle mkdir failure themselves.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
preferred = Path.home() / "Library" / "Logs" / "Jarvis"
|
||||
elif sys.platform == "win32":
|
||||
preferred = Path(os.environ.get("LOCALAPPDATA", Path.home())) / "Jarvis"
|
||||
else:
|
||||
preferred = Path.home() / ".jarvis"
|
||||
|
||||
try:
|
||||
preferred.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
return preferred
|
||||
except OSError:
|
||||
fallback = Path(tempfile.gettempdir()) / "jarvis-logs"
|
||||
fallback.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
return fallback
|
||||
38
src/desktop_app/rthook_onnxruntime.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""PyInstaller runtime hook: register DLL directories on Windows.
|
||||
|
||||
When PyInstaller extracts a one-file bundle the native DLLs end up in
|
||||
subdirectories of the temporary _MEI* folder. This hook adds those
|
||||
directories to the DLL search path so native modules can locate their
|
||||
dependencies.
|
||||
|
||||
Covers:
|
||||
- ONNX Runtime (onnxruntime/capi/)
|
||||
- NVIDIA CUDA libraries ({app}/cuda/) — installed optionally by the
|
||||
Inno Setup installer for GPU-accelerated speech recognition
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32" and getattr(sys, "frozen", False):
|
||||
_bundle_dir = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
|
||||
|
||||
# ONNX Runtime DLLs
|
||||
_ort_capi = os.path.join(_bundle_dir, "onnxruntime", "capi")
|
||||
if os.path.isdir(_ort_capi):
|
||||
try:
|
||||
os.add_dll_directory(_ort_capi)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
# NVIDIA CUDA DLLs (cuBLAS + cuDNN, placed by install_cuda.ps1)
|
||||
# Use the app's install directory (not _MEIPASS) since CUDA libs are
|
||||
# downloaded post-install, not bundled in the PyInstaller archive.
|
||||
_app_dir = os.path.dirname(sys.executable)
|
||||
_cuda_dir = os.path.join(_app_dir, "cuda")
|
||||
if os.path.isdir(_cuda_dir):
|
||||
os.environ["PATH"] = _cuda_dir + os.pathsep + os.environ.get("PATH", "")
|
||||
try:
|
||||
os.add_dll_directory(_cuda_dir)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
1202
src/desktop_app/settings_window.py
Normal file
132
src/desktop_app/settings_window.spec.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Settings Window Specification
|
||||
|
||||
Auto-generated settings UI that dynamically builds its interface from config field metadata.
|
||||
|
||||
## Overview
|
||||
|
||||
The Settings Window provides a graphical interface for editing `config.json` without requiring users to manually edit JSON. It reads the current config, presents categorised fields with appropriate input widgets, and saves changes back.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Metadata-driven**: All fields are defined in a `FIELD_METADATA` registry. Adding a new config parameter to the settings UI requires only adding a `FieldMeta` entry — no widget code changes.
|
||||
2. **Minimal config files**: Only non-default values are written to `config.json`. Removing a field from the config reverts it to the default.
|
||||
3. **Preserves unknown keys**: Keys not managed by the UI (e.g. `mcps`, `_config_version`, future additions) are preserved when saving.
|
||||
4. **Theme-consistent**: Uses the shared Jarvis theme from `themes.py`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
FieldMeta (dataclass)
|
||||
├── key: str # config.json key name
|
||||
├── label: str # Human-readable label
|
||||
├── description: str # Tooltip text
|
||||
├── category: str # Tab grouping key
|
||||
├── field_type: str # "bool" | "int" | "float" | "str" | "choice" | "device" | "list"
|
||||
├── choices # For "choice"/"device": [(value, display), ...]
|
||||
├── min_val / max_val # Numeric bounds
|
||||
├── step # Increment step
|
||||
├── suffix # Unit label (e.g. "s", "ms", "WPM")
|
||||
└── nullable # Whether None is valid (shows placeholder)
|
||||
```
|
||||
|
||||
## Widget Mapping
|
||||
|
||||
| field_type | Widget | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `bool` | QCheckBox | |
|
||||
| `int` | QSpinBox | With bounds, step, suffix |
|
||||
| `int` (nullable) | QCheckBox + QSpinBox | Checkbox enables/disables the spinbox |
|
||||
| `float` | QDoubleSpinBox | With bounds, step, suffix |
|
||||
| `str` | QLineEdit | Placeholder if nullable |
|
||||
| `choice` | QComboBox | Pre-defined options |
|
||||
| `device` | QComboBox | Dynamically populated from sounddevice |
|
||||
| `list` | QListWidget + Add/Edit/Remove buttons | Stores as JSON array in config |
|
||||
|
||||
## Layout
|
||||
|
||||
The settings window uses a sidebar navigation pattern: a fixed-width `QListWidget` on the left lists categories, and a `QStackedWidget` on the right shows the selected category's form. This avoids horizontal overflow from too many tabs.
|
||||
|
||||
## Categories (Sidebar Order)
|
||||
|
||||
1. LLM & AI Models
|
||||
2. Text-to-Speech
|
||||
3. Piper TTS
|
||||
4. Chatterbox TTS
|
||||
5. Voice Input (includes microphone device selection)
|
||||
6. Wake Word
|
||||
7. Speech Recognition (Whisper)
|
||||
8. Voice Activity Detection
|
||||
9. Timing & Windows
|
||||
10. Memory & Dialogue
|
||||
11. Location
|
||||
12. Features (includes Dictation Mode toggle and hotkey)
|
||||
13. MCP Servers
|
||||
14. Advanced
|
||||
|
||||
## Hardware Device Selection
|
||||
|
||||
The Voice Input tab includes a device dropdown populated at window open time via `sounddevice.query_devices()`. It lists all input-capable devices with their index and name. The stored value is the device index as a string, or empty string for system default.
|
||||
|
||||
## Save Behaviour
|
||||
|
||||
- Only keys that differ from `get_default_config()` are written.
|
||||
- Existing keys not managed by the UI are preserved (e.g. `mcps`, `active_profiles`, `wake_aliases`, `allowlist_bundles`, `stop_commands`).
|
||||
- After save, a dialog confirms success and reminds the user to restart.
|
||||
- If the daemon is running when save completes, the tray app offers to restart it.
|
||||
|
||||
## Reset to Defaults
|
||||
|
||||
- Prompts for confirmation.
|
||||
- Resets all widget values to `get_default_config()` values.
|
||||
- Does NOT immediately save — user must still click Save.
|
||||
|
||||
## Integration
|
||||
|
||||
- Accessed via "⚙️ Settings" in the system tray menu.
|
||||
- Opens as a modal QDialog.
|
||||
- Lazy-imported to avoid loading sounddevice at startup.
|
||||
|
||||
## MCP Servers Section
|
||||
|
||||
The MCP Servers category is **not** metadata-driven — it uses a custom page because `mcps` is a complex dict structure.
|
||||
|
||||
### Layout
|
||||
|
||||
- Description label explaining what MCP servers are
|
||||
- List widget showing configured servers (display name from catalogue if recognised, otherwise `🔌 {name}`)
|
||||
- Buttons: **Add from Catalogue**, **Add Custom**, **Edit**, **Remove**
|
||||
- Detail panel showing the selected server's name, command, args, and env vars
|
||||
|
||||
### Add from Catalogue
|
||||
|
||||
Opens `_MCPCatalogueDialog` showing all entries from `mcp_catalogue.CATALOGUE`. Already-configured servers appear checked and disabled. Servers that require an API key show a 🔑 badge. When the user confirms, they're prompted for any needed API keys.
|
||||
|
||||
### Add Custom
|
||||
|
||||
Opens `_MCPEditDialog` with fields for name, command, args (space-separated), and env vars (KEY=VALUE pairs). Validates that name and command are non-empty.
|
||||
|
||||
### Edit
|
||||
|
||||
Opens `_MCPEditDialog` pre-filled with the selected server's config. Name is read-only during edit.
|
||||
|
||||
### Remove
|
||||
|
||||
Prompts for confirmation, then removes the server from the in-memory dict.
|
||||
|
||||
### Save Behaviour
|
||||
|
||||
On save, the `mcps` dict is written to config.json if non-empty, or removed entirely if empty. On reset, all MCPs are cleared.
|
||||
|
||||
## Fields NOT Exposed in UI
|
||||
|
||||
These fields are managed elsewhere or are too complex for a simple form:
|
||||
|
||||
- `db_path` / `sqlite_vss_path` — internal storage paths
|
||||
- `active_profiles` — list managed by setup wizard
|
||||
- `allowlist_bundles` — list of bundle IDs
|
||||
- `wake_aliases` — list of strings (complex editing)
|
||||
- `stop_commands` / `stop_command_fuzzy_ratio` — list of strings
|
||||
- `use_stdin` — developer/CLI flag
|
||||
- `voice_debug` — environment variable only
|
||||
- `whisper_min_audio_duration` / `whisper_min_word_length` — rarely changed advanced params
|
||||
- `vad_frame_ms` / `vad_pre_roll_ms` — low-level VAD timing
|
||||
3117
src/desktop_app/setup_wizard.py
Normal file
90
src/desktop_app/setup_wizard.spec.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Setup Wizard Specification
|
||||
|
||||
First-run wizard that ensures Ollama, required models, and Whisper are ready before Jarvis starts.
|
||||
|
||||
## Overview
|
||||
|
||||
The setup wizard is shown only when **user action is required** — it is not shown merely because the Ollama server isn't running (Jarvis can auto-start it). The two triggers are:
|
||||
|
||||
1. Ollama CLI is not installed.
|
||||
2. Ollama server is running but required models are missing.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Minimal friction**: Skip pages whose requirements are already met. Auto-detect as much as possible.
|
||||
2. **Guided, not blocking**: The wizard resolves prerequisites; it does not configure every setting. Fine-tuning happens in the Settings Window.
|
||||
3. **Platform-aware**: Apple Silicon gets MLX Whisper options. Windows gets hidden-console Ollama serve. macOS opens the Ollama app.
|
||||
4. **Safe re-entry**: Running the wizard again never destroys existing config — it only fills in missing values.
|
||||
|
||||
## Page Flow
|
||||
|
||||
```
|
||||
Welcome → [Ollama Install] → [Ollama Server] → Models → [Whisper] → Dictation → MCP Servers → Search Providers → [Location] → Complete
|
||||
```
|
||||
|
||||
Pages in brackets are conditional — skipped when their prerequisite is already satisfied.
|
||||
|
||||
### Pages
|
||||
|
||||
| # | Page | Condition to show | Config written |
|
||||
|---|------|-------------------|----------------|
|
||||
| 1 | **Welcome** | Always | — |
|
||||
| 2 | **Ollama Install** | CLI not found | — |
|
||||
| 3 | **Ollama Server** | Server not running | — |
|
||||
| 4 | **Models** | Always (user selects chat model) | `ollama_chat_model` |
|
||||
| 5 | **Whisper Setup** | Always (user selects Whisper model) | `whisper_model` |
|
||||
| 6 | **Dictation** | Always | `dictation_enabled`, `dictation_hotkey`, `dictation_filler_removal` |
|
||||
| 7 | **MCP Servers** | Always | `mcps` |
|
||||
| 8 | **Search Providers** | Always | `brave_search_api_key`, `wikipedia_fallback_enabled` |
|
||||
| 9 | **Location** | Location enabled but detection failing | `location_ip_address` |
|
||||
| 10 | **Complete** | Always | — |
|
||||
|
||||
### Page Details
|
||||
|
||||
**WelcomePage** — Status dashboard showing CLI, server, models, location, and MLX Whisper (Apple Silicon) readiness. Refresh button triggers a background `StatusCheckWorker`.
|
||||
|
||||
**OllamaInstallPage** — Platform-specific download instructions. Opens official download page. Verify button re-checks `check_ollama_cli()`.
|
||||
|
||||
**OllamaServerPage** — Start button auto-starts Ollama (macOS: `open -a Ollama`, Windows: hidden `ollama serve`, Linux: terminal `ollama serve`). Verify button re-checks `check_ollama_server()`.
|
||||
|
||||
**ModelsPage** — Displays `SUPPORTED_CHAT_MODELS` as selectable cards with VRAM requirements (including always-loaded intent judge overhead). Installs: selected chat model + embedding model (`nomic-embed-text`) + intent judge (`gemma4:e2b`). Progress bar and log output during `ollama pull`. User can skip if models are already present.
|
||||
|
||||
**WhisperSetupPage** — Language mode toggle (multilingual vs English-only), then model size selection from hardcoded options. Apple Silicon: additional FFmpeg and MLX Whisper installation buttons.
|
||||
|
||||
**DictationPage** — Enable/disable dictation, hotkey selection dropdown (4 presets), filler word removal toggle with delay warning. Reads current config values on open so re-running the wizard preserves user choices.
|
||||
|
||||
**MCPPage** — Shows wizard-featured entries from `mcp_catalogue.py` as selectable cards (checkbox + name + description). Already-configured servers start checked. On validate, selected servers are added to `config.mcps` and deselected wizard entries are removed. Includes a tip pointing users to Settings → MCP Servers for the full catalogue and custom servers.
|
||||
|
||||
**SearchProvidersPage** — Explains and configures the web-search fallback chain (DDG → Brave → Wikipedia → honest block). Always shown: the explainer is the point, not the configuration. Brave card takes an optional API key (password-masked) with a link to the Brave key portal. Wikipedia card is a toggle that defaults to on. Only non-default values are written to `config.json` (empty Brave key and enabled Wikipedia are both omitted), matching the settings window's minimal-diff invariant.
|
||||
|
||||
**LocationPage** — Tests location auto-detection. If it fails (private/CGNAT IP), offers manual IP input with OpenDNS resolution and GeoLite2 validation.
|
||||
|
||||
**CompletePage** — Success summary with tips. Hides Cancel button.
|
||||
|
||||
## Detection Functions
|
||||
|
||||
| Function | Returns | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `should_show_setup_wizard()` | `bool` | Gate: only `True` when user action needed |
|
||||
| `check_ollama_cli()` | `(bool, path)` | CLI installed + path |
|
||||
| `check_ollama_server()` | `(bool, version)` | Server reachable + version |
|
||||
| `get_required_models()` | `list[str]` | Models needed per config |
|
||||
| `check_installed_models()` | `list[str]` | Models already pulled |
|
||||
| `check_ollama_status()` | `OllamaStatus` | Combined CLI + server + models |
|
||||
| `check_mlx_whisper_status()` | `MLXWhisperStatus` | Apple Silicon Whisper readiness |
|
||||
|
||||
## Threading
|
||||
|
||||
- `StatusCheckWorker(QThread)` — runs `check_ollama_status()` off the UI thread, emits result via signal.
|
||||
- `CommandWorker(QThread)` — runs shell commands (e.g. `ollama pull`), emits stdout line-by-line and completion status.
|
||||
|
||||
## Settings NOT Configured by Wizard
|
||||
|
||||
The wizard is deliberately limited to prerequisites. These are configured via the Settings Window:
|
||||
|
||||
- TTS settings (engine, voice, rate)
|
||||
- VAD / timing parameters
|
||||
- Wake word customisation
|
||||
- Dictation hotkey
|
||||
- Full MCP catalogue and custom MCP servers (wizard only shows featured entries)
|
||||
- All advanced parameters
|
||||
204
src/desktop_app/splash_screen.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
🚀 Jarvis Splash Screen
|
||||
|
||||
A stylish startup splash screen with animated loading indicator
|
||||
that shows progress during application initialization.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
|
||||
from PyQt6.QtGui import QPainter, QPen, QColor, QBrush, QRadialGradient, QFont
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
||||
|
||||
from desktop_app.themes import COLORS
|
||||
|
||||
|
||||
class AnimatedOrb(QWidget):
|
||||
"""Animated pulsing orb with rotating arcs."""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(120, 120)
|
||||
|
||||
# Animation state
|
||||
self._rotation = 0.0
|
||||
self._pulse_phase = 0.0
|
||||
self._glow_intensity = 0.5
|
||||
|
||||
# Animation timer (60 FPS)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._animate)
|
||||
self._timer.start(16)
|
||||
|
||||
def _animate(self):
|
||||
"""Update animation state."""
|
||||
self._rotation += 2.0 # Degrees per frame
|
||||
if self._rotation >= 360:
|
||||
self._rotation -= 360
|
||||
|
||||
self._pulse_phase += 0.08
|
||||
self._glow_intensity = 0.4 + 0.3 * math.sin(self._pulse_phase)
|
||||
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Draw the animated orb."""
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
center_x = self.width() / 2
|
||||
center_y = self.height() / 2
|
||||
|
||||
# Colors from theme
|
||||
accent = QColor(COLORS["accent_primary"])
|
||||
accent_secondary = QColor(COLORS["accent_secondary"])
|
||||
bg = QColor(COLORS["bg_primary"])
|
||||
|
||||
# Draw outer glow
|
||||
glow_radius = 50 + 5 * math.sin(self._pulse_phase)
|
||||
glow = QRadialGradient(center_x, center_y, glow_radius)
|
||||
glow_color = QColor(accent)
|
||||
glow_color.setAlphaF(self._glow_intensity * 0.3)
|
||||
glow.setColorAt(0, glow_color)
|
||||
glow_color.setAlphaF(0)
|
||||
glow.setColorAt(1, glow_color)
|
||||
painter.setBrush(QBrush(glow))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(QRectF(center_x - glow_radius, center_y - glow_radius,
|
||||
glow_radius * 2, glow_radius * 2))
|
||||
|
||||
# Draw core orb
|
||||
core_radius = 25 + 3 * math.sin(self._pulse_phase)
|
||||
core_gradient = QRadialGradient(center_x - 5, center_y - 5, core_radius * 1.5)
|
||||
core_gradient.setColorAt(0, accent_secondary)
|
||||
core_gradient.setColorAt(0.7, accent)
|
||||
darker = QColor(COLORS["accent_muted"])
|
||||
core_gradient.setColorAt(1, darker)
|
||||
painter.setBrush(QBrush(core_gradient))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(QRectF(center_x - core_radius, center_y - core_radius,
|
||||
core_radius * 2, core_radius * 2))
|
||||
|
||||
# Draw rotating arcs
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
arc_pen = QPen(accent_secondary)
|
||||
arc_pen.setWidth(3)
|
||||
arc_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(arc_pen)
|
||||
|
||||
arc_rect = QRectF(center_x - 40, center_y - 40, 80, 80)
|
||||
|
||||
# Three arcs at different rotations
|
||||
for i, offset in enumerate([0, 120, 240]):
|
||||
painter.save()
|
||||
painter.translate(center_x, center_y)
|
||||
painter.rotate(self._rotation + offset)
|
||||
painter.translate(-center_x, -center_y)
|
||||
|
||||
# Vary alpha for each arc
|
||||
arc_color = QColor(accent_secondary)
|
||||
arc_color.setAlphaF(0.6 + 0.2 * math.sin(self._pulse_phase + i))
|
||||
arc_pen.setColor(arc_color)
|
||||
painter.setPen(arc_pen)
|
||||
|
||||
painter.drawArc(arc_rect, 0 * 16, 60 * 16) # 60 degree arc
|
||||
painter.restore()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the animation."""
|
||||
self._timer.stop()
|
||||
|
||||
|
||||
class SplashScreen(QWidget):
|
||||
"""Splash screen shown during application startup."""
|
||||
|
||||
# Signal emitted when splash should close
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Frameless, always on top, tool window (no taskbar entry)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
Qt.WindowType.WindowStaysOnTopHint |
|
||||
Qt.WindowType.Tool
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
self.setFixedSize(300, 280)
|
||||
self._setup_ui()
|
||||
self._center_on_screen()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI components."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 30, 20, 30)
|
||||
layout.setSpacing(20)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Title
|
||||
title = QLabel("JARVIS")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title_font = QFont()
|
||||
title_font.setPointSize(28)
|
||||
title_font.setWeight(QFont.Weight.Bold)
|
||||
title_font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 8)
|
||||
title.setFont(title_font)
|
||||
title.setStyleSheet(f"color: {COLORS['accent_secondary']}; background: transparent;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Animated orb
|
||||
self._orb = AnimatedOrb()
|
||||
orb_container = QWidget()
|
||||
orb_layout = QVBoxLayout(orb_container)
|
||||
orb_layout.setContentsMargins(0, 0, 0, 0)
|
||||
orb_layout.addWidget(self._orb, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
orb_container.setStyleSheet("background: transparent;")
|
||||
layout.addWidget(orb_container)
|
||||
|
||||
# Status label
|
||||
self._status_label = QLabel("Initializing...")
|
||||
self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
status_font = QFont()
|
||||
status_font.setPointSize(11)
|
||||
self._status_label.setFont(status_font)
|
||||
self._status_label.setStyleSheet(f"color: {COLORS['text_secondary']}; background: transparent;")
|
||||
layout.addWidget(self._status_label)
|
||||
|
||||
def _center_on_screen(self):
|
||||
"""Center the splash screen on the primary display."""
|
||||
screen = QApplication.primaryScreen()
|
||||
if screen:
|
||||
screen_geometry = screen.availableGeometry()
|
||||
x = (screen_geometry.width() - self.width()) // 2 + screen_geometry.x()
|
||||
y = (screen_geometry.height() - self.height()) // 2 + screen_geometry.y()
|
||||
self.move(x, y)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Draw the splash background."""
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Semi-transparent dark background with rounded corners
|
||||
bg_color = QColor(COLORS["bg_primary"])
|
||||
bg_color.setAlphaF(0.95)
|
||||
painter.setBrush(QBrush(bg_color))
|
||||
|
||||
border_color = QColor(COLORS["border"])
|
||||
painter.setPen(QPen(border_color, 1))
|
||||
|
||||
painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 16, 16)
|
||||
|
||||
def set_status(self, status: str):
|
||||
"""Update the status message."""
|
||||
self._status_label.setText(status)
|
||||
# Process events to ensure the UI updates
|
||||
QApplication.processEvents()
|
||||
|
||||
def close_splash(self):
|
||||
"""Close the splash screen gracefully."""
|
||||
self._orb.stop()
|
||||
self.finished.emit()
|
||||
self.close()
|
||||
533
src/desktop_app/themes.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
🎨 Jarvis UI Themes
|
||||
|
||||
Shared stylesheets for Qt interfaces, matching the Memory Viewer's
|
||||
deep space theme with amber accents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Color palette
|
||||
COLORS = {
|
||||
"bg_primary": "#0a0b0f",
|
||||
"bg_secondary": "#12141a",
|
||||
"bg_tertiary": "#1a1d26",
|
||||
"bg_card": "#161920",
|
||||
"bg_hover": "#1e222c",
|
||||
|
||||
"accent_primary": "#f59e0b",
|
||||
"accent_secondary": "#fbbf24",
|
||||
"accent_glow": "rgba(245, 158, 11, 0.15)",
|
||||
"accent_muted": "#92400e",
|
||||
|
||||
"text_primary": "#f4f4f5",
|
||||
"text_secondary": "#a1a1aa",
|
||||
"text_muted": "#71717a",
|
||||
|
||||
"border": "#27272a",
|
||||
"border_glow": "rgba(245, 158, 11, 0.3)",
|
||||
|
||||
"success": "#22c55e",
|
||||
"success_light": "#4ade80",
|
||||
"warning": "#f59e0b",
|
||||
"warning_light": "#fbbf24",
|
||||
"error": "#ef4444",
|
||||
"error_light": "#f87171",
|
||||
}
|
||||
|
||||
|
||||
# Comprehensive Qt stylesheet matching the Memory Viewer's design
|
||||
JARVIS_THEME_STYLESHEET = """
|
||||
QMainWindow, QDialog, QWizard, QWizardPage {
|
||||
background-color: #0a0b0f;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
background-color: #0a0b0f;
|
||||
color: #f4f4f5;
|
||||
font-family: '.AppleSystemUIFont', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #f4f4f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QLabel#title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
QLabel#subtitle {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
QLabel#section_title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
QTextEdit, QPlainTextEdit {
|
||||
background-color: #12141a;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
selection-background-color: rgba(245, 158, 11, 0.3);
|
||||
selection-color: #fbbf24;
|
||||
}
|
||||
|
||||
QTextEdit:focus, QPlainTextEdit:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #12141a;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
selection-background-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QLineEdit::placeholder {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #1a1d26;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #1e222c;
|
||||
border-color: #f59e0b;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #12141a;
|
||||
color: #71717a;
|
||||
border-color: #1a1d26;
|
||||
}
|
||||
|
||||
QPushButton#primary {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #f59e0b, stop:1 #d97706);
|
||||
color: #0a0b0f;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#primary:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #fbbf24, stop:1 #f59e0b);
|
||||
}
|
||||
|
||||
QPushButton#primary:disabled {
|
||||
background: #27272a;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
QPushButton#danger {
|
||||
background-color: #1a1d26;
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
QPushButton#danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-color: #f87171;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
QComboBox {
|
||||
background-color: #12141a;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
QComboBox:hover {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #71717a;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #161920;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
selection-background-color: rgba(245, 158, 11, 0.15);
|
||||
selection-color: #fbbf24;
|
||||
}
|
||||
|
||||
QCheckBox {
|
||||
color: #f4f4f5;
|
||||
spacing: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:hover {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QRadioButton {
|
||||
color: #f4f4f5;
|
||||
spacing: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 9px;
|
||||
background-color: #12141a;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:hover {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:checked {
|
||||
background-color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QProgressBar {
|
||||
background-color: #12141a;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
height: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #f59e0b, stop:1 #fbbf24);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollArea {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QScrollBar:vertical {
|
||||
background-color: #12141a;
|
||||
width: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #27272a;
|
||||
border-radius: 5px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background-color: #12141a;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #27272a;
|
||||
border-radius: 5px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
background-color: #161920;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
padding-top: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 16px;
|
||||
padding: 0 8px;
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
QTabWidget::pane {
|
||||
background-color: #161920;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #12141a;
|
||||
color: #a1a1aa;
|
||||
border: 1px solid #27272a;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #161920;
|
||||
color: #fbbf24;
|
||||
border-color: #27272a;
|
||||
border-bottom-color: #161920;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #1a1d26;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
background-color: #12141a;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button {
|
||||
background-color: #1a1d26;
|
||||
border: none;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
|
||||
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
|
||||
QListWidget {
|
||||
background-color: #12141a;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
QListWidget::item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QListWidget::item:selected {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
QListWidget::item:hover:!selected {
|
||||
background-color: #1e222c;
|
||||
}
|
||||
|
||||
QMessageBox {
|
||||
background-color: #0a0b0f;
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
QToolTip {
|
||||
background-color: #161920;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #161920;
|
||||
color: #f4f4f5;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #27272a;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* Wizard-specific styles */
|
||||
QWizard QPushButton {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
QWizard QLabel#qt_watermark_label {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Card-style container */
|
||||
QFrame#card {
|
||||
background-color: #161920;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
_CHECKMARK_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">'
|
||||
'<path d="M4 9l3.5 3.5L14 5" stroke="#0a0b0f" stroke-width="2.5" '
|
||||
'stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>'
|
||||
)
|
||||
|
||||
_RADIO_DOT_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">'
|
||||
'<circle cx="9" cy="9" r="4" fill="#0a0b0f"/></svg>'
|
||||
)
|
||||
|
||||
_ARROW_UP_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">'
|
||||
'<path d="M2.5 7.5L6 4l3.5 3.5" stroke="#a1a1aa" stroke-width="1.5" '
|
||||
'stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>'
|
||||
)
|
||||
|
||||
_ARROW_DOWN_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">'
|
||||
'<path d="M2.5 4.5L6 8l3.5-3.5" stroke="#a1a1aa" stroke-width="1.5" '
|
||||
'stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>'
|
||||
)
|
||||
|
||||
# Cached icon paths (created once per process)
|
||||
_icon_dir: str | None = None
|
||||
|
||||
|
||||
_ICON_STYLESHEET_TEMPLATE = """
|
||||
QCheckBox::indicator:checked {{
|
||||
image: url({check});
|
||||
}}
|
||||
QRadioButton::indicator:checked {{
|
||||
image: url({radio});
|
||||
}}
|
||||
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{
|
||||
image: url({arrow_up});
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}}
|
||||
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{
|
||||
image: url({arrow_down});
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_icons() -> dict[str, str]:
|
||||
"""Write indicator SVGs to a temp directory, return {name: path} mapping."""
|
||||
global _icon_dir
|
||||
if _icon_dir is None:
|
||||
_icon_dir = tempfile.mkdtemp(prefix="jarvis_theme_")
|
||||
|
||||
icons = {
|
||||
"check": _CHECKMARK_SVG,
|
||||
"radio": _RADIO_DOT_SVG,
|
||||
"arrow_up": _ARROW_UP_SVG,
|
||||
"arrow_down": _ARROW_DOWN_SVG,
|
||||
}
|
||||
paths: dict[str, str] = {}
|
||||
for name, svg in icons.items():
|
||||
path = os.path.join(_icon_dir, f"{name}.svg")
|
||||
if not os.path.exists(path):
|
||||
with open(path, "w") as f:
|
||||
f.write(svg)
|
||||
paths[name] = path.replace("\\", "/")
|
||||
return paths
|
||||
|
||||
|
||||
def apply_theme(widget) -> None:
|
||||
"""Apply the Jarvis theme to a Qt widget, including SVG-based indicator icons."""
|
||||
icons = _ensure_icons()
|
||||
icon_css = _ICON_STYLESHEET_TEMPLATE.format(**icons)
|
||||
widget.setStyleSheet(JARVIS_THEME_STYLESHEET + icon_css)
|
||||
|
||||
675
src/desktop_app/update_dialog.py
Normal file
@@ -0,0 +1,675 @@
|
||||
"""
|
||||
Update notification and download progress dialogs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QCursor
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .themes import COLORS, JARVIS_THEME_STYLESHEET
|
||||
from .updater import (
|
||||
DownloadSignals,
|
||||
DownloadWorker,
|
||||
ReleaseInfo,
|
||||
UpdateStatus,
|
||||
install_update,
|
||||
save_installed_asset_id,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Changelog parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CATEGORY_MAP: dict[str, tuple[str, str]] = {
|
||||
"feat": ("✨", "New Features"),
|
||||
"feature": ("✨", "New Features"),
|
||||
"fix": ("🐛", "Bug Fixes"),
|
||||
"perf": ("⚡", "Performance"),
|
||||
"refactor": ("♻️", "Improvements"),
|
||||
"improve": ("♻️", "Improvements"),
|
||||
"security": ("🔒", "Security"),
|
||||
"docs": ("📝", "Documentation"),
|
||||
"chore": ("🔧", "Maintenance"),
|
||||
"ci": ("🔧", "Maintenance"),
|
||||
"build": ("🔧", "Maintenance"),
|
||||
"deps": ("🔧", "Maintenance"),
|
||||
"test": ("🧪", "Testing"),
|
||||
"style": ("🎨", "Style"),
|
||||
"revert": ("⏪", "Reverts"),
|
||||
}
|
||||
|
||||
_CATEGORY_ORDER = [
|
||||
"New Features", "Bug Fixes", "Performance", "Improvements",
|
||||
"Security", "Documentation", "Maintenance", "Testing", "Style",
|
||||
"Reverts", "Changes",
|
||||
]
|
||||
|
||||
_DEFAULT_CATEGORY = ("📋", "Changes")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangelogEntry:
|
||||
text: str
|
||||
pr_number: Optional[int]
|
||||
category_emoji: str
|
||||
category_name: str
|
||||
|
||||
|
||||
def _detect_category(raw: str) -> tuple[str, str, str]:
|
||||
"""Return (emoji, category_name, cleaned_text) for a raw change line."""
|
||||
m = re.match(r'^(\w+)(?:\([^)]+\))?!?\s*:\s*(.+)$', raw.strip(), re.IGNORECASE)
|
||||
if m:
|
||||
ctype = m.group(1).lower()
|
||||
clean = m.group(2).strip()
|
||||
if ctype in _CATEGORY_MAP:
|
||||
emoji, name = _CATEGORY_MAP[ctype]
|
||||
return emoji, name, clean
|
||||
return _DEFAULT_CATEGORY[0], _DEFAULT_CATEGORY[1], raw.strip()
|
||||
|
||||
|
||||
def parse_release_notes(notes: str) -> dict[str, list[ChangelogEntry]]:
|
||||
"""Parse GitHub release markdown into categorised changelog entries.
|
||||
|
||||
Handles both GitHub's auto-generated format
|
||||
(``* fix(x): desc by @user in https://.../pull/NNN``) and manually
|
||||
written conventional-commit bullets. Returns an ordered dict keyed by
|
||||
category name.
|
||||
"""
|
||||
# Strip "Full Changelog" footer
|
||||
notes = re.sub(r'\*\*Full Changelog\*\*.*$', '', notes, flags=re.MULTILINE).strip()
|
||||
|
||||
entries: list[ChangelogEntry] = []
|
||||
for line in notes.splitlines():
|
||||
line = line.strip()
|
||||
if not re.match(r'^[*\-+]\s', line):
|
||||
continue
|
||||
|
||||
text = line[2:].strip()
|
||||
|
||||
# GitHub auto-generated: "... by @user in https://.../pull/NNN"
|
||||
m_gh = re.search(r'\s+by\s+@\w+\s+in\s+https?://\S+/pull/(\d+)\s*$', text)
|
||||
if m_gh:
|
||||
pr_number: Optional[int] = int(m_gh.group(1))
|
||||
text = text[: m_gh.start()].strip()
|
||||
else:
|
||||
pr_number = None
|
||||
# Plain attribution: "... by @user"
|
||||
text = re.sub(r'\s+by\s+@\w+\s*$', '', text).strip()
|
||||
# Inline PR ref: "... (#NNN)"
|
||||
m_pr = re.search(r'\s*\(#(\d+)\)\s*$', text)
|
||||
if m_pr:
|
||||
pr_number = int(m_pr.group(1))
|
||||
text = text[: m_pr.start()].strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
emoji, cat_name, clean_text = _detect_category(text)
|
||||
entries.append(ChangelogEntry(
|
||||
text=clean_text,
|
||||
pr_number=pr_number,
|
||||
category_emoji=emoji,
|
||||
category_name=cat_name,
|
||||
))
|
||||
|
||||
# Group preserving priority order
|
||||
buckets: dict[str, list[ChangelogEntry]] = {}
|
||||
for entry in entries:
|
||||
buckets.setdefault(entry.category_name, []).append(entry)
|
||||
|
||||
return {name: buckets[name] for name in _CATEGORY_ORDER if name in buckets}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Changelog widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ClickableFrame(QFrame):
|
||||
"""QFrame that calls a Python callable on left-click."""
|
||||
|
||||
def __init__(self, on_click, parent=None):
|
||||
super().__init__(parent)
|
||||
self._on_click = on_click
|
||||
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._on_click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class _VersionCard(QFrame):
|
||||
"""Collapsible card showing the changelog for one release version."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
release: ReleaseInfo,
|
||||
is_latest: bool,
|
||||
expanded: bool,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._release = release
|
||||
self._expanded = expanded
|
||||
self._parsed = parse_release_notes(release.release_notes or "")
|
||||
self._setup_ui(is_latest)
|
||||
|
||||
def _setup_ui(self, is_latest: bool) -> None:
|
||||
self.setObjectName("card")
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setSpacing(0)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Clickable header
|
||||
header = _ClickableFrame(self._toggle)
|
||||
header.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {COLORS['bg_card']};
|
||||
border: 1px solid {COLORS['border']};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QFrame:hover {{
|
||||
background-color: {COLORS['bg_hover']};
|
||||
border-color: {COLORS['border_glow']};
|
||||
}}
|
||||
""")
|
||||
h_layout = QHBoxLayout(header)
|
||||
h_layout.setContentsMargins(14, 10, 14, 10)
|
||||
h_layout.setSpacing(8)
|
||||
|
||||
version_badge = QLabel(f" v{self._release.version} ")
|
||||
version_badge.setStyleSheet(f"""
|
||||
background-color: {COLORS['accent_glow']};
|
||||
color: {COLORS['accent_secondary']};
|
||||
border: 1px solid {COLORS['border_glow']};
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
""")
|
||||
h_layout.addWidget(version_badge)
|
||||
|
||||
name = self._release.name or ""
|
||||
redundant = {self._release.tag_name, f"v{self._release.version}", self._release.version}
|
||||
if name and name not in redundant:
|
||||
name_label = QLabel(name)
|
||||
name_label.setStyleSheet(
|
||||
f"color: {COLORS['text_primary']}; font-size: 13px; background: transparent;"
|
||||
)
|
||||
h_layout.addWidget(name_label)
|
||||
|
||||
h_layout.addStretch()
|
||||
|
||||
if is_latest:
|
||||
latest_badge = QLabel(" LATEST ")
|
||||
latest_badge.setStyleSheet(f"""
|
||||
background-color: rgba(34, 197, 94, 0.12);
|
||||
color: {COLORS['success']};
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
""")
|
||||
h_layout.addWidget(latest_badge)
|
||||
|
||||
if self._release.prerelease:
|
||||
dev_badge = QLabel(" DEV ")
|
||||
dev_badge.setStyleSheet(f"""
|
||||
background-color: {COLORS['accent_glow']};
|
||||
color: {COLORS['warning']};
|
||||
border: 1px solid {COLORS['border_glow']};
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
""")
|
||||
h_layout.addWidget(dev_badge)
|
||||
|
||||
self._arrow = QLabel("▾" if self._expanded else "▸")
|
||||
self._arrow.setStyleSheet(
|
||||
f"color: {COLORS['text_muted']}; font-size: 14px; "
|
||||
f"padding-left: 4px; background: transparent;"
|
||||
)
|
||||
h_layout.addWidget(self._arrow)
|
||||
|
||||
outer.addWidget(header)
|
||||
|
||||
# Collapsible content
|
||||
self._content = QWidget()
|
||||
self._content.setObjectName("version_content")
|
||||
self._content.setStyleSheet(f"""
|
||||
QWidget#version_content {{
|
||||
background-color: {COLORS['bg_secondary']};
|
||||
border: 1px solid {COLORS['border']};
|
||||
border-top: none;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
""")
|
||||
c_layout = QVBoxLayout(self._content)
|
||||
c_layout.setSpacing(4)
|
||||
c_layout.setContentsMargins(16, 10, 16, 14)
|
||||
|
||||
if self._parsed:
|
||||
first_cat = True
|
||||
for cat_name, cat_entries in self._parsed.items():
|
||||
if not cat_entries:
|
||||
continue
|
||||
|
||||
cat_row = QHBoxLayout()
|
||||
cat_row.setContentsMargins(0, 0 if first_cat else 8, 0, 2)
|
||||
|
||||
emoji_lbl = QLabel(cat_entries[0].category_emoji)
|
||||
emoji_lbl.setStyleSheet("font-size: 13px; background: transparent;")
|
||||
cat_row.addWidget(emoji_lbl)
|
||||
|
||||
cat_lbl = QLabel(cat_name)
|
||||
cat_lbl.setStyleSheet(
|
||||
f"color: {COLORS['text_primary']}; font-size: 12px; "
|
||||
f"font-weight: 600; background: transparent;"
|
||||
)
|
||||
cat_row.addWidget(cat_lbl)
|
||||
cat_row.addStretch()
|
||||
c_layout.addLayout(cat_row)
|
||||
first_cat = False
|
||||
|
||||
for entry in cat_entries:
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(12, 0, 0, 0)
|
||||
row.setSpacing(6)
|
||||
|
||||
bullet = QLabel("·")
|
||||
bullet.setFixedWidth(10)
|
||||
bullet.setStyleSheet(
|
||||
f"color: {COLORS['accent_muted']}; font-size: 14px; background: transparent;"
|
||||
)
|
||||
row.addWidget(bullet)
|
||||
|
||||
text_lbl = QLabel(entry.text)
|
||||
text_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
||||
text_lbl.setWordWrap(True)
|
||||
text_lbl.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
text_lbl.setStyleSheet(
|
||||
f"color: {COLORS['text_secondary']}; font-size: 12px; background: transparent;"
|
||||
)
|
||||
row.addWidget(text_lbl, 1)
|
||||
|
||||
if entry.pr_number:
|
||||
pr_lbl = QLabel(f"#{entry.pr_number}")
|
||||
pr_lbl.setStyleSheet(f"""
|
||||
color: {COLORS['text_muted']};
|
||||
background-color: {COLORS['bg_tertiary']};
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
""")
|
||||
row.addWidget(pr_lbl)
|
||||
|
||||
c_layout.addLayout(row)
|
||||
else:
|
||||
placeholder = QLabel("No release notes available.")
|
||||
placeholder.setStyleSheet(
|
||||
f"color: {COLORS['text_muted']}; font-size: 12px; background: transparent;"
|
||||
)
|
||||
c_layout.addWidget(placeholder)
|
||||
|
||||
self._content.setVisible(self._expanded)
|
||||
outer.addWidget(self._content)
|
||||
|
||||
def _toggle(self) -> None:
|
||||
self._expanded = not self._expanded
|
||||
self._content.setVisible(self._expanded)
|
||||
self._arrow.setText("▾" if self._expanded else "▸")
|
||||
# Tell the scroll-area container to recompute its size
|
||||
p = self.parent()
|
||||
while p:
|
||||
if isinstance(p, QScrollArea):
|
||||
p.widget().adjustSize()
|
||||
break
|
||||
p = p.parent()
|
||||
|
||||
|
||||
class ChangelogWidget(QScrollArea):
|
||||
"""Scrollable accordion list of version changelog cards."""
|
||||
|
||||
def __init__(self, releases: list[ReleaseInfo], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWidgetResizable(True)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
container = QWidget()
|
||||
container.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(0, 0, 4, 0)
|
||||
|
||||
for i, release in enumerate(releases):
|
||||
card = _VersionCard(
|
||||
release=release,
|
||||
is_latest=(i == 0),
|
||||
expanded=(i == 0),
|
||||
)
|
||||
layout.addWidget(card)
|
||||
|
||||
layout.addStretch()
|
||||
self.setWidget(container)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main update dialog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UpdateAvailableDialog(QDialog):
|
||||
"""Dialog shown when an update is available."""
|
||||
|
||||
def __init__(self, status: UpdateStatus, parent=None):
|
||||
super().__init__(parent)
|
||||
self.status = status
|
||||
self.release = status.latest_release
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle("Update Available")
|
||||
self.setMinimumSize(540, 520)
|
||||
self.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(14)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
|
||||
# Title
|
||||
title = QLabel("Update Available")
|
||||
title.setObjectName("title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
f"font-size: 20px; font-weight: 600; color: {COLORS['accent_secondary']};"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Version + download-size row
|
||||
info_frame = QFrame()
|
||||
info_frame.setObjectName("card")
|
||||
info_layout = QHBoxLayout(info_frame)
|
||||
info_layout.setContentsMargins(14, 10, 14, 10)
|
||||
|
||||
ver_col = QVBoxLayout()
|
||||
ver_col.setSpacing(4)
|
||||
current_lbl = QLabel(f"Current version: {self.status.current_version}")
|
||||
current_lbl.setObjectName("subtitle")
|
||||
ver_col.addWidget(current_lbl)
|
||||
new_lbl = QLabel(f"New version: {self.release.version}")
|
||||
new_lbl.setStyleSheet(f"color: {COLORS['success']}; font-weight: 500;")
|
||||
ver_col.addWidget(new_lbl)
|
||||
if self.release.prerelease:
|
||||
dev_lbl = QLabel("Development build")
|
||||
dev_lbl.setStyleSheet(f"color: {COLORS['warning']}; font-size: 11px;")
|
||||
ver_col.addWidget(dev_lbl)
|
||||
info_layout.addLayout(ver_col)
|
||||
|
||||
info_layout.addStretch()
|
||||
|
||||
size_mb = self.release.asset_size / (1024 * 1024)
|
||||
size_lbl = QLabel(f"{size_mb:.1f} MB")
|
||||
size_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
size_lbl.setStyleSheet(f"color: {COLORS['text_muted']}; font-size: 11px;")
|
||||
info_layout.addWidget(size_lbl)
|
||||
|
||||
layout.addWidget(info_frame)
|
||||
|
||||
# Changelog section
|
||||
releases = self.status.releases_since_current or (
|
||||
[self.release] if self.release else []
|
||||
)
|
||||
section_title = (
|
||||
f"Changes since v{self.status.current_version}"
|
||||
if len(releases) > 1
|
||||
else "What's New"
|
||||
)
|
||||
notes_label = QLabel(section_title)
|
||||
notes_label.setObjectName("section_title")
|
||||
layout.addWidget(notes_label)
|
||||
|
||||
changelog = ChangelogWidget(releases)
|
||||
changelog.setMinimumHeight(200)
|
||||
changelog.setMaximumHeight(340)
|
||||
layout.addWidget(changelog, 1)
|
||||
|
||||
layout.addStretch(0)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
later_btn = QPushButton("Later")
|
||||
later_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(later_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
view_btn = QPushButton("View on GitHub")
|
||||
view_btn.clicked.connect(self._open_github)
|
||||
button_layout.addWidget(view_btn)
|
||||
|
||||
update_btn = QPushButton("Update Now")
|
||||
update_btn.setObjectName("primary")
|
||||
update_btn.clicked.connect(self.accept)
|
||||
button_layout.addWidget(update_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _open_github(self):
|
||||
webbrowser.open(self.release.html_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Progress dialog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UpdateProgressDialog(QDialog):
|
||||
"""Dialog showing download and installation progress."""
|
||||
|
||||
def __init__(self, release: ReleaseInfo, pre_install_callback=None, parent=None):
|
||||
"""Initialise the update progress dialog.
|
||||
|
||||
Args:
|
||||
release: The release info to download and install.
|
||||
pre_install_callback: Optional callback called after download completes
|
||||
but before installation starts. Use this to save state (e.g., diary)
|
||||
before the update process begins. The callback should be synchronous.
|
||||
parent: Parent widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.release = release
|
||||
self._pre_install_callback = pre_install_callback
|
||||
self.download_worker: Optional[DownloadWorker] = None
|
||||
self.download_signals = DownloadSignals()
|
||||
self.download_path: Optional[Path] = None
|
||||
self._temp_dir: Optional[Path] = None
|
||||
self._setup_ui()
|
||||
self._connect_signals()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle("Updating Jarvis")
|
||||
self.setMinimumSize(450, 220)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Dialog
|
||||
| Qt.WindowType.WindowStaysOnTopHint
|
||||
| Qt.WindowType.CustomizeWindowHint
|
||||
| Qt.WindowType.WindowTitleHint
|
||||
)
|
||||
self.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
|
||||
self.title_label = QLabel("Downloading Update")
|
||||
self.title_label.setObjectName("title")
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.title_label.setStyleSheet(
|
||||
f"font-size: 18px; font-weight: 600; color: {COLORS['accent_secondary']};"
|
||||
)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
self.status_label = QLabel("Preparing download...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setObjectName("subtitle")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setTextVisible(True)
|
||||
self.progress_bar.setMinimumHeight(12)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self._cancel_download)
|
||||
layout.addWidget(self.cancel_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def _connect_signals(self):
|
||||
self.download_signals.progress.connect(self._on_progress)
|
||||
self.download_signals.completed.connect(self._on_completed)
|
||||
self.download_signals.error.connect(self._on_error)
|
||||
|
||||
def start_download(self):
|
||||
"""Start the download process."""
|
||||
self._temp_dir = Path(tempfile.mkdtemp())
|
||||
self.download_path = self._temp_dir / self.release.asset_name
|
||||
|
||||
self.download_worker = DownloadWorker(
|
||||
self.release.download_url,
|
||||
self.download_path,
|
||||
self.download_signals,
|
||||
)
|
||||
self.download_worker.start()
|
||||
|
||||
def _cleanup_temp_dir(self):
|
||||
if self._temp_dir and self._temp_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(self._temp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
self._temp_dir = None
|
||||
|
||||
def _on_progress(self, downloaded: int, total: int):
|
||||
if total > 0:
|
||||
percent = int((downloaded / total) * 100)
|
||||
self.progress_bar.setValue(percent)
|
||||
downloaded_mb = downloaded / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
self.status_label.setText(
|
||||
f"Downloading: {downloaded_mb:.1f} / {total_mb:.1f} MB"
|
||||
)
|
||||
|
||||
def _on_completed(self, path: str):
|
||||
self.cancel_btn.setEnabled(False)
|
||||
|
||||
if self._pre_install_callback:
|
||||
self.title_label.setText("Preparing Update")
|
||||
self.status_label.setText("Saving your session...")
|
||||
self.progress_bar.setRange(0, 0)
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
QApplication.processEvents()
|
||||
|
||||
try:
|
||||
self._pre_install_callback()
|
||||
except Exception as e:
|
||||
from jarvis.debug import debug_log
|
||||
debug_log(f"Pre-install callback failed: {e}", "updater")
|
||||
|
||||
self.title_label.setText("Installing Update")
|
||||
self.status_label.setText("Installing update...")
|
||||
self.progress_bar.setRange(0, 0)
|
||||
|
||||
QTimer.singleShot(500, lambda: self._install(Path(path)))
|
||||
|
||||
def _install(self, download_path: Path):
|
||||
if install_update(download_path):
|
||||
save_installed_asset_id(self.release.asset_id)
|
||||
|
||||
self.title_label.setText("Update Complete")
|
||||
self.status_label.setText("Update installed! Restarting...")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['success']};")
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(100)
|
||||
|
||||
QTimer.singleShot(1500, lambda: self.done(QDialog.DialogCode.Accepted))
|
||||
else:
|
||||
self._on_error("Installation failed. Please try again or update manually.")
|
||||
|
||||
def _on_error(self, error: str):
|
||||
self.title_label.setText("Update Failed")
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['error']};")
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.cancel_btn.setText("Close")
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self._cleanup_temp_dir()
|
||||
|
||||
def _cancel_download(self):
|
||||
if self.download_worker and self.download_worker.isRunning():
|
||||
self.download_worker.cancel()
|
||||
self.download_worker.wait()
|
||||
self._cleanup_temp_dir()
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._cancel_download()
|
||||
event.accept()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility dialogs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def show_no_update_dialog(current_version: str, parent=None) -> None:
|
||||
"""Show a dialog indicating no updates are available."""
|
||||
msg = QMessageBox(parent)
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.setWindowTitle("No Updates Available")
|
||||
msg.setText(f"You're running the latest version ({current_version})")
|
||||
msg.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
msg.exec()
|
||||
|
||||
|
||||
def show_update_error_dialog(error: str, parent=None) -> None:
|
||||
"""Show a dialog indicating an update check error."""
|
||||
msg = QMessageBox(parent)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setWindowTitle("Update Check Failed")
|
||||
msg.setText("Could not check for updates")
|
||||
msg.setInformativeText(error)
|
||||
msg.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
msg.exec()
|
||||
635
src/desktop_app/updater.py
Normal file
@@ -0,0 +1,635 @@
|
||||
"""
|
||||
Auto-update functionality for Jarvis Desktop App.
|
||||
|
||||
Checks GitHub Releases for new versions and handles the update process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from PyQt6.QtCore import QObject, QThread, pyqtSignal
|
||||
|
||||
from jarvis import get_version
|
||||
from jarvis.debug import debug_log
|
||||
|
||||
from .paths import get_log_dir
|
||||
|
||||
GITHUB_REPO = "isair/jarvis"
|
||||
# Absolute path to macOS's ditto tool. Exposed as a module attribute so
|
||||
# tests (which run on non-macOS CI runners without /usr/bin/ditto) can
|
||||
# substitute a path that exists.
|
||||
DITTO_PATH = "/usr/bin/ditto"
|
||||
UPDATER_LOG_NAME = "updater.log"
|
||||
# Truncate the updater log above this size before appending a new run. Each
|
||||
# run writes ~10 lines, so 1 MiB keeps hundreds of update histories without
|
||||
# unbounded growth.
|
||||
UPDATER_LOG_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _extract_macos_bundle(zip_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract a macOS .app zip into ``dest_dir``.
|
||||
|
||||
Uses ``ditto`` when available because PyInstaller's Qt/Qt WebEngine
|
||||
bundle contains symlinks (framework ``Versions/Current`` entries) that
|
||||
Python's ``zipfile`` silently flattens into regular files, producing a
|
||||
bundle macOS refuses to launch with "Jarvis.app can't be opened". Falls
|
||||
back to ``zipfile`` when ditto is absent so unit tests on non-macOS CI
|
||||
runners still exercise the rest of the installer.
|
||||
"""
|
||||
if Path(DITTO_PATH).is_file():
|
||||
subprocess.run(
|
||||
[DITTO_PATH, "-x", "-k", str(zip_path), str(dest_dir)],
|
||||
check=True,
|
||||
)
|
||||
return
|
||||
import zipfile
|
||||
debug_log("ditto unavailable, falling back to zipfile (symlinks will not be preserved)", "updater")
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(dest_dir)
|
||||
|
||||
|
||||
def _escape_applescript_path(path: Path) -> str:
|
||||
"""Escape a path for use in AppleScript POSIX file strings.
|
||||
|
||||
AppleScript POSIX file paths are enclosed in double quotes, so we need to
|
||||
escape backslashes and double quotes.
|
||||
"""
|
||||
return str(path).replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _escape_batch_path(path: Path) -> str:
|
||||
"""Escape a path for use in Windows batch scripts.
|
||||
|
||||
Batch scripts handle paths in double quotes, but certain characters
|
||||
like % need to be escaped. For safety, we reject paths with problematic
|
||||
characters since they're unusual for app installation paths.
|
||||
"""
|
||||
path_str = str(path)
|
||||
# Reject paths with characters that are hard to safely escape in batch
|
||||
dangerous_chars = ['%', '!', '^', '&', '<', '>', '|']
|
||||
for char in dangerous_chars:
|
||||
if char in path_str:
|
||||
raise ValueError(f"Path contains unsafe character for batch script: {char}")
|
||||
return path_str
|
||||
|
||||
|
||||
def _escape_shell_path(path: Path) -> str:
|
||||
"""Escape a path for use in shell scripts.
|
||||
|
||||
Uses single quotes which prevent all interpretation except for single quotes
|
||||
themselves, which we escape by ending the string, adding escaped quote, and
|
||||
starting a new string.
|
||||
"""
|
||||
# Single quotes prevent interpretation, escape embedded single quotes
|
||||
return "'" + str(path).replace("'", "'\"'\"'") + "'"
|
||||
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases"
|
||||
|
||||
|
||||
def _get_update_state_path() -> Path:
|
||||
"""Get path to update state file."""
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg:
|
||||
config_dir = Path(xdg) / "jarvis"
|
||||
else:
|
||||
config_dir = Path.home() / ".config" / "jarvis"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir / "update_state.json"
|
||||
|
||||
|
||||
def get_last_installed_asset_id() -> Optional[int]:
|
||||
"""Get the asset ID of the last installed update.
|
||||
|
||||
We track the asset ID rather than release ID because for the "latest"
|
||||
prerelease tag, the release ID stays the same when updated, but each
|
||||
uploaded asset gets a new unique ID.
|
||||
"""
|
||||
try:
|
||||
state_path = _get_update_state_path()
|
||||
if state_path.exists():
|
||||
with state_path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("last_installed_asset_id")
|
||||
except Exception as e:
|
||||
debug_log(f"Failed to read update state: {e}", "updater")
|
||||
return None
|
||||
|
||||
|
||||
def save_installed_asset_id(asset_id: int) -> None:
|
||||
"""Save the asset ID after a successful update."""
|
||||
try:
|
||||
state_path = _get_update_state_path()
|
||||
data = {}
|
||||
if state_path.exists():
|
||||
with state_path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["last_installed_asset_id"] = asset_id
|
||||
with state_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
debug_log(f"Saved installed asset ID: {asset_id}", "updater")
|
||||
except Exception as e:
|
||||
debug_log(f"Failed to save update state: {e}", "updater")
|
||||
|
||||
|
||||
class UpdateChannel(Enum):
|
||||
"""Update channel for the application."""
|
||||
|
||||
STABLE = "stable"
|
||||
DEVELOP = "develop"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""Information about a GitHub release."""
|
||||
|
||||
asset_id: int # Unique GitHub asset ID for tracking updates (changes on each upload)
|
||||
tag_name: str
|
||||
version: str
|
||||
name: str
|
||||
prerelease: bool
|
||||
html_url: str
|
||||
download_url: str
|
||||
asset_name: str
|
||||
asset_size: int
|
||||
release_notes: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateStatus:
|
||||
"""Result of checking for updates."""
|
||||
|
||||
update_available: bool
|
||||
current_version: str
|
||||
current_channel: str
|
||||
latest_release: Optional[ReleaseInfo]
|
||||
releases_since_current: list[ReleaseInfo] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def get_platform_asset_name() -> str:
|
||||
"""Get the expected asset name for the current platform."""
|
||||
if sys.platform == "darwin":
|
||||
arch = platform.machine()
|
||||
if arch == "arm64":
|
||||
return "Jarvis-macOS-arm64.zip"
|
||||
return "Jarvis-macOS-x64.zip"
|
||||
elif sys.platform == "win32":
|
||||
return "Jarvis-Windows-x64.zip"
|
||||
else:
|
||||
return "Jarvis-Linux-x64.tar.gz"
|
||||
|
||||
|
||||
def parse_version(tag: str) -> tuple[int, ...]:
|
||||
"""Parse version string to tuple for comparison.
|
||||
|
||||
Handles both 'v1.2.3' and 'latest' (develop) formats.
|
||||
"""
|
||||
if tag == "latest":
|
||||
return (0, 0, 0)
|
||||
|
||||
version_str = tag.lstrip("v")
|
||||
|
||||
try:
|
||||
parts = version_str.split(".")
|
||||
return tuple(int(p) for p in parts)
|
||||
except ValueError:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _make_release_info(release: dict, asset: dict) -> ReleaseInfo:
|
||||
return ReleaseInfo(
|
||||
asset_id=asset["id"],
|
||||
tag_name=release["tag_name"],
|
||||
version=release["tag_name"].lstrip("v"),
|
||||
name=release.get("name", release["tag_name"]),
|
||||
prerelease=release.get("prerelease", False),
|
||||
html_url=release["html_url"],
|
||||
download_url=asset["browser_download_url"],
|
||||
asset_name=asset["name"],
|
||||
asset_size=asset["size"],
|
||||
release_notes=release.get("body", ""),
|
||||
)
|
||||
|
||||
|
||||
def check_for_updates(channel: Optional[UpdateChannel] = None) -> UpdateStatus:
|
||||
"""Check GitHub Releases for available updates.
|
||||
|
||||
Args:
|
||||
channel: Update channel to check. If None, uses current app's channel.
|
||||
|
||||
Returns:
|
||||
UpdateStatus with update information.
|
||||
"""
|
||||
current_version, current_channel = get_version()
|
||||
|
||||
if channel is None:
|
||||
channel = (
|
||||
UpdateChannel.DEVELOP
|
||||
if current_channel == "develop"
|
||||
else UpdateChannel.STABLE
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
GITHUB_API_URL,
|
||||
params={"per_page": 100},
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
|
||||
platform_asset_name = get_platform_asset_name()
|
||||
|
||||
if channel == UpdateChannel.DEVELOP:
|
||||
target_release = None
|
||||
for release in releases:
|
||||
if release.get("draft", False):
|
||||
continue
|
||||
if release.get("tag_name") != "latest":
|
||||
continue
|
||||
for asset in release.get("assets", []):
|
||||
if asset["name"] == platform_asset_name:
|
||||
target_release = _make_release_info(release, asset)
|
||||
break
|
||||
if target_release:
|
||||
break
|
||||
|
||||
if not target_release:
|
||||
return UpdateStatus(
|
||||
update_available=False,
|
||||
current_version=current_version,
|
||||
current_channel=current_channel,
|
||||
latest_release=None,
|
||||
)
|
||||
|
||||
last_installed_id = get_last_installed_asset_id()
|
||||
update_available = (
|
||||
last_installed_id is None
|
||||
or target_release.asset_id != last_installed_id
|
||||
)
|
||||
return UpdateStatus(
|
||||
update_available=update_available,
|
||||
current_version=current_version,
|
||||
current_channel=current_channel,
|
||||
latest_release=target_release,
|
||||
releases_since_current=[target_release] if update_available else [],
|
||||
)
|
||||
|
||||
# STABLE: collect every release newer than the current version so the
|
||||
# dialog can show a full changelog spanning multiple skipped versions.
|
||||
current_tuple = parse_version(current_version)
|
||||
newer_releases: list[ReleaseInfo] = []
|
||||
for release in releases:
|
||||
if release.get("draft", False) or release.get("prerelease", False):
|
||||
continue
|
||||
for asset in release.get("assets", []):
|
||||
if asset["name"] == platform_asset_name:
|
||||
if parse_version(release["tag_name"]) > current_tuple:
|
||||
newer_releases.append(_make_release_info(release, asset))
|
||||
break # found the platform asset for this release
|
||||
|
||||
if not newer_releases:
|
||||
return UpdateStatus(
|
||||
update_available=False,
|
||||
current_version=current_version,
|
||||
current_channel=current_channel,
|
||||
latest_release=None,
|
||||
)
|
||||
|
||||
return UpdateStatus(
|
||||
update_available=True,
|
||||
current_version=current_version,
|
||||
current_channel=current_channel,
|
||||
latest_release=newer_releases[0],
|
||||
releases_since_current=newer_releases,
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
debug_log(f"Failed to check for updates: {e}", "updater")
|
||||
return UpdateStatus(
|
||||
update_available=False,
|
||||
current_version=current_version,
|
||||
current_channel=current_channel,
|
||||
latest_release=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
class DownloadSignals(QObject):
|
||||
"""Signals for download progress updates."""
|
||||
|
||||
progress = pyqtSignal(int, int) # downloaded_bytes, total_bytes
|
||||
completed = pyqtSignal(str) # path to downloaded file
|
||||
error = pyqtSignal(str) # error message
|
||||
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
"""Background worker for downloading updates."""
|
||||
|
||||
def __init__(self, url: str, dest_path: Path, signals: DownloadSignals):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.dest_path = dest_path
|
||||
self.signals = signals
|
||||
self._cancelled = False
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get(self.url, stream=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
|
||||
with open(self.dest_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if self._cancelled:
|
||||
return
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
self.signals.progress.emit(downloaded, total_size)
|
||||
|
||||
self.signals.completed.emit(str(self.dest_path))
|
||||
|
||||
except Exception as e:
|
||||
self.signals.error.emit(str(e))
|
||||
|
||||
def cancel(self):
|
||||
self._cancelled = True
|
||||
|
||||
|
||||
def get_app_path() -> Path:
|
||||
"""Get the path to the current application."""
|
||||
if getattr(sys, "frozen", False):
|
||||
if sys.platform == "darwin":
|
||||
# Jarvis.app/Contents/MacOS/Jarvis -> Jarvis.app
|
||||
return Path(sys.executable).parent.parent.parent
|
||||
elif sys.platform == "win32":
|
||||
return Path(sys.executable)
|
||||
else:
|
||||
return Path(sys.executable).parent
|
||||
else:
|
||||
raise RuntimeError("Cannot update when running from source")
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
"""Check if running as a bundled/frozen application."""
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def install_update_macos(download_path: Path) -> bool:
|
||||
"""Install update on macOS.
|
||||
|
||||
Strategy mirrors Linux: write a shell script that waits for the current
|
||||
process to exit, replaces the .app bundle with `rm -rf` + `mv`, relaunches
|
||||
via `open`, and cleans up temp. Using plain Unix file operations avoids
|
||||
the Finder/AppleScript automation prompts that were failing mid-install
|
||||
and leaving users with a trashed app and no replacement.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
app_path = get_app_path()
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
current_pid = os.getpid()
|
||||
|
||||
try:
|
||||
_extract_macos_bundle(download_path, temp_dir)
|
||||
|
||||
new_app_path = temp_dir / "Jarvis.app"
|
||||
|
||||
if not new_app_path.exists():
|
||||
raise FileNotFoundError("Jarvis.app not found in download")
|
||||
|
||||
# Read the executable name from the new bundle's Info.plist rather
|
||||
# than hardcoding "Jarvis" — if the bundle ever renames its
|
||||
# CFBundleExecutable, the fallback relaunch still targets the right
|
||||
# binary.
|
||||
binary_name = "Jarvis"
|
||||
info_plist = new_app_path / "Contents" / "Info.plist"
|
||||
if info_plist.is_file():
|
||||
try:
|
||||
with info_plist.open("rb") as fp:
|
||||
binary_name = plistlib.load(fp).get("CFBundleExecutable", binary_name)
|
||||
except Exception as e:
|
||||
debug_log(f"Could not read CFBundleExecutable, defaulting to {binary_name}: {e}", "updater")
|
||||
|
||||
escaped_app = _escape_shell_path(app_path)
|
||||
escaped_backup = _escape_shell_path(app_path.with_suffix(app_path.suffix + ".backup"))
|
||||
escaped_new_app = _escape_shell_path(new_app_path)
|
||||
escaped_temp = _escape_shell_path(temp_dir)
|
||||
escaped_binary = _escape_shell_path(app_path / "Contents" / "MacOS" / binary_name)
|
||||
log_path = get_log_dir() / UPDATER_LOG_NAME
|
||||
escaped_log = _escape_shell_path(log_path)
|
||||
log_max = UPDATER_LOG_MAX_BYTES
|
||||
|
||||
# The quarantine strip is essential for unsigned builds: without it,
|
||||
# Gatekeeper may re-prompt with "unidentified developer" on every
|
||||
# update. Keeping the previous bundle as .backup provides a one-step
|
||||
# rollback if the new version fails to launch.
|
||||
#
|
||||
# After the mv swap, LaunchServices still has the old bundle's inode
|
||||
# cached, so a bare `open` can silently no-op. `lsregister -f` forces
|
||||
# a re-scan, `open -n` forces a fresh instance, and if that still
|
||||
# fails we exec the bundle's inner binary directly. Script output is
|
||||
# appended to ~/Library/Logs/Jarvis/updater.log so future failures
|
||||
# leave a trace — the script runs detached with no terminal.
|
||||
script_path = temp_dir / "update.sh"
|
||||
script_content = f'''#!/bin/bash
|
||||
LOG_FILE={escaped_log}
|
||||
if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)" -gt {log_max} ]; then
|
||||
: > "$LOG_FILE"
|
||||
fi
|
||||
exec >> "$LOG_FILE" 2>&1
|
||||
echo "=== Jarvis update $(date) ==="
|
||||
echo "Waiting for process {current_pid} to exit..."
|
||||
while kill -0 {current_pid} 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Process exited, applying update..."
|
||||
rm -rf {escaped_backup}
|
||||
if [ -e {escaped_app} ]; then
|
||||
mv {escaped_app} {escaped_backup}
|
||||
fi
|
||||
mv {escaped_new_app} {escaped_app}
|
||||
xattr -dr com.apple.quarantine {escaped_app} 2>/dev/null || true
|
||||
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
|
||||
if [ -x "$LSREGISTER" ]; then
|
||||
"$LSREGISTER" -f {escaped_app} || true
|
||||
fi
|
||||
echo "Relaunching..."
|
||||
open -n {escaped_app}
|
||||
open_rc=$?
|
||||
if [ $open_rc -ne 0 ]; then
|
||||
echo "open failed (exit $open_rc), execing binary directly"
|
||||
nohup {escaped_binary} >> "$LOG_FILE" 2>&1 &
|
||||
fi
|
||||
rm -rf {escaped_temp}
|
||||
'''
|
||||
script_path.write_text(script_content)
|
||||
script_path.chmod(0o755)
|
||||
|
||||
subprocess.Popen([str(script_path)], start_new_session=True)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"macOS update failed: {e}", "updater")
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
return False
|
||||
|
||||
|
||||
def install_update_windows(download_path: Path) -> bool:
|
||||
"""Install update on Windows.
|
||||
|
||||
Strategy:
|
||||
1. Extract zip to temp location (contains Inno Setup installer as Jarvis.exe)
|
||||
2. Create batch script to:
|
||||
- Wait for current process to actually exit (by PID)
|
||||
- Run the installer silently (upgrades in place to Program Files)
|
||||
- Clean up temp directory
|
||||
3. Execute batch script and exit
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
current_pid = os.getpid()
|
||||
installed_exe_path = get_app_path()
|
||||
|
||||
try:
|
||||
escaped_temp = _escape_batch_path(temp_dir)
|
||||
escaped_installed_exe = _escape_batch_path(installed_exe_path)
|
||||
|
||||
with zipfile.ZipFile(download_path, "r") as zf:
|
||||
zf.extractall(temp_dir)
|
||||
|
||||
new_exe_path = temp_dir / "Jarvis.exe"
|
||||
|
||||
if not new_exe_path.exists():
|
||||
raise FileNotFoundError("Jarvis.exe not found in download")
|
||||
|
||||
escaped_new_exe = _escape_batch_path(new_exe_path)
|
||||
|
||||
batch_script = temp_dir / "update.bat"
|
||||
# Wait for the current process to exit by checking if PID still exists.
|
||||
# tasklist returns errorlevel 0 if process found, 1 if not found.
|
||||
# We use /SILENT (not /VERYSILENT) so Inno Setup shows its own progress
|
||||
# window during install — otherwise the user sees nothing between the
|
||||
# download dialog closing and the new app launching, which can take
|
||||
# long enough to feel like a hang. The installer's own [Run] launch
|
||||
# step is still skipped under /SILENT (skipifsilent), so we relaunch
|
||||
# the upgraded exe ourselves.
|
||||
batch_content = f'''@echo off
|
||||
echo Updating Jarvis...
|
||||
echo Waiting for process {current_pid} to exit...
|
||||
:wait_loop
|
||||
tasklist /fi "pid eq {current_pid}" 2>nul | find "{current_pid}" >nul
|
||||
if not errorlevel 1 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_loop
|
||||
)
|
||||
echo Process exited, running installer...
|
||||
"{escaped_new_exe}" /SILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
echo Launching updated Jarvis...
|
||||
start "" "{escaped_installed_exe}"
|
||||
rmdir /s /q "{escaped_temp}"
|
||||
'''
|
||||
batch_script.write_text(batch_content)
|
||||
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", str(batch_script)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"Windows update failed: {e}", "updater")
|
||||
# Clean up temp dir on failure
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
return False
|
||||
|
||||
|
||||
def install_update_linux(download_path: Path) -> bool:
|
||||
"""Install update on Linux.
|
||||
|
||||
Strategy:
|
||||
1. Extract tar.gz to temp location
|
||||
2. Create shell script to:
|
||||
- Wait for current process to actually exit (by PID)
|
||||
- Replace directory
|
||||
- Launch new app
|
||||
- Clean up temp directory
|
||||
3. Execute script and exit
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
app_dir = get_app_path()
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
current_pid = os.getpid()
|
||||
|
||||
try:
|
||||
with tarfile.open(download_path, "r:gz") as tf:
|
||||
tf.extractall(temp_dir)
|
||||
|
||||
new_app_dir = temp_dir / "Jarvis"
|
||||
|
||||
if not new_app_dir.exists():
|
||||
raise FileNotFoundError("Jarvis directory not found in download")
|
||||
|
||||
# Escape paths using single quotes to prevent shell injection
|
||||
escaped_app_dir = _escape_shell_path(app_dir)
|
||||
escaped_backup = _escape_shell_path(app_dir.with_name(app_dir.name + ".backup"))
|
||||
escaped_new_app = _escape_shell_path(new_app_dir)
|
||||
escaped_temp = _escape_shell_path(temp_dir)
|
||||
escaped_jarvis = _escape_shell_path(app_dir / "Jarvis")
|
||||
|
||||
script_path = temp_dir / "update.sh"
|
||||
# Keeping the previous directory as .backup gives the user a one-step
|
||||
# rollback if the new version fails to launch.
|
||||
script_content = f'''#!/bin/bash
|
||||
echo "Updating Jarvis..."
|
||||
echo "Waiting for process {current_pid} to exit..."
|
||||
while kill -0 {current_pid} 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Process exited, applying update..."
|
||||
rm -rf {escaped_backup}
|
||||
if [ -e {escaped_app_dir} ]; then
|
||||
mv {escaped_app_dir} {escaped_backup}
|
||||
fi
|
||||
mv {escaped_new_app} {escaped_app_dir}
|
||||
{escaped_jarvis} &
|
||||
rm -rf {escaped_temp}
|
||||
'''
|
||||
script_path.write_text(script_content)
|
||||
script_path.chmod(0o755)
|
||||
|
||||
subprocess.Popen([str(script_path)], start_new_session=True)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"Linux update failed: {e}", "updater")
|
||||
return False
|
||||
|
||||
|
||||
def install_update(download_path: Path) -> bool:
|
||||
"""Install update for current platform."""
|
||||
if sys.platform == "darwin":
|
||||
return install_update_macos(download_path)
|
||||
elif sys.platform == "win32":
|
||||
return install_update_windows(download_path)
|
||||
else:
|
||||
return install_update_linux(download_path)
|
||||