Skip to content

Commit 2dac663

Browse files
committed
feat(gui): add mount subcommand to browse snapshots via WebDAV
Surface vykar mount in the GUI with buttons on Overview (mount whole repo) and Snapshots (mount a single snapshot). On click the worker binds 127.0.0.1:0, emits the bound URL, opens the default browser, and shows a footer banner with a clickable link and a Stop button. Core changes: run_with_progress takes an Option<Arc<Notify>> for programmatic shutdown, and Serving is emitted from listener.local_addr() so ephemeral ports resolve.
1 parent 4610137 commit 2dac663

13 files changed

Lines changed: 373 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vykar-cli/src/cmd/mount.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub(crate) fn run_mount(
3838
cache_size,
3939
source_filter,
4040
Some(&mut on_progress),
41+
None,
4142
)
4243
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })
4344
})?;

crates/vykar-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ percent-encoding = { version = "2", optional = true }
7676
[features]
7777
default = []
7878
backend-sftp = ["vykar-storage/backend-sftp"]
79-
mount = ["dav-server", "tokio/rt-multi-thread", "tokio/macros", "tokio/signal", "tokio/net", "hyper", "hyper-util", "lru", "bytes", "futures-util", "percent-encoding"]
79+
mount = ["dav-server", "tokio/rt-multi-thread", "tokio/macros", "tokio/signal", "tokio/net", "tokio/sync", "hyper", "hyper-util", "lru", "bytes", "futures-util", "percent-encoding"]
8080

8181
[dev-dependencies]
8282
proptest = { workspace = true }

crates/vykar-core/src/commands/mount.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,9 +794,11 @@ pub fn run(
794794
cache_size,
795795
source_filter,
796796
None,
797+
None,
797798
)
798799
}
799800

