First-run chooser, node shutdown on switch, file picker, export path fix
- First-run: show Start Fresh / Import chooser only when single auto-created identity with no profile (not on every boot without a display name). - Identity switch: shut down old node's endpoint before starting new one. Fixes lockup after multiple switches (zombie background tasks). - File picker: native Browse buttons on export (folder) and import (ZIP file) via tauri-plugin-dialog. - Export path: resolve relative paths against home dir (was using process cwd). - Lightbox close: only close on overlay/image click, not inner form content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a349d33422
commit
ec731fdb4b
6 changed files with 171 additions and 15 deletions
67
Cargo.lock
generated
67
Cargo.lock
generated
|
|
@ -2780,6 +2780,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -4632,6 +4633,30 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -5598,6 +5623,48 @@ dependencies = [
|
||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-notification"
|
name = "tauri-plugin-notification"
|
||||||
version = "2.3.3"
|
version = "2.3.3"
|
||||||
|
|
|
||||||
|
|
@ -257,11 +257,10 @@ impl IdentityManager {
|
||||||
anyhow::bail!("Identity {} not found", &target_hex[..12]);
|
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 {
|
if let Some(ref node) = self.active_node {
|
||||||
info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity");
|
info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity — shutting down old node");
|
||||||
// Node::shutdown would go here once implemented
|
node.network.shutdown_ref().await;
|
||||||
// For now, dropping the Arc triggers cleanup when last reference is released
|
|
||||||
}
|
}
|
||||||
self.active_node = None;
|
self.active_node = None;
|
||||||
self.active_id = None;
|
self.active_id = None;
|
||||||
|
|
|
||||||
|
|
@ -2335,6 +2335,14 @@ impl Network {
|
||||||
Ok(())
|
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).
|
/// Propagate an engagement diff to all downstream holders of a post (CDN tree).
|
||||||
/// Excludes the sender to avoid loops.
|
/// Excludes the sender to avoid loops.
|
||||||
pub async fn propagate_engagement_diff(
|
pub async fn propagate_engagement_diff(
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ base64 = "0.22"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
open = "5"
|
open = "5"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
|
|
|
||||||
|
|
@ -1643,6 +1643,25 @@ struct AddressInfoDto {
|
||||||
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
|
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<String>, filter_ext: Option<Vec<String>>) -> Result<Option<String>, 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<Option<String>, 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]
|
#[tauri::command]
|
||||||
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
|
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
|
@ -2276,13 +2295,21 @@ async fn export_data(
|
||||||
"everything" => itsgoin_core::export::ExportScope::Everything,
|
"everything" => itsgoin_core::export::ExportScope::Everything,
|
||||||
_ => return Err("Invalid scope".to_string()),
|
_ => 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(
|
let result = itsgoin_core::export::export_data(
|
||||||
&node.data_dir,
|
&node.data_dir,
|
||||||
&node.storage,
|
&node.storage,
|
||||||
&node.blob_store,
|
&node.blob_store,
|
||||||
&node.node_id,
|
&node.node_id,
|
||||||
export_scope,
|
export_scope,
|
||||||
std::path::Path::new(&output_dir),
|
&resolved_dir,
|
||||||
).await.map_err(|e| e.to_string())?;
|
).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let paths: Vec<String> = result.paths.iter()
|
let paths: Vec<String> = result.paths.iter()
|
||||||
|
|
@ -2372,6 +2399,7 @@ pub fn run() {
|
||||||
info!("Starting Tauri app");
|
info!("Starting Tauri app");
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
// Desktop: store data next to the AppImage/executable so each copy
|
// Desktop: store data next to the AppImage/executable so each copy
|
||||||
// gets its own identity. Mobile: use the standard app data dir.
|
// gets its own identity. Mobile: use the standard app data dir.
|
||||||
|
|
@ -2407,15 +2435,17 @@ pub fn run() {
|
||||||
itsgoin_core::types::DeviceProfile::Desktop
|
itsgoin_core::types::DeviceProfile::Desktop
|
||||||
};
|
};
|
||||||
let (node, identity_mgr) = tauri::async_runtime::block_on(async {
|
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() {
|
let n = if let Some(node) = mgr.active_node() {
|
||||||
Arc::clone(node)
|
Arc::clone(node)
|
||||||
} else {
|
} else {
|
||||||
// No identities at all — this is a fresh install after migration
|
// No identity — create a temporary one so the app can start.
|
||||||
// The IdentityManager should have either migrated or the user needs to create one
|
// Frontend detects this via get_node_info and shows the first-run chooser.
|
||||||
anyhow::bail!("No identity available — create one first")
|
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
|
// Start background networking
|
||||||
|
|
@ -2502,6 +2532,8 @@ pub fn run() {
|
||||||
sync_from_peer,
|
sync_from_peer,
|
||||||
get_network_summary,
|
get_network_summary,
|
||||||
get_our_info,
|
get_our_info,
|
||||||
|
pick_file,
|
||||||
|
pick_folder,
|
||||||
get_activity_log,
|
get_activity_log,
|
||||||
trigger_rebalance,
|
trigger_rebalance,
|
||||||
request_referrals,
|
request_referrals,
|
||||||
|
|
|
||||||
|
|
@ -3372,7 +3372,10 @@ $('#export-btn').addEventListener('click', () => {
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:0.75rem">
|
<div style="margin-bottom:0.75rem">
|
||||||
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
|
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
|
||||||
<input id="export-output-dir" type="text" value="Downloads" style="width:100%;margin-top:0.25rem;font-size:0.8rem" />
|
<div style="display:flex;gap:0.25rem;margin-top:0.25rem">
|
||||||
|
<input id="export-output-dir" type="text" value="Downloads" style="flex:1;font-size:0.8rem" />
|
||||||
|
<button class="btn btn-ghost btn-sm" id="export-browse">Browse</button>
|
||||||
|
</div>
|
||||||
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
|
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||||
|
|
@ -3406,6 +3409,12 @@ $('#export-btn').addEventListener('click', () => {
|
||||||
overlay.querySelector('#export-go').disabled = false;
|
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.querySelector('#export-cancel').addEventListener('click', () => overlay.remove());
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
});
|
});
|
||||||
|
|
@ -3418,8 +3427,11 @@ $('#import-btn').addEventListener('click', () => {
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
|
||||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
|
||||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Enter the path to an ItsGoin export ZIP file.</p>
|
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
|
||||||
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="width:100%;margin-bottom:0.75rem;font-size:0.8rem" />
|
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
|
||||||
|
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
|
||||||
|
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
|
||||||
|
</div>
|
||||||
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
|
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
|
||||||
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
|
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
|
||||||
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label>
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label>
|
||||||
|
|
@ -3438,6 +3450,13 @@ $('#import-btn').addEventListener('click', () => {
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(overlay);
|
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 () => {
|
overlay.querySelector('#import-preview').addEventListener('click', async () => {
|
||||||
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
|
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
|
||||||
if (!zipPath) { toast('Enter a ZIP path'); return; }
|
if (!zipPath) { toast('Enter a ZIP path'); return; }
|
||||||
|
|
@ -3646,9 +3665,39 @@ async function init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const info = await loadNodeInfo();
|
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) {
|
if (info && !info.hasProfile) {
|
||||||
|
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 = `
|
||||||
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:2rem;max-width:360px;width:90%;text-align:center">
|
||||||
|
<h2 style="color:#7fdbca;margin:0 0 0.5rem">Welcome to ItsGoin</h2>
|
||||||
|
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.75rem">
|
||||||
|
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
|
||||||
|
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(chooser);
|
||||||
|
chooser.querySelector('#first-run-new').addEventListener('click', () => {
|
||||||
|
chooser.remove();
|
||||||
setupOverlay.classList.remove('hidden');
|
setupOverlay.classList.remove('hidden');
|
||||||
setupName.focus();
|
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)
|
// Pre-load feed + messages from local DB (instant — no network needed)
|
||||||
await loadFeed(true).catch(() => {});
|
await loadFeed(true).catch(() => {});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue