Require folder selection for SMFS onboarding

This commit is contained in:
Sreeram Sreedhar 2026-06-23 22:54:49 -07:00
parent f3889806a4
commit 9ff3bbfac6
6 changed files with 321 additions and 14 deletions

View file

@ -24,6 +24,7 @@ import {
updateDesktopOnboardingStatus,
} from "@/lib/onboarding"
import {
chooseSmfsMountPath,
getDefaultSmfsContainerTag,
getSmfsState,
mountSmfs,
@ -340,6 +341,9 @@ function FilesystemStep({
}) {
const [tag, setTag] = useState("sm_fs_desktop")
const [status, setStatus] = useState<SmfsStatus | null>(null)
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(
null,
)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -353,7 +357,16 @@ function FilesystemStep({
getSmfsState(),
])
setTag(defaultTag)
setStatus(statuses[0] ?? null)
const nextStatus = statuses[0] ?? null
setStatus(nextStatus)
if (
nextStatus?.mountPath &&
(nextStatus.mountPathConfigured ||
nextStatus.state === "mounted" ||
nextStatus.state === "external")
) {
setSelectedMountPath(nextStatus.mountPath)
}
} catch (err) {
setError(formatUnknownError(err, "Could not load filesystem status"))
} finally {
@ -369,7 +382,12 @@ function FilesystemStep({
setError(null)
setBusy(true)
try {
setStatus(await mountSmfs(tag))
if (!selectedMountPath) {
throw new Error("Choose a folder before mounting SMFS")
}
const nextStatus = await mountSmfs(tag, selectedMountPath)
setStatus(nextStatus)
setSelectedMountPath(nextStatus.mountPath)
} catch (err) {
setError(formatUnknownError(err, "Could not mount memory folder"))
} finally {
@ -377,7 +395,25 @@ function FilesystemStep({
}
}
async function chooseFolder() {
setError(null)
setBusy(true)
try {
const path = await chooseSmfsMountPath()
if (path) {
setSelectedMountPath(path)
}
} catch (err) {
setError(formatUnknownError(err, "Could not choose mount folder"))
} finally {
setBusy(false)
}
}
const mounted = status?.state === "mounted" || status?.state === "external"
const mountPath =
selectedMountPath ?? (status?.mountPathConfigured ? status.mountPath : null)
const canMount = Boolean(mountPath) && !loading && !busy
return (
<section className="mx-auto w-full max-w-4xl space-y-6">
@ -410,8 +446,11 @@ function FilesystemStep({
<div className="mt-auto flex items-center justify-between gap-4 pt-8">
<FilesystemStatus mounted={mounted} loading={loading} />
<p className="truncate font-mono font-medium text-[#737373] text-[13px]">
{status?.tag ?? tag}
<p
className="truncate font-mono font-medium text-[#737373] text-[13px]"
title={mountPath ?? undefined}
>
{mountPath ? compactPath(mountPath) : "No folder selected"}
</p>
</div>
{error ? <p className="mt-4 text-red-300 text-sm">{error}</p> : null}
@ -438,16 +477,25 @@ function FilesystemStep({
Mount folder
</p>
<p className="mt-2 line-clamp-2 font-medium text-[#9A9AA2] text-[14px] leading-[1.45]">
Create the local memory folder for this Mac
Choose or create the local folder SMFS should mount into
</p>
</div>
<Button
type="button"
variant="insideOut"
onClick={mounted ? onContinue : mount}
disabled={loading || busy}
onClick={chooseFolder}
disabled={loading || busy || mounted}
className="mt-auto h-10 w-full rounded-full font-medium text-[#FAFAFA] text-[15px]"
>
{mountPath ? "Change folder" : "Choose folder"}
</Button>
<Button
type="button"
variant="insideOut"
onClick={mounted ? onContinue : mount}
disabled={!mounted && !canMount}
className="mt-3 h-10 w-full rounded-full font-medium text-[#FAFAFA] text-[15px]"
>
{busy || loading ? (
<Loader2 className="size-4 animate-spin" />
@ -842,6 +890,16 @@ function NovaBackground() {
)
}
function compactPath(path: string) {
if (path.startsWith("/Users/")) {
const parts = path.split("/")
if (parts.length > 3) {
return `~/${parts.slice(3).join("/")}`
}
}
return path
}
function formatUnknownError(error: unknown, fallback: string) {
return error instanceof Error
? error.message

View file

@ -13,6 +13,7 @@ export type SmfsStatus = {
| "missing-binary"
| "error"
mountPath: string
mountPathConfigured: boolean
ownedByApp: boolean
profileAvailable: boolean
lastSync?: string | null
@ -38,8 +39,12 @@ export function getSmfsState() {
return invoke<SmfsStatus[]>("smfs_state")
}
export function mountSmfs(tag?: string) {
return invoke<SmfsStatus>("smfs_mount", { tag })
export function chooseSmfsMountPath() {
return invoke<string | null>("smfs_choose_mount_path")
}
export function mountSmfs(tag?: string, mountPath?: string) {
return invoke<SmfsStatus>("smfs_mount", { tag, mountPath })
}
export function unmountSmfs(tag?: string) {

View file

@ -47,6 +47,28 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -85,6 +107,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@ -114,6 +147,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@ -777,6 +821,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@ -824,6 +877,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@ -2572,6 +2631,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -2924,6 +2989,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -3083,6 +3172,12 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -3445,6 +3540,7 @@ version = "0.1.0"
dependencies = [
"keyring",
"reqwest 0.12.28",
"rfd",
"serde",
"serde_json",
"tauri",
@ -4361,6 +4457,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@ -4531,6 +4633,66 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.102"
@ -5413,6 +5575,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",

View file

@ -26,3 +26,4 @@ tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
rfd = "0.15"

View file

@ -136,8 +136,17 @@ fn smfs_state(app: tauri::AppHandle) -> Result<Vec<smfs::SmfsStatus>, String> {
}
#[tauri::command]
fn smfs_mount(app: tauri::AppHandle, tag: Option<String>) -> Result<smfs::SmfsStatus, String> {
smfs::mount(&app, tag)
fn smfs_choose_mount_path() -> Result<Option<String>, String> {
smfs::choose_mount_path()
}
#[tauri::command]
fn smfs_mount(
app: tauri::AppHandle,
tag: Option<String>,
mount_path: Option<String>,
) -> Result<smfs::SmfsStatus, String> {
smfs::mount(&app, tag, mount_path)
}
#[tauri::command]
@ -232,6 +241,7 @@ pub fn run() {
spotlight_get_shortcut,
spotlight_set_shortcut,
smfs_state,
smfs_choose_mount_path,
smfs_mount,
smfs_unmount,
smfs_sync,

View file

@ -24,6 +24,7 @@ pub struct SmfsStatus {
tag: String,
state: SmfsState,
mount_path: String,
mount_path_configured: bool,
owned_by_app: bool,
profile_available: bool,
last_sync: Option<String>,
@ -96,11 +97,32 @@ 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> {
pub fn choose_mount_path() -> Result<Option<String>, String> {
Ok(rfd::FileDialog::new()
.set_title("Choose SMFS mount folder")
.pick_folder()
.map(|path| path_to_string(&path)))
}
pub fn mount(
app: &AppHandle,
tag: Option<String>,
mount_path: 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)?;
let (configured_path, _) = configured_mount_path(app, &tag)?;
let mount_path = match mount_path {
Some(path) => normalize_mount_path(path)?,
None => configured_path,
};
fs::create_dir_all(&mount_path).map_err(|error| error.to_string())?;
if !mount_path.is_dir() {
return Err(format!(
"{} is not a directory",
path_to_string(&mount_path)
));
}
let status = status_for_tag(app, &tag)?;
match status.state {
@ -242,7 +264,7 @@ pub fn profile(app: &AppHandle, tag: Option<String>) -> Result<SmfsProfile, Stri
fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
validate_tag(tag)?;
let expected_mount_path = expected_mount_path(app, tag)?;
let (expected_mount_path, mount_path_configured) = configured_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"]) {
@ -252,6 +274,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
tag: tag.to_string(),
state: SmfsState::MissingBinary,
mount_path: path_to_string(&expected_mount_path),
mount_path_configured,
owned_by_app: false,
profile_available: false,
last_sync: None,
@ -264,6 +287,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
tag: tag.to_string(),
state: SmfsState::Error,
mount_path: path_to_string(&expected_mount_path),
mount_path_configured,
owned_by_app: false,
profile_available: false,
last_sync: None,
@ -291,6 +315,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
.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 mount_path_configured = owned_by_app || mount_path_configured;
let state = if is_degraded(&parsed) {
SmfsState::Degraded
} else if owned_by_app {
@ -303,6 +328,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
tag: tag.to_string(),
state,
mount_path: path_to_string(&active_mount_path),
mount_path_configured,
owned_by_app,
profile_available,
last_sync: first_string(&parsed, &[&["lastSync"], &["last_sync"]]),
@ -320,6 +346,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
tag: tag.to_string(),
state: SmfsState::Unmounted,
mount_path: path_to_string(&expected_mount_path),
mount_path_configured,
owned_by_app: false,
profile_available: false,
last_sync: None,
@ -332,6 +359,7 @@ fn status_for_tag(app: &AppHandle, tag: &str) -> Result<SmfsStatus, String> {
tag: tag.to_string(),
state: SmfsState::Error,
mount_path: path_to_string(&expected_mount_path),
mount_path_configured,
owned_by_app: false,
profile_available: false,
last_sync: None,
@ -367,7 +395,49 @@ fn validate_tag(tag: &str) -> Result<(), String> {
}
}
fn normalize_mount_path(path: String) -> Result<PathBuf, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Choose a folder before mounting SMFS".to_string());
}
let path = shellexpand_path(trimmed);
if path.exists() && !path.is_dir() {
return Err(format!("{} is not a directory", path_to_string(&path)));
}
Ok(path)
}
fn shellexpand_path(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home);
}
}
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}
fn configured_mount_path(app: &AppHandle, tag: &str) -> Result<(PathBuf, bool), String> {
if let Some(marker) = read_ownership_marker(app, tag) {
return Ok((PathBuf::from(marker.mount_path), true));
}
Ok((default_mount_path(app, tag)?, false))
}
fn expected_mount_path(app: &AppHandle, tag: &str) -> Result<PathBuf, String> {
configured_mount_path(app, tag).map(|(path, _)| path)
}
fn default_mount_path(app: &AppHandle, tag: &str) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()