mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-06 02:47:54 +00:00
Add desktop SMFS integration
This commit is contained in:
parent
1b7f59f88b
commit
7249b287e9
11 changed files with 1018 additions and 35 deletions
|
|
@ -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 <path-to-1024px.png>`.
|
||||
|
|
|
|||
|
|
@ -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<SpotlightShortcut | null>(null)
|
||||
const [shortcutError, setShortcutError] = useState<string | null>(null)
|
||||
const [savingShortcut, setSavingShortcut] = useState<string | null>(null)
|
||||
const [smfsStatuses, setSmfsStatuses] = useState<SmfsStatus[]>([])
|
||||
const [smfsTag, setSmfsTag] = useState("sm_fs_desktop")
|
||||
const [smfsError, setSmfsError] = useState<string | null>(null)
|
||||
const [smfsBusy, setSmfsBusy] = useState<string | null>(null)
|
||||
const [smfsLogs, setSmfsLogs] = useState<string | null>(null)
|
||||
const [smfsProfile, setSmfsProfile] = useState<SmfsProfile | null>(null)
|
||||
|
||||
const refreshSmfsState = useCallback(async () => {
|
||||
setSmfsError(null)
|
||||
try {
|
||||
setSmfsStatuses(await getSmfsState())
|
||||
} catch (err) {
|
||||
setSmfsError(formatError(err, "Could not load SMFS status"))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
invoke<AppInfo>("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<SmfsStatus | string | undefined>,
|
||||
) {
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-2xl p-8">
|
||||
<h1 className="mb-6 font-semibold text-2xl">Settings</h1>
|
||||
|
|
@ -127,6 +221,176 @@ export default function SettingsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Memory filesystem</CardTitle>
|
||||
<CardDescription>
|
||||
Desktop-managed SMFS mount for a filesystem-backed space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<label className="block space-y-1">
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Filesystem space tag
|
||||
</span>
|
||||
<span className="flex overflow-hidden rounded-md border border-border bg-background">
|
||||
<span className="flex shrink-0 items-center border-border border-r bg-muted/30 px-3 font-mono text-muted-foreground text-xs">
|
||||
{SMFS_CONTAINER_TAG_PREFIX}
|
||||
</span>
|
||||
<input
|
||||
value={smfsSuffix}
|
||||
onChange={(event) =>
|
||||
setSmfsTag(
|
||||
`${SMFS_CONTAINER_TAG_PREFIX}${sanitizeSmfsSuffix(
|
||||
event.target.value,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
disabled={smfsBusy !== null || smfsStatus?.ownedByApp}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 py-2 font-mono text-sm outline-none"
|
||||
aria-label="SMFS container tag suffix"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<Row label="Active tag" value={smfsStatus?.tag ?? smfsTag} />
|
||||
<Row label="Required prefix" value={SMFS_CONTAINER_TAG_PREFIX} />
|
||||
<Row label="State" value={formatSmfsState(smfsStatus?.state)} />
|
||||
<Row label="Mount path" value={smfsStatus?.mountPath ?? "..."} />
|
||||
<Row label="Binary" value={smfsStatus?.binaryPath ?? "..."} />
|
||||
<Row
|
||||
label="Ownership"
|
||||
value={
|
||||
smfsStatus?.ownedByApp
|
||||
? "Managed by desktop"
|
||||
: smfsStatus?.state === "external"
|
||||
? "External mount"
|
||||
: "Not mounted"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="profile.md"
|
||||
value={
|
||||
smfsStatus?.profileAvailable
|
||||
? "Available"
|
||||
: smfsStatus?.state === "mounted" ||
|
||||
smfsStatus?.state === "external"
|
||||
? "Not ready yet"
|
||||
: "Unavailable"
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={smfsBusy !== null}
|
||||
onClick={() =>
|
||||
runSmfsAction("refresh SMFS", async () => {
|
||||
await refreshSmfsState()
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={smfsBusy !== null || smfsStatus?.state === "external"}
|
||||
onClick={() =>
|
||||
runSmfsAction("mount SMFS", () => mountSmfs(smfsTag))
|
||||
}
|
||||
>
|
||||
Mount
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={smfsBusy !== null || !smfsStatus?.ownedByApp}
|
||||
onClick={() =>
|
||||
runSmfsAction("sync SMFS", () => syncSmfs(smfsTag))
|
||||
}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={smfsBusy !== null}
|
||||
onClick={() =>
|
||||
runSmfsAction("reveal SMFS", async () => {
|
||||
await revealSmfs(smfsTag)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
>
|
||||
Reveal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={smfsBusy !== null || !smfsStatus?.ownedByApp}
|
||||
onClick={() =>
|
||||
runSmfsAction("unmount SMFS", () => unmountSmfs(smfsTag))
|
||||
}
|
||||
>
|
||||
Unmount
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={smfsBusy !== null}
|
||||
onClick={() =>
|
||||
runSmfsAction("read SMFS logs", () => getSmfsLogs(smfsTag, 200))
|
||||
}
|
||||
>
|
||||
Load logs
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
smfsBusy !== null ||
|
||||
!(
|
||||
smfsStatus?.state === "mounted" ||
|
||||
smfsStatus?.state === "external" ||
|
||||
smfsStatus?.state === "degraded"
|
||||
)
|
||||
}
|
||||
onClick={() => loadSmfsProfile()}
|
||||
>
|
||||
Read profile.md
|
||||
</Button>
|
||||
{smfsBusy ? (
|
||||
<p className="text-muted-foreground text-xs">Working: {smfsBusy}</p>
|
||||
) : null}
|
||||
{(smfsError ?? smfsStatus?.error) ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{smfsError ?? smfsStatus?.error}
|
||||
</p>
|
||||
) : null}
|
||||
{smfsStatus?.state === "missing-binary" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
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.
|
||||
</p>
|
||||
) : null}
|
||||
{smfsProfile ? (
|
||||
<div className="space-y-2">
|
||||
<Row label="Profile path" value={smfsProfile.path} />
|
||||
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-muted/20 p-3 whitespace-pre-wrap text-xs">
|
||||
{smfsProfile.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{smfsLogs ? (
|
||||
<pre className="max-h-52 overflow-auto rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||
{smfsLogs}
|
||||
</pre>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">About</CardTitle>
|
||||
|
|
@ -162,9 +426,11 @@ const SHORTCUT_OPTIONS = [
|
|||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="grid grid-cols-[8rem_minmax(0,1fr)] gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{value}</span>
|
||||
<span className="truncate font-medium tabular-nums" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
apps/desktop/lib/smfs.ts
Normal file
73
apps/desktop/lib/smfs.ts
Normal file
|
|
@ -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<SmfsStatus[]>("smfs_state")
|
||||
}
|
||||
|
||||
export function mountSmfs(tag?: string) {
|
||||
return invoke<SmfsStatus>("smfs_mount", { tag })
|
||||
}
|
||||
|
||||
export function unmountSmfs(tag?: string) {
|
||||
return invoke<SmfsStatus>("smfs_unmount", { tag })
|
||||
}
|
||||
|
||||
export function syncSmfs(tag?: string) {
|
||||
return invoke<SmfsStatus>("smfs_sync", { tag })
|
||||
}
|
||||
|
||||
export function revealSmfs(tag?: string) {
|
||||
return invoke<void>("smfs_reveal", { tag })
|
||||
}
|
||||
|
||||
export function getSmfsLogs(tag?: string, lines = 200) {
|
||||
return invoke<string>("smfs_logs", { tag, lines })
|
||||
}
|
||||
|
||||
export function getDefaultSmfsContainerTag() {
|
||||
return invoke<string>("smfs_default_container_tag")
|
||||
}
|
||||
|
||||
export function getSmfsProfile(tag?: string) {
|
||||
return invoke<SmfsProfile>("smfs_profile", { tag })
|
||||
}
|
||||
|
||||
export function onSmfsStatus(handler: (event: SmfsStatusEvent) => void) {
|
||||
return listen<SmfsStatusEvent>(SMFS_STATUS_EVENT, (event) => {
|
||||
handler(event.payload)
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
4
apps/desktop/src-tauri/.gitignore
vendored
4
apps/desktop/src-tauri/.gitignore
vendored
|
|
@ -3,3 +3,7 @@
|
|||
|
||||
# Tauri-generated schemas / artifacts
|
||||
/gen/schemas
|
||||
|
||||
# SMFS sidecars are copied or downloaded per target before native packaging.
|
||||
/binaries/*
|
||||
!/binaries/.gitkeep
|
||||
|
|
|
|||
1
apps/desktop/src-tauri/binaries/.gitkeep
Normal file
1
apps/desktop/src-tauri/binaries/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -46,7 +46,7 @@ fn get_cached_token() -> Result<Option<String>, 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<AuthSession, String> {
|
|||
|
||||
fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<smfs::SmfsStatus>, String> {
|
||||
smfs::state(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn smfs_mount(app: tauri::AppHandle, tag: Option<String>) -> Result<smfs::SmfsStatus, String> {
|
||||
smfs::mount(&app, tag)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn smfs_unmount(app: tauri::AppHandle, tag: Option<String>) -> Result<smfs::SmfsStatus, String> {
|
||||
smfs::unmount(&app, tag)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn smfs_sync(app: tauri::AppHandle, tag: Option<String>) -> Result<smfs::SmfsStatus, String> {
|
||||
smfs::sync(&app, tag)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn smfs_reveal(app: tauri::AppHandle, tag: Option<String>) -> Result<(), String> {
|
||||
smfs::reveal(&app, tag)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn smfs_logs(
|
||||
app: tauri::AppHandle,
|
||||
tag: Option<String>,
|
||||
lines: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
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<String>) -> Result<smfs::SmfsProfile, String> {
|
||||
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.
|
||||
|
|
|
|||
543
apps/desktop/src-tauri/src/smfs.rs
Normal file
543
apps/desktop/src-tauri/src/smfs.rs
Normal file
|
|
@ -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<String>,
|
||||
binary_path: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<SmfsStatus>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Vec<SmfsStatus>, String> {
|
||||
Ok(vec![status_for_tag(app, DEFAULT_CONTAINER_TAG)?])
|
||||
}
|
||||
|
||||
pub fn mount(app: &AppHandle, tag: Option<String>) -> Result<SmfsStatus, String> {
|
||||
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<String>) -> Result<SmfsStatus, String> {
|
||||
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<String>) -> Result<SmfsStatus, String> {
|
||||
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<String>) -> 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<String>, lines: Option<u32>) -> Result<String, String> {
|
||||
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<String>) -> Result<SmfsProfile, String> {
|
||||
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<SmfsStatus, String> {
|
||||
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::<Value>(&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<String>) -> Result<String, String> {
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<OwnershipMarker> {
|
||||
let path = ownership_marker_path(app, tag).ok()?;
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<OwnershipMarker>(&contents).ok()
|
||||
}
|
||||
|
||||
fn run_smfs<const N: usize>(app: &AppHandle, args: [&str; N]) -> Result<Output, String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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")
|
||||
}
|
||||
|
|
@ -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<WebviewWindow> {
|
|||
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> {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"externalBin": ["binaries/smfs"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue