Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tauri-plugin-deep-link = "2"
tauri-plugin-updater = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
image = { version = "0.25", default-features = false, features = ["png"] }
serde.workspace = true
serde_json.workspace = true
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"opener:default",
"deep-link:default",
"updater:default",
"process:allow-restart",
"dialog:default"
]
}
40 changes: 39 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,48 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
guard
}

/// Get app version
/// Get app version (compiled into the binary)
#[tauri::command]
fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}

/// Get the on-disk bundle version (macOS only).
///
/// After a Homebrew Cask upgrade, the `.app` bundle on disk has the new version
/// but the running process still has the old compiled-in version. Comparing
/// `get_version()` with `get_bundle_version()` lets the frontend detect this
/// mismatch and prompt the user to restart.
#[tauri::command]
fn get_bundle_version() -> Option<String> {
#[cfg(target_os = "macos")]
{
// Read CFBundleShortVersionString from the running app's Info.plist
let exe = std::env::current_exe().ok()?;
// exe is typically: Foo.app/Contents/MacOS/Foo
let contents_dir = exe.parent()?.parent()?;
let plist_path = contents_dir.join("Info.plist");
let plist = std::fs::read_to_string(&plist_path).ok()?;

// Simple extraction — avoids adding a plist parsing dependency.
// Looks for <key>CFBundleShortVersionString</key>\n<string>X.Y.Z</string>
let key = "CFBundleShortVersionString";
let key_pos = plist.find(key)?;
let after_key = &plist[key_pos + key.len()..];
let string_start = after_key.find("<string>")? + "<string>".len();
let string_end = after_key[string_start..].find("</string>")?;
Some(
after_key[string_start..string_start + string_end]
.trim()
.to_string(),
)
}
#[cfg(not(target_os = "macos"))]
{
None
}
}

/// Get the path to the logs directory
#[tauri::command]
fn get_logs_path() -> String {
Expand Down Expand Up @@ -195,6 +231,7 @@ pub fn run() {
Some(vec!["--hidden"]), // Start minimized to tray
))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// This callback is called when a second instance is launched
info!("Second instance detected, focusing existing window");
Expand Down Expand Up @@ -691,6 +728,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
get_version,
get_bundle_version,
// Space commands
commands::list_spaces,
commands::get_space,
Expand Down
48 changes: 45 additions & 3 deletions apps/desktop/src/features/settings/UpdateChecker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import {
Expand All @@ -9,7 +9,7 @@ import {
CardDescription,
CardContent,
} from '@mcpmux/ui';
import { Download, Loader2, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
import { Download, Loader2, CheckCircle, AlertCircle, RefreshCw, RotateCcw } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';

interface DownloadEvent {
Expand All @@ -27,6 +27,7 @@ export function UpdateChecker() {
const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, total: 0 });
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [bundleVersionMismatch, setBundleVersionMismatch] = useState<string | null>(null);

// Load current version on mount
useState(() => {
Expand All @@ -35,6 +36,23 @@ export function UpdateChecker() {
.catch((err) => console.error('Failed to get version:', err));
});

// Check if the on-disk bundle version differs from the running version (Homebrew Cask upgrades)
useEffect(() => {
if (!currentVersion) return;
invoke<string | null>('get_bundle_version')
.then((bundleVersion) => {
if (bundleVersion && bundleVersion !== currentVersion) {
console.log(
`[Updater] Bundle version mismatch: running=${currentVersion}, on-disk=${bundleVersion}`
);
setBundleVersionMismatch(bundleVersion);
}
})
.catch(() => {
// Expected to return null on non-macOS platforms
});
}, [currentVersion]);

const checkForUpdates = async () => {
setChecking(true);
setMessage(null);
Expand Down Expand Up @@ -149,8 +167,32 @@ export function UpdateChecker() {
</p>
</div>

{/* Bundle version mismatch (e.g., after brew upgrade) */}
{bundleVersionMismatch && (
<div
className="border rounded-lg p-4 space-y-3 bg-surface-secondary"
data-testid="restart-required"
>
<div>
<p className="font-medium text-lg">Restart Required</p>
<p className="text-sm text-[rgb(var(--muted))] mt-1">
Version v{bundleVersionMismatch} has been installed on disk, but you are still
running v{currentVersion}. Restart to apply the update.
</p>
</div>
<Button
onClick={() => relaunch()}
variant="primary"
data-testid="restart-now-btn"
>
<RotateCcw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</div>
)}

{/* Check Button */}
{!updateInfo && (
{!updateInfo && !bundleVersionMismatch && (
<Button
onClick={checkForUpdates}
disabled={checking || downloading}
Expand Down