801+
#[allow(clippy::too_many_arguments)]
800802
pub fn run_with_progress(
801803
config: &VykarConfig,
802804
passphrase: Option<&str>,
@@ -805,6 +807,7 @@ pub fn run_with_progress(
805807
cache_size: usize,
806808
source_filter: &[String],
807809
mut progress: Option<&mut dyn FnMut(MountProgressEvent)>,
810+
shutdown: Option<Arc<tokio::sync::Notify>>,
808811
) -> Result<()> {
809812
let mut repo = open_repo(
810813
config,
@@ -877,7 +880,17 @@ pub fn run_with_progress(
877880
.map_err(|e| VykarError::Other(format!("failed to create tokio runtime: {e}")))?;
878881

879882
let is_multi_snapshot = snapshot_name.is_none();
880-
rt.block_on(async { serve(handler, tree, address, is_multi_snapshot, &mut progress).await })
883+
rt.block_on(async {
884+
serve(
885+
handler,
886+
tree,
887+
address,
888+
is_multi_snapshot,
889+
&mut progress,
890+
shutdown,
891+
)
892+
.await
893+
})
881894
}
882895

883896
async fn serve(
@@ -886,6 +899,7 @@ async fn serve(
886899
address: &str,
887900
is_multi_snapshot: bool,
888901
progress: &mut Option<&mut dyn FnMut(MountProgressEvent)>,
902+
shutdown: Option<Arc<tokio::sync::Notify>>,
889903
) -> Result<()> {
890904
let addr: std::net::SocketAddr = address
891905
.parse()
@@ -894,10 +908,13 @@ async fn serve(
894908
let listener = TcpListener::bind(addr)
895909
.await
896910
.map_err(|e| VykarError::Other(format!("failed to bind to {addr}: {e}")))?;
911+
let bound = listener
912+
.local_addr()
913+
.map_err(|e| VykarError::Other(format!("local_addr failed: {e}")))?;
897914

898915
if let Some(cb) = progress.as_deref_mut() {
899916
cb(MountProgressEvent::Serving {
900-
address: format!("{addr}"),
917+
address: format!("{bound}"),
901918
});
902919
}
903920

@@ -959,6 +976,17 @@ async fn serve(
959976
}
960977
break;
961978
}
979+
_ = async {
980+
match shutdown.as_ref() {
981+
Some(n) => n.notified().await,
982+
None => std::future::pending::<()>().await,
983+
}
984+
} => {
985+
if let Some(cb) = progress.as_deref_mut() {
986+
cb(MountProgressEvent::ShuttingDown);
987+
}
988+
break;
989+
}
962990
}
963991
}
964992

crates/vykar-gui/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ path = "src/main.rs"
1414
workspace = true
1515

1616
[dependencies]
17-
vykar-core = { workspace = true, features = ["backend-sftp"] }
17+
vykar-core = { workspace = true, features = ["backend-sftp", "mount"] }
1818
vykar-common.workspace = true
1919
vykar-types.workspace = true
20+
tokio = { workspace = true, features = ["sync"] }
21+
opener = "0.8"
2022
slint = { version = "1.15", default-features = false, features = ["std", "compat-1-2", "backend-winit", "renderer-skia", "accessibility"] }
2123
tray-icon = "0.22"
2224
crossbeam-channel.workspace = true

crates/vykar-gui/src/controllers/main_window.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,67 @@ pub(crate) fn wire_callbacks(
335335
});
336336
}
337337

338+
{
339+
let tx = app_tx.clone();
340+
let ui_weak = ui.as_weak();
341+
ui.on_mount_selected_snapshot_clicked(move |row| {
342+
let Some(ui) = ui_weak.upgrade() else {
343+
return;
344+
};
345+
let r = row as usize;
346+
let ids = ui.global::<AppData>().get_snapshot_ids();
347+
let rnames = ui.global::<AppData>().get_snapshot_repo_names();
348+
let (snap_name, rname) = match (ids.row_data(r), rnames.row_data(r)) {
349+
(Some(id), Some(rn)) => (id.to_string(), rn.to_string()),
350+
_ => return,
351+
};
352+
// Optimistically mark active so the Mount buttons disable immediately.
353+
// MountStarted will set the real URL; MountFailed will clear this.
354+
ui.set_is_mount_active(true);
355+
let _ = tx.send(AppCommand::StartMount {
356+
repo_name: rname,
357+
snapshot_name: Some(snap_name),
358+
});
359+
});
360+
}
361+
362+
{
363+
let tx = app_tx.clone();
364+
let ui_weak = ui.as_weak();
365+
ui.on_mount_repo_clicked(move |idx| {
366+
let Some(ui) = ui_weak.upgrade() else {
367+
return;
368+
};
369+
let labels = ui.global::<AppData>().get_repo_labels();
370+
if let Some(name) = labels.row_data(idx as usize) {
371+
ui.set_is_mount_active(true);
372+
let _ = tx.send(AppCommand::StartMount {
373+
repo_name: name.to_string(),
374+
snapshot_name: None,
375+
});
376+
}
377+
});
378+
}
379+
380+
{
381+
let tx = app_tx.clone();
382+
ui.on_stop_mount_clicked(move || {
383+
let _ = tx.send(AppCommand::StopMount);
384+
});
385+
}
386+
387+
{
388+
let ui_weak = ui.as_weak();
389+
ui.on_open_mount_url_clicked(move || {
390+
if let Some(ui) = ui_weak.upgrade() {
391+
let url = ui.get_mount_url().to_string();
392+
if !url.is_empty() {
393+
let _ = opener::open_browser(&url);
394+
}
395+
}
396+
});
397+
}
398+
338399
{
339400
let tx = app_tx;
340401
let ui_weak = ui.as_weak();

crates/vykar-gui/src/event_consumer.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ pub(crate) fn spawn(
389389
*last = Some(s);
390390
}
391391
}
392+
// Best-effort: stop any active mount so the listener is released cleanly.
393+
let _ = app_tx.send(AppCommand::StopMount);
392394
let _ = slint::quit_event_loop();
393395
}
394396
UiEvent::ShowWindow => {
@@ -410,6 +412,34 @@ pub(crate) fn spawn(
410412
}
411413
}
412414
}
415+
UiEvent::MountStarted { url } => {
416+
ui.set_is_mount_active(true);
417+
ui.set_mount_url(url.clone().into());
418+
if opener::open_browser(&url).is_err() {
419+
let now = chrono::Local::now();
420+
append_log_row(
421+
&ui,
422+
&now.format("%b %d").to_string(),
423+
&now.format("%H:%M:%S").to_string(),
424+
&format!("Mount running at {url} — open it manually"),
425+
);
426+
}
427+
}
428+
UiEvent::MountStopped => {
429+
ui.set_is_mount_active(false);
430+
ui.set_mount_url("".into());
431+
}
432+
UiEvent::MountFailed { message } => {
433+
ui.set_is_mount_active(false);
434+
ui.set_mount_url("".into());
435+
let now = chrono::Local::now();
436+
append_log_row(
437+
&ui,
438+
&now.format("%b %d").to_string(),
439+
&now.format("%H:%M:%S").to_string(),
440+
&format!("Mount failed: {message}"),
441+
);
442+
}
413443
UiEvent::TriggerSnapshotRefresh => {
414444
let idx = ui.get_current_repo_index();
415445
if idx >= 0 {

crates/vykar-gui/src/messages.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ pub(crate) enum AppCommand {
5555
SaveAndApplyConfig {
5656
yaml_text: String,
5757
},
58+
StartMount {
59+
repo_name: String,
60+
snapshot_name: Option<String>,
61+
},
62+
StopMount,
5863
}
5964

6065
// ── Data transfer structs ──
@@ -154,4 +159,11 @@ pub(crate) enum UiEvent {
154159
Quit,
155160
ShowWindow,
156161
TriggerSnapshotRefresh,
162+
MountStarted {
163+
url: String,
164+
},
165+
MountStopped,
166+
MountFailed {
167+
message: String,
168+
},
157169
}

crates/vykar-gui/src/worker/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::view_models::send_structured_data;
1515
mod actions;
1616
mod backup;
1717
mod config_cmds;
18+
mod mount;
1819
mod repo_info;
1920
mod shared;
2021

@@ -33,6 +34,8 @@ pub(super) struct WorkerContext {
3334

3435
pub(super) scheduler_lock_held: bool,
3536
pub(super) schedule_paused: bool,
37+
38+
pub(super) mount: Option<mount::MountHandle>,
3639
}
3740

3841
fn startup(ctx: &mut WorkerContext) {
@@ -108,6 +111,7 @@ pub(crate) fn run_worker(
108111
cancel_requested,
109112
scheduler_lock_held,
110113
schedule_paused: !scheduler_lock_held,
114+
mount: None,
111115
};
112116

113117
startup(&mut ctx);
@@ -152,6 +156,14 @@ pub(crate) fn run_worker(
152156
AppCommand::SaveAndApplyConfig { yaml_text } => {
153157
config_cmds::handle_save_and_apply_config(&mut ctx, yaml_text)
154158
}
159+
AppCommand::StartMount {
160+
repo_name,
161+
snapshot_name,
162+
} => mount::handle_start_mount(&mut ctx, repo_name, snapshot_name),
163+
AppCommand::StopMount => mount::handle_stop_mount(&mut ctx),
155164
}
156165
}
166+
167+
// On worker shutdown, stop any active mount so we don't leak a listener.
168+
mount::handle_stop_mount(&mut ctx);
157169
}

0 commit comments

Comments
 (0)