diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3757367b..60c91236 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,15 +18,27 @@ In development, the desktop UI and Rust auth commands default to the local API a `http://localhost:8787`. Set `NEXT_PUBLIC_BACKEND_URL` / `SUPERMEMORY_API_URL` if you need a different API. +SMFS integration uses the first available binary in this order: + +1. `SUPERMEMORY_DESKTOP_SMFS_BIN` +2. bundled sidecar in `src-tauri/binaries` +3. `smfs` on `PATH` + +The desktop-managed filesystem space uses container tags with the fixed +`sm_fs_` prefix. The default development tag is `sm_fs_desktop`. + ## Build ```sh bun run build # Next static export -> ./out (no Rust needed) -bun run tauri build # native .app / .dmg (requires the Rust toolchain) +bun run build:native # copy local smfs sidecar, then build native .app / .dmg ``` ## Prerequisites for the native build - Rust toolchain (`rustup`) + Xcode Command Line Tools. +- SMFS installed locally (`curl -fsSL https://smfs.ai/install | bash`) for + `bun run build:native`, or a pinned sidecar copied into `src-tauri/binaries` + by CI. - App icons in `src-tauri/icons/` — generate once with `bun run tauri icon `. diff --git a/apps/desktop/app/(app)/settings/page.tsx b/apps/desktop/app/(app)/settings/page.tsx index e7901a63..028bd840 100644 --- a/apps/desktop/app/(app)/settings/page.tsx +++ b/apps/desktop/app/(app)/settings/page.tsx @@ -11,13 +11,27 @@ import { import { Button } from "@ui/components/button" import Link from "next/link" import { useRouter } from "next/navigation" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { clearSession, getSession, type AuthSession } from "@/lib/auth" import { getSpotlightShortcut, setSpotlightShortcut, type SpotlightShortcut, } from "@/lib/spotlight" +import { + getDefaultSmfsContainerTag, + getSmfsLogs, + getSmfsProfile, + getSmfsState, + mountSmfs, + onSmfsStatus, + revealSmfs, + SMFS_CONTAINER_TAG_PREFIX, + type SmfsProfile, + type SmfsStatus, + syncSmfs, + unmountSmfs, +} from "@/lib/smfs" type AppInfo = { name: string @@ -32,6 +46,21 @@ export default function SettingsPage() { const [shortcut, setShortcut] = useState(null) const [shortcutError, setShortcutError] = useState(null) const [savingShortcut, setSavingShortcut] = useState(null) + const [smfsStatuses, setSmfsStatuses] = useState([]) + const [smfsTag, setSmfsTag] = useState("sm_fs_desktop") + const [smfsError, setSmfsError] = useState(null) + const [smfsBusy, setSmfsBusy] = useState(null) + const [smfsLogs, setSmfsLogs] = useState(null) + const [smfsProfile, setSmfsProfile] = useState(null) + + const refreshSmfsState = useCallback(async () => { + setSmfsError(null) + try { + setSmfsStatuses(await getSmfsState()) + } catch (err) { + setSmfsError(formatError(err, "Could not load SMFS status")) + } + }, []) useEffect(() => { invoke("app_info") @@ -43,6 +72,31 @@ export default function SettingsPage() { getSpotlightShortcut() .then(setShortcut) .catch(() => setShortcut(null)) + getDefaultSmfsContainerTag() + .then(setSmfsTag) + .catch(() => setSmfsTag("sm_fs_desktop")) + refreshSmfsState() + }, [refreshSmfsState]) + + useEffect(() => { + let unlisten: (() => void) | undefined + + onSmfsStatus((event) => { + if (event.statuses.length > 0) { + setSmfsStatuses(event.statuses) + } + setSmfsError(event.error ?? null) + }) + .then((handler) => { + unlisten = handler + }) + .catch(() => { + unlisten = undefined + }) + + return () => { + unlisten?.() + } }, []) async function signOut() { @@ -68,6 +122,46 @@ export default function SettingsPage() { } } + async function runSmfsAction( + action: string, + fn: () => Promise, + ) { + setSmfsError(null) + setSmfsBusy(action) + try { + const result = await fn() + setSmfsProfile(null) + if (typeof result === "string") { + setSmfsLogs(result || "No logs available.") + } else if (result) { + setSmfsStatuses([result]) + } else { + await refreshSmfsState() + } + } catch (err) { + setSmfsError(formatError(err, `Could not ${action}`)) + } finally { + setSmfsBusy(null) + } + } + + const smfsStatus = smfsStatuses[0] + const smfsSuffix = smfsTag.startsWith(SMFS_CONTAINER_TAG_PREFIX) + ? smfsTag.slice(SMFS_CONTAINER_TAG_PREFIX.length) + : smfsTag + + async function loadSmfsProfile() { + setSmfsError(null) + setSmfsBusy("read profile.md") + try { + setSmfsProfile(await getSmfsProfile(smfsTag)) + } catch (err) { + setSmfsError(formatError(err, "Could not read profile.md")) + } finally { + setSmfsBusy(null) + } + } + return (

Settings

@@ -127,6 +221,176 @@ export default function SettingsPage() { + + + Memory filesystem + + Desktop-managed SMFS mount for a filesystem-backed space. + + + + + + + + + + + +
+ + + + + +
+ + + {smfsBusy ? ( +

Working: {smfsBusy}

+ ) : null} + {(smfsError ?? smfsStatus?.error) ? ( +

+ {smfsError ?? smfsStatus?.error} +

+ ) : null} + {smfsStatus?.state === "missing-binary" ? ( +

+ Install SMFS locally or set SUPERMEMORY_DESKTOP_SMFS_BIN to a + binary path while developing. Production builds should bundle the + sidecar in src-tauri/binaries. +

+ ) : null} + {smfsProfile ? ( +
+ +
+								{smfsProfile.content}
+							
+
+ ) : null} + {smfsLogs ? ( +
+							{smfsLogs}
+						
+ ) : null} +
+
+ About @@ -162,9 +426,11 @@ const SHORTCUT_OPTIONS = [ function Row({ label, value }: { label: string; value: string }) { return ( -
+
{label} - {value} + + {value} +
) } @@ -179,3 +445,34 @@ function formatShortcut(value: string) { .replace("Shift", "⇧") .replaceAll("+", " ") } + +function formatError(error: unknown, fallback: string) { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : fallback +} + +function sanitizeSmfsSuffix(value: string) { + return value.replace(/[^A-Za-z0-9_-]/g, "") +} + +function formatSmfsState(state?: SmfsStatus["state"]) { + switch (state) { + case "degraded": + return "Mounted with warnings" + case "external": + return "External mount" + case "error": + return "Error" + case "missing-binary": + return "Missing binary" + case "mounted": + return "Mounted" + case "unmounted": + return "Unmounted" + default: + return "Loading" + } +} diff --git a/apps/desktop/lib/smfs.ts b/apps/desktop/lib/smfs.ts new file mode 100644 index 00000000..86f4ab24 --- /dev/null +++ b/apps/desktop/lib/smfs.ts @@ -0,0 +1,73 @@ +"use client" + +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" + +export type SmfsStatus = { + tag: string + state: + | "external" + | "mounted" + | "degraded" + | "unmounted" + | "missing-binary" + | "error" + mountPath: string + ownedByApp: boolean + profileAvailable: boolean + lastSync?: string | null + binaryPath: string + error?: string | null +} + +export type SmfsStatusEvent = { + statuses: SmfsStatus[] + error?: string | null +} + +export type SmfsProfile = { + tag: string + path: string + content: string +} + +export const SMFS_CONTAINER_TAG_PREFIX = "sm_fs_" +export const SMFS_STATUS_EVENT = "smfs:status" + +export function getSmfsState() { + return invoke("smfs_state") +} + +export function mountSmfs(tag?: string) { + return invoke("smfs_mount", { tag }) +} + +export function unmountSmfs(tag?: string) { + return invoke("smfs_unmount", { tag }) +} + +export function syncSmfs(tag?: string) { + return invoke("smfs_sync", { tag }) +} + +export function revealSmfs(tag?: string) { + return invoke("smfs_reveal", { tag }) +} + +export function getSmfsLogs(tag?: string, lines = 200) { + return invoke("smfs_logs", { tag, lines }) +} + +export function getDefaultSmfsContainerTag() { + return invoke("smfs_default_container_tag") +} + +export function getSmfsProfile(tag?: string) { + return invoke("smfs_profile", { tag }) +} + +export function onSmfsStatus(handler: (event: SmfsStatusEvent) => void) { + return listen(SMFS_STATUS_EVENT, (event) => { + handler(event.payload) + }) +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0e479f1f..e52df73d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "NEXT_PUBLIC_DESKTOP_DEV=1 NEXT_PUBLIC_BACKEND_URL=http://localhost:8787 next dev --port 3003", "build": "next build", + "prepare-smfs-sidecar": "mkdir -p src-tauri/binaries && cp \"$(command -v smfs)\" \"src-tauri/binaries/smfs-$(rustc -vV | sed -n 's/^host: //p')\"", + "build:native": "bun run prepare-smfs-sidecar && tauri build", "check-types": "tsc --noEmit", "tauri": "tauri" }, diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore index 1a2a9989..86024c22 100644 --- a/apps/desktop/src-tauri/.gitignore +++ b/apps/desktop/src-tauri/.gitignore @@ -3,3 +3,7 @@ # Tauri-generated schemas / artifacts /gen/schemas + +# SMFS sidecars are copied or downloaded per target before native packaging. +/binaries/* +!/binaries/.gitkeep diff --git a/apps/desktop/src-tauri/binaries/.gitkeep b/apps/desktop/src-tauri/binaries/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/apps/desktop/src-tauri/binaries/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index f9a309ed..05a7b640 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -46,7 +46,7 @@ fn get_cached_token() -> Result, String> { .map_err(|_| "Could not lock token cache".to_string()) } -fn api_url() -> String { +pub fn api_url() -> String { if let Ok(api_url) = std::env::var("SUPERMEMORY_DESKTOP_API_URL") { return api_url; } @@ -139,7 +139,9 @@ pub async fn whoami() -> Result { fn first_string(value: &Value, paths: &[&[&str]]) -> Option { paths.iter().find_map(|path| { - let found = path.iter().try_fold(value, |current, key| current.get(key))?; + let found = path + .iter() + .try_fold(value, |current, key| current.get(key))?; found.as_str().map(ToString::to_string) }) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c6d19239..71057db0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auth; +mod smfs; mod spotlight; mod tray; @@ -80,6 +81,50 @@ fn spotlight_set_shortcut( spotlight::set_shortcut(&app, &state, accelerator) } +#[tauri::command] +fn smfs_state(app: tauri::AppHandle) -> Result, String> { + smfs::state(&app) +} + +#[tauri::command] +fn smfs_mount(app: tauri::AppHandle, tag: Option) -> Result { + smfs::mount(&app, tag) +} + +#[tauri::command] +fn smfs_unmount(app: tauri::AppHandle, tag: Option) -> Result { + smfs::unmount(&app, tag) +} + +#[tauri::command] +fn smfs_sync(app: tauri::AppHandle, tag: Option) -> Result { + smfs::sync(&app, tag) +} + +#[tauri::command] +fn smfs_reveal(app: tauri::AppHandle, tag: Option) -> Result<(), String> { + smfs::reveal(&app, tag) +} + +#[tauri::command] +fn smfs_logs( + app: tauri::AppHandle, + tag: Option, + lines: Option, +) -> Result { + smfs::logs(&app, tag, lines) +} + +#[tauri::command] +fn smfs_default_container_tag() -> String { + smfs::default_container_tag().to_string() +} + +#[tauri::command] +fn smfs_profile(app: tauri::AppHandle, tag: Option) -> Result { + smfs::profile(&app, tag) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -90,6 +135,7 @@ pub fn run() { app.manage(spotlight::SpotlightShortcutState::new(accelerator.clone())); spotlight::register_shortcut(app, &accelerator); tray::create(app)?; + smfs::start_status_poller(app.handle().clone()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -102,7 +148,15 @@ pub fn run() { spotlight_hide, spotlight_open_result, spotlight_get_shortcut, - spotlight_set_shortcut + spotlight_set_shortcut, + smfs_state, + smfs_mount, + smfs_unmount, + smfs_sync, + smfs_reveal, + smfs_logs, + smfs_default_container_tag, + smfs_profile ]) // Bootstrap failure is unrecoverable (no window, no app), so we abort // loudly here. This is the one sanctioned `expect` — see roadmap quality bar. diff --git a/apps/desktop/src-tauri/src/smfs.rs b/apps/desktop/src-tauri/src/smfs.rs new file mode 100644 index 00000000..73dd6a07 --- /dev/null +++ b/apps/desktop/src-tauri/src/smfs.rs @@ -0,0 +1,543 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::auth; + +pub const CONTAINER_TAG_PREFIX: &str = "sm_fs_"; +const DEFAULT_CONTAINER_TAG: &str = "sm_fs_desktop"; +const STATUS_EVENT: &str = "smfs:status"; +const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(10); +const OWNERSHIP_DIR: &str = "smfs-owned-mounts"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SmfsStatus { + tag: String, + state: SmfsState, + mount_path: String, + owned_by_app: bool, + profile_available: bool, + last_sync: Option, + binary_path: String, + error: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +enum SmfsState { + External, + Mounted, + Degraded, + Unmounted, + MissingBinary, + Error, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SmfsStatusEvent { + statuses: Vec, + error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SmfsProfile { + tag: String, + path: String, + content: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct OwnershipMarker { + tag: String, + mount_path: String, + created_at_unix: u64, +} + +pub fn default_container_tag() -> &'static str { + DEFAULT_CONTAINER_TAG +} + +pub fn start_status_poller(app: AppHandle) { + thread::spawn(move || loop { + let _ = emit_status(&app); + thread::sleep(STATUS_POLL_INTERVAL); + }); +} + +pub fn emit_status(app: &AppHandle) -> Result<(), String> { + let event = match state(app) { + Ok(statuses) => SmfsStatusEvent { + statuses, + error: None, + }, + Err(error) => SmfsStatusEvent { + statuses: Vec::new(), + error: Some(error), + }, + }; + + app.emit(STATUS_EVENT, event) + .map_err(|error| error.to_string()) +} + +pub fn state(app: &AppHandle) -> Result, String> { + Ok(vec![status_for_tag(app, DEFAULT_CONTAINER_TAG)?]) +} + +pub fn mount(app: &AppHandle, tag: Option) -> Result { + let tag = normalize_tag(tag)?; + let token = auth::get_token()?.ok_or_else(|| "No stored token".to_string())?; + let mount_path = expected_mount_path(app, &tag)?; + fs::create_dir_all(&mount_path).map_err(|error| error.to_string())?; + + let status = status_for_tag(app, &tag)?; + match status.state { + SmfsState::External => { + return Err(format!( + "{tag} is already mounted outside Supermemory Desktop; leaving it untouched" + )); + } + SmfsState::Mounted | SmfsState::Degraded if status.owned_by_app => { + return Ok(status); + } + SmfsState::Mounted | SmfsState::Degraded => { + return Err(format!( + "{tag} is already mounted but is not marked as app-owned; leaving it untouched" + )); + } + SmfsState::MissingBinary => { + return Err(status + .error + .unwrap_or_else(|| "Could not find the smfs binary".to_string())); + } + SmfsState::Error | SmfsState::Unmounted => {} + } + + let mount_path_string = path_to_string(&mount_path); + let output = run_smfs( + app, + [ + "mount", + &tag, + "--path", + &mount_path_string, + "--key", + &token, + "--api-url", + &auth::api_url(), + "--sync-interval", + "30", + ], + )?; + ensure_success(output)?; + write_ownership_marker(app, &tag, &mount_path)?; + + let status = status_for_tag(app, &tag)?; + let _ = emit_status(app); + Ok(status) +} + +pub fn unmount(app: &AppHandle, tag: Option) -> Result { + let tag = normalize_tag(tag)?; + let status = status_for_tag(app, &tag)?; + + if matches!(status.state, SmfsState::Unmounted) { + let _ = remove_ownership_marker(app, &tag); + return Ok(status); + } + + if !status.owned_by_app { + return Err(format!( + "{tag} is not an app-owned SMFS mount; refusing to unmount it" + )); + } + + let output = run_smfs(app, ["unmount", &tag])?; + ensure_success(output)?; + remove_ownership_marker(app, &tag)?; + + let status = status_for_tag(app, &tag)?; + let _ = emit_status(app); + Ok(status) +} + +pub fn sync(app: &AppHandle, tag: Option) -> Result { + let tag = normalize_tag(tag)?; + let status = status_for_tag(app, &tag)?; + if !matches!( + status.state, + SmfsState::Mounted | SmfsState::Degraded | SmfsState::External + ) { + return Err(format!("{tag} is not mounted")); + } + + let output = run_smfs(app, ["sync", &tag])?; + ensure_success(output)?; + + let status = status_for_tag(app, &tag)?; + let _ = emit_status(app); + Ok(status) +} + +pub fn reveal(app: &AppHandle, tag: Option) -> Result<(), String> { + let tag = normalize_tag(tag)?; + let status = status_for_tag(app, &tag)?; + let path = if matches!( + status.state, + SmfsState::Mounted | SmfsState::Degraded | SmfsState::External + ) { + PathBuf::from(status.mount_path) + } else { + expected_mount_path(app, &tag)? + }; + fs::create_dir_all(&path).map_err(|error| error.to_string())?; + + let output = Command::new("open") + .arg(path) + .output() + .map_err(|error| format!("Could not open Finder: {error}"))?; + ensure_success(output) +} + +pub fn logs(app: &AppHandle, tag: Option, lines: Option) -> Result { + let tag = normalize_tag(tag)?; + let line_count = lines.unwrap_or(200).clamp(1, 1000).to_string(); + let output = run_smfs(app, ["logs", "-n", &line_count, &tag])?; + output_text(output) +} + +pub fn profile(app: &AppHandle, tag: Option) -> Result { + let tag = normalize_tag(tag)?; + let status = status_for_tag(app, &tag)?; + if !matches!( + status.state, + SmfsState::Mounted | SmfsState::Degraded | SmfsState::External + ) { + return Err(format!("{tag} is not mounted")); + } + + let profile_path = PathBuf::from(status.mount_path).join("profile.md"); + let content = fs::read_to_string(&profile_path) + .map_err(|error| format!("Could not read profile.md: {error}"))?; + + Ok(SmfsProfile { + tag, + path: path_to_string(&profile_path), + content, + }) +} + +fn status_for_tag(app: &AppHandle, tag: &str) -> Result { + validate_tag(tag)?; + + let expected_mount_path = expected_mount_path(app, tag)?; + let binary_path = smfs_bin(app); + let binary_path_string = path_to_string(&binary_path); + let output = match run_smfs(app, ["status", tag, "--json"]) { + Ok(output) => output, + Err(error) if is_missing_binary_error(&error) => { + return Ok(SmfsStatus { + tag: tag.to_string(), + state: SmfsState::MissingBinary, + mount_path: path_to_string(&expected_mount_path), + owned_by_app: false, + profile_available: false, + last_sync: None, + binary_path: binary_path_string, + error: Some(error), + }); + } + Err(error) => { + return Ok(SmfsStatus { + tag: tag.to_string(), + state: SmfsState::Error, + mount_path: path_to_string(&expected_mount_path), + owned_by_app: false, + profile_available: false, + last_sync: None, + binary_path: binary_path_string, + error: Some(error), + }); + } + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + let parsed = serde_json::from_str::(&stdout).unwrap_or(Value::Null); + let active_mount_path = first_string( + &parsed, + &[ + &["mountPath"], + &["mount_path"], + &["path"], + &["mount", "path"], + &["mount", "mountPath"], + ], + ) + .map(PathBuf::from) + .unwrap_or_else(|| expected_mount_path.clone()); + let owned_by_app = is_app_owned_mount(app, tag, &active_mount_path); + let profile_available = active_mount_path.join("profile.md").exists(); + let state = if is_degraded(&parsed) { + SmfsState::Degraded + } else if owned_by_app { + SmfsState::Mounted + } else { + SmfsState::External + }; + + return Ok(SmfsStatus { + tag: tag.to_string(), + state, + mount_path: path_to_string(&active_mount_path), + owned_by_app, + profile_available, + last_sync: first_string(&parsed, &[&["lastSync"], &["last_sync"]]), + binary_path: binary_path_string, + error: None, + }); + } + + let message = format!("{stdout}{stderr}"); + if message.contains("daemon not running") + || message.contains("no socket") + || message.contains("not mounted") + { + return Ok(SmfsStatus { + tag: tag.to_string(), + state: SmfsState::Unmounted, + mount_path: path_to_string(&expected_mount_path), + owned_by_app: false, + profile_available: false, + last_sync: None, + binary_path: binary_path_string, + error: None, + }); + } + + Ok(SmfsStatus { + tag: tag.to_string(), + state: SmfsState::Error, + mount_path: path_to_string(&expected_mount_path), + owned_by_app: false, + profile_available: false, + last_sync: None, + binary_path: binary_path_string, + error: Some(message.trim().to_string()), + }) +} + +fn normalize_tag(tag: Option) -> Result { + let tag = tag.unwrap_or_else(|| DEFAULT_CONTAINER_TAG.to_string()); + validate_tag(&tag)?; + Ok(tag) +} + +fn validate_tag(tag: &str) -> Result<(), String> { + if !tag.starts_with(CONTAINER_TAG_PREFIX) { + return Err(format!( + "SMFS desktop container tags must start with {CONTAINER_TAG_PREFIX}" + )); + } + + if tag.len() <= CONTAINER_TAG_PREFIX.len() { + return Err("SMFS desktop container tag needs a suffix after sm_fs_".to_string()); + } + + if tag + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-') + { + Ok(()) + } else { + Err("SMFS desktop container tags can only contain letters, numbers, underscores, and hyphens".to_string()) + } +} + +fn expected_mount_path(app: &AppHandle, tag: &str) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| error.to_string())? + .join("mounts") + .join(tag)) +} + +fn ownership_marker_path(app: &AppHandle, tag: &str) -> Result { + Ok(app + .path() + .app_config_dir() + .map_err(|error| error.to_string())? + .join(OWNERSHIP_DIR) + .join(format!("{tag}.json"))) +} + +fn write_ownership_marker(app: &AppHandle, tag: &str, mount_path: &Path) -> Result<(), String> { + let marker_path = ownership_marker_path(app, tag)?; + let parent = marker_path + .parent() + .ok_or_else(|| "Could not resolve SMFS ownership directory".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + + let created_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_secs(); + let payload = serde_json::to_vec_pretty(&OwnershipMarker { + tag: tag.to_string(), + mount_path: path_to_string(mount_path), + created_at_unix, + }) + .map_err(|error| error.to_string())?; + let temp_path = marker_path.with_extension("json.tmp"); + fs::write(&temp_path, payload).map_err(|error| error.to_string())?; + fs::rename(temp_path, marker_path).map_err(|error| error.to_string()) +} + +fn remove_ownership_marker(app: &AppHandle, tag: &str) -> Result<(), String> { + let marker_path = ownership_marker_path(app, tag)?; + match fs::remove_file(marker_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn is_app_owned_mount(app: &AppHandle, tag: &str, active_mount_path: &Path) -> bool { + let Some(marker) = read_ownership_marker(app, tag) else { + return false; + }; + + marker.tag == tag && PathBuf::from(marker.mount_path) == active_mount_path +} + +fn read_ownership_marker(app: &AppHandle, tag: &str) -> Option { + let path = ownership_marker_path(app, tag).ok()?; + let contents = fs::read_to_string(path).ok()?; + serde_json::from_str::(&contents).ok() +} + +fn run_smfs(app: &AppHandle, args: [&str; N]) -> Result { + let binary = smfs_bin(app); + Command::new(&binary) + .args(args) + .env("SUPERMEMORY_API_URL", auth::api_url()) + .output() + .map_err(|error| format!("Could not run smfs at {}: {error}", path_to_string(&binary))) +} + +fn smfs_bin(app: &AppHandle) -> PathBuf { + if let Ok(path) = env::var("SUPERMEMORY_DESKTOP_SMFS_BIN") { + return PathBuf::from(path); + } + + for candidate in smfs_sidecar_candidates(app) { + if candidate.is_file() { + return candidate; + } + } + + find_in_path("smfs").unwrap_or_else(|| PathBuf::from("smfs")) +} + +fn smfs_sidecar_candidates(app: &AppHandle) -> Vec { + let mut candidates = Vec::new(); + + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("binaries").join("smfs")); + candidates.push(resource_dir.join("smfs")); + candidates.push(resource_dir.join("binaries").join(sidecar_name())); + candidates.push(resource_dir.join(sidecar_name())); + } + + if let Ok(exe_path) = env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + candidates.push(exe_dir.join("binaries").join("smfs")); + candidates.push(exe_dir.join("smfs")); + candidates.push(exe_dir.join("binaries").join(sidecar_name())); + candidates.push(exe_dir.join(sidecar_name())); + } + } + + candidates +} + +fn sidecar_name() -> String { + format!("smfs-{}", target_triple()) +} + +fn target_triple() -> String { + let arch = env::consts::ARCH; + let os = match env::consts::OS { + "macos" => "apple-darwin", + "linux" => "unknown-linux-gnu", + other => other, + }; + format!("{arch}-{os}") +} + +fn find_in_path(binary_name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|entry| entry.join(binary_name)) + .find(|candidate| candidate.is_file()) +} + +fn ensure_success(output: Output) -> Result<(), String> { + if output.status.success() { + Ok(()) + } else { + Err(output_text(output)?) + } +} + +fn output_text(output: Output) -> Result { + let stdout = String::from_utf8(output.stdout).map_err(|error| error.to_string())?; + let stderr = String::from_utf8(output.stderr).map_err(|error| error.to_string())?; + let text = format!("{stdout}{stderr}"); + Ok(text.trim().to_string()) +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn first_string(value: &Value, paths: &[&[&str]]) -> Option { + paths.iter().find_map(|path| { + let found = path + .iter() + .try_fold(value, |current, key| current.get(key))?; + found.as_str().map(ToString::to_string) + }) +} + +fn is_degraded(value: &Value) -> bool { + first_string( + value, + &[ + &["state"], + &["status"], + &["health"], + &["mount", "state"], + &["mount", "status"], + ], + ) + .is_some_and(|state| state.eq_ignore_ascii_case("degraded")) +} + +fn is_missing_binary_error(error: &str) -> bool { + error.contains("No such file or directory") || error.contains("os error 2") +} diff --git a/apps/desktop/src-tauri/src/spotlight.rs b/apps/desktop/src-tauri/src/spotlight.rs index 67c68392..c174e220 100644 --- a/apps/desktop/src-tauri/src/spotlight.rs +++ b/apps/desktop/src-tauri/src/spotlight.rs @@ -2,12 +2,9 @@ use std::{fs, sync::Mutex}; use serde::{Deserialize, Serialize}; use tauri::{ - App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, - WindowEvent, -}; -use tauri_plugin_global_shortcut::{ - GlobalShortcutExt, Shortcut, ShortcutEvent, ShortcutState, + App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutEvent, ShortcutState}; const MAIN_LABEL: &str = "main"; const SPOTLIGHT_LABEL: &str = "spotlight"; @@ -59,20 +56,17 @@ pub fn create_window(app: &App) -> tauri::Result { return Ok(window); } - let window = WebviewWindowBuilder::new( - app, - SPOTLIGHT_LABEL, - WebviewUrl::App("spotlight/".into()), - ) - .title("Supermemory Spotlight") - .inner_size(760.0, 420.0) - .min_inner_size(560.0, 220.0) - .decorations(false) - .always_on_top(true) - .skip_taskbar(true) - .visible(false) - .center() - .build()?; + let window = + WebviewWindowBuilder::new(app, SPOTLIGHT_LABEL, WebviewUrl::App("spotlight/".into())) + .title("Supermemory Spotlight") + .inner_size(760.0, 420.0) + .min_inner_size(560.0, 220.0) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .center() + .build()?; let window_for_blur = window.clone(); window.on_window_event(move |event| { @@ -194,18 +188,18 @@ where } fn unregister_accelerator(app: &AppHandle, accelerator: &str) -> Result<(), String> { - validate_accelerator(accelerator)?; - app.global_shortcut() - .unregister(accelerator) - .map_err(|error| error.to_string()) + validate_accelerator(accelerator)?; + app.global_shortcut() + .unregister(accelerator) + .map_err(|error| error.to_string()) } fn unregister_accelerator_if_registered(app: &AppHandle, accelerator: &str) -> Result<(), String> { - validate_accelerator(accelerator)?; - if app.global_shortcut().is_registered(accelerator) { - unregister_accelerator(app, accelerator)?; - } - Ok(()) + validate_accelerator(accelerator)?; + if app.global_shortcut().is_registered(accelerator) { + unregister_accelerator(app, accelerator)?; + } + Ok(()) } fn validate_accelerator(accelerator: &str) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 22f4b94a..0e98c37c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -29,6 +29,7 @@ "bundle": { "active": true, "targets": ["app", "dmg"], + "externalBin": ["binaries/smfs"], "icon": [ "icons/32x32.png", "icons/128x128.png",