diff --git a/Cargo.lock b/Cargo.lock index 3b4fb10..665be27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,6 +2780,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-notification", "tokio", "tracing", @@ -4632,6 +4633,30 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -5598,6 +5623,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" diff --git a/crates/core/src/identity.rs b/crates/core/src/identity.rs index 399bff3..155b08c 100644 --- a/crates/core/src/identity.rs +++ b/crates/core/src/identity.rs @@ -257,11 +257,10 @@ impl IdentityManager { anyhow::bail!("Identity {} not found", &target_hex[..12]); } - // Shutdown current node if running + // Shutdown current node before switching if let Some(ref node) = self.active_node { - info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity"); - // Node::shutdown would go here once implemented - // For now, dropping the Arc triggers cleanup when last reference is released + info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity — shutting down old node"); + node.network.shutdown_ref().await; } self.active_node = None; self.active_id = None; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 0566035..524ed65 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2335,6 +2335,14 @@ impl Network { Ok(()) } + /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. + pub async fn shutdown_ref(&self) { + if let Some(ref mapping) = self.upnp_mapping { + crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; + } + self.endpoint.close().await; + } + /// Propagate an engagement diff to all downstream holders of a post (CDN tree). /// Excludes the sender to avoid loops. pub async fn propagate_engagement_diff( diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index bdd6a76..974d870 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -24,4 +24,5 @@ base64 = "0.22" dirs = "5" open = "5" tauri-plugin-notification = "2" +tauri-plugin-dialog = "2" notify-rust = "4" diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 5ddc679..5431ad9 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1643,6 +1643,25 @@ struct AddressInfoDto { status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server" } +#[tauri::command] +async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option, filter_ext: Option>) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let mut builder = app.dialog().file().set_title(&title); + if let (Some(name), Some(exts)) = (filter_name, filter_ext) { + let ext_refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect(); + builder = builder.add_filter(&name, &ext_refs); + } + let path = builder.blocking_pick_file(); + Ok(path.map(|p| p.to_string())) +} + +#[tauri::command] +async fn pick_folder(app: tauri::AppHandle, title: String) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let path = app.dialog().file().set_title(&title).blocking_pick_folder(); + Ok(path.map(|p| p.to_string())) +} + #[tauri::command] async fn get_our_info(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; @@ -2276,13 +2295,21 @@ async fn export_data( "everything" => itsgoin_core::export::ExportScope::Everything, _ => return Err("Invalid scope".to_string()), }; + // Resolve relative paths against user's home directory + let resolved_dir = if std::path::Path::new(&output_dir).is_relative() { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(&output_dir) + } else { + std::path::PathBuf::from(&output_dir) + }; let result = itsgoin_core::export::export_data( &node.data_dir, &node.storage, &node.blob_store, &node.node_id, export_scope, - std::path::Path::new(&output_dir), + &resolved_dir, ).await.map_err(|e| e.to_string())?; let paths: Vec = result.paths.iter() @@ -2372,6 +2399,7 @@ pub fn run() { info!("Starting Tauri app"); tauri::Builder::default() .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_dialog::init()) .setup(move |app| { // Desktop: store data next to the AppImage/executable so each copy // gets its own identity. Mobile: use the standard app data dir. @@ -2407,15 +2435,17 @@ pub fn run() { itsgoin_core::types::DeviceProfile::Desktop }; let (node, identity_mgr) = tauri::async_runtime::block_on(async { - let mgr = IdentityManager::open(&data_dir, None, profile).await?; + let mut mgr = IdentityManager::open(&data_dir, None, profile).await?; - // If no identity exists yet, create one let n = if let Some(node) = mgr.active_node() { Arc::clone(node) } else { - // No identities at all — this is a fresh install after migration - // The IdentityManager should have either migrated or the user needs to create one - anyhow::bail!("No identity available — create one first") + // No identity — create a temporary one so the app can start. + // Frontend detects this via get_node_info and shows the first-run chooser. + info!("No identity found — creating initial identity for first-run setup"); + let node_id = mgr.create_identity("My Identity")?; + mgr.switch_identity(&node_id).await?; + Arc::clone(mgr.active_node().expect("just created")) }; // Start background networking @@ -2502,6 +2532,8 @@ pub fn run() { sync_from_peer, get_network_summary, get_our_info, + pick_file, + pick_folder, get_activity_log, trigger_rebalance, request_referrals, diff --git a/frontend/app.js b/frontend/app.js index 92b150c..1c3ee18 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3372,7 +3372,10 @@ $('#export-btn').addEventListener('click', () => {
- +
+ + +

Relative to your home directory, or absolute path

@@ -3406,6 +3409,12 @@ $('#export-btn').addEventListener('click', () => { overlay.querySelector('#export-go').disabled = false; } }); + overlay.querySelector('#export-browse').addEventListener('click', async () => { + try { + const path = await invoke('pick_folder', { title: 'Choose export folder' }); + if (path) overlay.querySelector('#export-output-dir').value = path; + } catch (_) {} + }); overlay.querySelector('#export-cancel').addEventListener('click', () => overlay.remove()); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); }); @@ -3418,8 +3427,11 @@ $('#import-btn').addEventListener('click', () => { overlay.innerHTML = `

Import Data

-

Enter the path to an ItsGoin export ZIP file.

- +

Select an ItsGoin export ZIP file.

+
+ + +
`; document.body.appendChild(overlay); + overlay.querySelector('#import-browse').addEventListener('click', async () => { + try { + const path = await invoke('pick_file', { title: 'Select export ZIP', filterName: 'ZIP files', filterExt: ['zip'] }); + if (path) overlay.querySelector('#import-zip-path').value = path; + } catch (_) {} + }); + overlay.querySelector('#import-preview').addEventListener('click', async () => { const zipPath = overlay.querySelector('#import-zip-path').value.trim(); if (!zipPath) { toast('Enter a ZIP path'); return; } @@ -3646,9 +3665,39 @@ async function init() { } } const info = await loadNodeInfo(); + // Show first-run chooser only if no profile AND only one identity (auto-created) + let isFirstRun = false; if (info && !info.hasProfile) { - setupOverlay.classList.remove('hidden'); - setupName.focus(); + try { + const ids = await invoke('list_identities'); + isFirstRun = ids.length <= 1; + } catch (_) {} + } + if (isFirstRun) { + // First-run chooser: start fresh or import + const chooser = document.createElement('div'); + chooser.className = 'image-lightbox'; + chooser.style.cursor = 'default'; + chooser.innerHTML = ` +
+

Welcome to ItsGoin

+

How would you like to get started?

+
+ + +
+
`; + document.body.appendChild(chooser); + chooser.querySelector('#first-run-new').addEventListener('click', () => { + chooser.remove(); + setupOverlay.classList.remove('hidden'); + setupName.focus(); + }); + chooser.querySelector('#first-run-import').addEventListener('click', () => { + chooser.remove(); + // Open the import wizard + document.getElementById('import-btn')?.click(); + }); } // Pre-load feed + messages from local DB (instant — no network needed) await loadFeed(true).catch(() => {});