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.
This commit is contained in:
javis-bot
2026-06-09 14:51:05 +09:00
parent a5bf8d1826
commit c4abf63f38
308 changed files with 94135 additions and 1 deletions

View File

@@ -0,0 +1 @@
Always use the shared theme under `src/desktop_app/themes.py`.

View 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',
]

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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/` |

View 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!")

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View 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...")

View 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()

File diff suppressed because it is too large Load Diff

View 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]

File diff suppressed because it is too large Load Diff

35
src/desktop_app/paths.py Normal file
View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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
View 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)

View 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
View 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)