From d5fab2e9ca2afc0f335aad1211382c33b191795e Mon Sep 17 00:00:00 2001 From: nailao Date: Sat, 20 Jun 2026 09:39:03 +0800 Subject: [PATCH 1/2] feat: add encrypted config sync via WebDAV and S3 - encrypt SSH configuration with Argon2id and XChaCha20-Poly1305 - support WebDAV and S3-compatible storage backends - add ETag-based conflict protection - add sync settings and localized UI - keep remote credentials in memory only --- Cargo.lock | 122 +++++++++ Cargo.toml | 8 + locales/en.yml | 21 ++ locales/zh-CN.yml | 21 ++ src/app/config_sync.rs | 129 +++++++++ src/app/dialogs.rs | 69 +++++ src/app/mod.rs | 129 ++++++++- src/main.rs | 1 + src/session/config.rs | 112 +++++++- src/sync/mod.rs | 577 +++++++++++++++++++++++++++++++++++++++++ src/terminal/mod.rs | 1 + 11 files changed, 1184 insertions(+), 6 deletions(-) create mode 100644 src/app/config_sync.rs create mode 100644 src/sync/mod.rs diff --git a/Cargo.lock b/Cargo.lock index bb25cae..4ab0080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,18 @@ dependencies = [ "syn", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -307,7 +319,10 @@ version = "0.4.6" dependencies = [ "alacritty_terminal", "anyhow", + "argon2", "async-trait", + "base64", + "chacha20poly1305", "chrono", "directories", "flate2", @@ -316,11 +331,15 @@ dependencies = [ "gpui-component", "gpui-component-assets", "gpui_platform", + "hex", + "hmac", "image", "menu", "notify 6.1.1", "open", "portable-pty", + "rand 0.8.6", + "reqwest", "rfd", "russh", "russh-keys", @@ -328,6 +347,7 @@ dependencies = [ "rust-i18n 3.1.5", "serde", "serde_json", + "sha2", "ssh-key", "sys-locale", "sysinfo 0.33.1", @@ -842,6 +862,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -1091,6 +1120,19 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -3336,6 +3378,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -3344,13 +3387,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -5014,6 +5060,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -5901,6 +5958,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "resvg" version = "0.45.1" @@ -7854,6 +7949,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.12.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -8615,6 +8728,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index c01dd83..f589076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,14 @@ notify = "6.1.1" open = "5.1" time = { version = "0.3.45", features = ["formatting", "local-offset"] } tracing-appender = "0.2.5" +argon2 = "0.5" +base64 = "0.22" +chacha20poly1305 = "0.10" +hmac = "0.12" +hex = "0.4" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha2 = "0.10" [profile.release] opt-level = 3 diff --git a/locales/en.yml b/locales/en.yml index 5bffb87..3fd8331 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -158,3 +158,24 @@ title_bar_style: "Title Bar Style" title_bar_native: "Native" title_bar_integrated: "Integrated" restart_hint: " (Restart required)" +settings_sync: "Configuration Sync" +sync_endpoint: "WebDAV Endpoint" +sync_username: "WebDAV Username" +sync_webdav_password: "WebDAV Password" +sync_encryption_password: "Encryption Password (8+ characters)" +sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions." +sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)" +sync_s3_region: "S3 Region" +sync_s3_bucket: "S3 Bucket" +sync_s3_object_key: "Object Key" +sync_s3_access_key: "Access Key ID" +sync_s3_secret_key: "Secret Access Key" +sync_s3_session_token: "Session Token (optional)" +sync_download: "Download Remote" +sync_upload: "Upload Remote" +sync_not_run: "Not synchronized yet" +sync_uploading: "Encrypting and uploading configuration..." +sync_downloading: "Downloading and decrypting configuration..." +sync_upload_complete: "Configuration uploaded" +sync_download_complete: "Configuration downloaded" +sync_failed: "Synchronization failed" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e151ea8..d2137a2 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -160,3 +160,24 @@ title_bar_style: "标题栏样式" title_bar_native: "原生" title_bar_integrated: "融合" restart_hint: " (修改后重启生效)" +settings_sync: "配置同步" +sync_endpoint: "WebDAV 地址" +sync_username: "WebDAV 用户名" +sync_webdav_password: "WebDAV 密码" +sync_encryption_password: "配置加密口令(至少 8 位)" +sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。" +sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)" +sync_s3_region: "S3 Region" +sync_s3_bucket: "S3 Bucket" +sync_s3_object_key: "对象路径" +sync_s3_access_key: "Access Key ID" +sync_s3_secret_key: "Secret Access Key" +sync_s3_session_token: "Session Token(可选)" +sync_download: "从远程下载" +sync_upload: "上传到远程" +sync_not_run: "尚未同步" +sync_uploading: "正在加密并上传配置..." +sync_downloading: "正在下载并解密配置..." +sync_upload_complete: "配置上传完成" +sync_download_complete: "配置下载完成" +sync_failed: "同步失败" diff --git a/src/app/config_sync.rs b/src/app/config_sync.rs new file mode 100644 index 0000000..3b94ca9 --- /dev/null +++ b/src/app/config_sync.rs @@ -0,0 +1,129 @@ +use gpui::{Context, Entity, SharedString}; +use gpui_component::input::InputState; +use rust_i18n::t; + +use crate::{ + Ashell, + sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult}, + terminal::BackendEvent, +}; + +impl Ashell { + fn sync_input_value(input: &Entity, cx: &Context) -> String { + input.read(cx).value().trim().to_string() + } + + fn sync_credentials(&self, cx: &Context) -> SyncCredentials { + let backend = if self.config.sync_backend() == "s3" { + SyncBackendCredentials::S3 { + endpoint: Self::sync_input_value(&self.sync_s3_endpoint_input, cx), + region: Self::sync_input_value(&self.sync_s3_region_input, cx), + bucket: Self::sync_input_value(&self.sync_s3_bucket_input, cx), + object_key: Self::sync_input_value(&self.sync_s3_object_key_input, cx), + access_key: Self::sync_input_value(&self.sync_s3_access_key_input, cx), + secret_key: self.sync_s3_secret_key_input.read(cx).value().to_string(), + session_token: self + .sync_s3_session_token_input + .read(cx) + .value() + .to_string(), + } + } else { + SyncBackendCredentials::WebDav { + endpoint: Self::sync_input_value(&self.sync_endpoint_input, cx), + username: Self::sync_input_value(&self.sync_username_input, cx), + password: self.sync_webdav_password_input.read(cx).value().to_string(), + } + }; + SyncCredentials { + backend, + encryption_password: self + .sync_encryption_password_input + .read(cx) + .value() + .to_string(), + } + } + + fn begin_sync( + &mut self, + status: SharedString, + cx: &mut Context, + ) -> Option { + if self.sync_in_progress { + return None; + } + let credentials = self.sync_credentials(cx); + match &credentials.backend { + SyncBackendCredentials::WebDav { + endpoint, username, .. + } => { + self.config + .set_sync_connection(endpoint.clone(), username.clone()); + } + SyncBackendCredentials::S3 { + endpoint, + region, + bucket, + object_key, + .. + } => { + self.config.set_sync_s3_connection( + endpoint.clone(), + region.clone(), + bucket.clone(), + object_key.clone(), + ); + } + } + if let Err(err) = self.config.save() { + self.sync_status = format!("{}: {err:#}", t!("sync_failed")).into(); + cx.notify(); + return None; + } + self.sync_in_progress = true; + self.sync_status = status; + cx.notify(); + Some(credentials) + } + + pub(crate) fn set_sync_backend(&mut self, backend: &str, cx: &mut Context) { + self.config.set_sync_backend(backend); + let _ = self.config.save(); + self.sync_status = t!("sync_not_run").into(); + cx.notify(); + } + + pub(crate) fn upload_sync_config(&mut self, cx: &mut Context) { + let Some(credentials) = self.begin_sync(t!("sync_uploading").into(), cx) else { + return; + }; + let payload = SyncPayload::new( + self.config.sync_device_id().to_string(), + self.config.sessions().to_vec(), + ); + let expected_etag = self.config.sync_etag().map(str::to_string); + let events = self.events_tx.clone(); + self.runtime.spawn(async move { + let result = match sync::upload(credentials, payload, expected_etag).await { + Ok(etag) => SyncResult::Uploaded { etag }, + Err(err) => SyncResult::Failed(format!("{err:#}")), + }; + let _ = events.send(BackendEvent::SyncFinished(result)); + }); + } + + pub(crate) fn download_sync_config(&mut self, cx: &mut Context) { + let Some(credentials) = self.begin_sync(t!("sync_downloading").into(), cx) else { + return; + }; + let events = self.events_tx.clone(); + self.runtime.spawn(async move { + let result = match sync::download(credentials).await { + Ok((payload, etag)) => SyncResult::Downloaded { payload, etag }, + Err(err) => SyncResult::Failed(format!("{err:#}")), + }; + let _ = events.send(BackendEvent::SyncFinished(result)); + }); + } +} diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 0ad8ebf..50f44de 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1094,6 +1094,17 @@ impl Ashell { use gpui::IntoElement; let version = env!("CARGO_PKG_VERSION"); let view_clone_for_general = view.clone(); + let sync_endpoint_input = view.read(cx).sync_endpoint_input.clone(); + let sync_username_input = view.read(cx).sync_username_input.clone(); + let sync_webdav_password_input = view.read(cx).sync_webdav_password_input.clone(); + let sync_s3_endpoint_input = view.read(cx).sync_s3_endpoint_input.clone(); + let sync_s3_region_input = view.read(cx).sync_s3_region_input.clone(); + let sync_s3_bucket_input = view.read(cx).sync_s3_bucket_input.clone(); + let sync_s3_object_key_input = view.read(cx).sync_s3_object_key_input.clone(); + let sync_s3_access_key_input = view.read(cx).sync_s3_access_key_input.clone(); + let sync_s3_secret_key_input = view.read(cx).sync_s3_secret_key_input.clone(); + let sync_s3_session_token_input = view.read(cx).sync_s3_session_token_input.clone(); + let sync_encryption_password_input = view.read(cx).sync_encryption_password_input.clone(); let focus_handle = view.read(cx).focus_handle.clone(); @@ -1648,6 +1659,64 @@ impl Ashell { ) ) ) + .page( + SettingPage::new(t!("settings_sync").to_string()) + .icon(IconName::Globe) + .group( + SettingGroup::new() + .title(t!("settings_sync").to_string()) + .item(SettingItem::render({ + let view = view.clone(); + let endpoint = sync_endpoint_input.clone(); + let username = sync_username_input.clone(); + let webdav_password = sync_webdav_password_input.clone(); + let s3_endpoint = sync_s3_endpoint_input.clone(); + let s3_region = sync_s3_region_input.clone(); + let s3_bucket = sync_s3_bucket_input.clone(); + let s3_object_key = sync_s3_object_key_input.clone(); + let s3_access_key = sync_s3_access_key_input.clone(); + let s3_secret_key = sync_s3_secret_key_input.clone(); + let s3_session_token = sync_s3_session_token_input.clone(); + let encryption_password = sync_encryption_password_input.clone(); + move |_, window, cx| { + let in_progress = view.read(cx).sync_in_progress; + let status = view.read(cx).sync_status.clone(); + let is_s3 = view.read(cx).config.sync_backend() == "s3"; + v_flex() + .w_full() + .gap_3() + .child(Button::new("sync-backend").small().label(if is_s3 { "S3" } else { "WebDAV" }).dropdown_menu_with_anchor(Anchor::BottomLeft, { + let view = view.clone(); + move |menu, window, _| menu + .item(PopupMenuItem::new("WebDAV").checked(!is_s3).on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx)))) + .item(PopupMenuItem::new("S3").checked(is_s3).on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx)))) + })) + .when(!is_s3, |this| this + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_endpoint").to_string())).child(Input::new(&endpoint).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_username").to_string())).child(Input::new(&username).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_webdav_password").to_string())).child(Input::new(&webdav_password).w_full()))) + .when(is_s3, |this| this + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_endpoint").to_string())).child(Input::new(&s3_endpoint).w_full())) + .child(h_flex().gap_2() + .child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_region").to_string())).child(Input::new(&s3_region).w_full())) + .child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_bucket").to_string())).child(Input::new(&s3_bucket).w_full()))) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_object_key").to_string())).child(Input::new(&s3_object_key).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_access_key").to_string())).child(Input::new(&s3_access_key).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_secret_key").to_string())).child(Input::new(&s3_secret_key).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_session_token").to_string())).child(Input::new(&s3_session_token).w_full()))) + .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_encryption_password").to_string())).child(Input::new(&encryption_password).w_full())) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("sync_security_hint").to_string())) + .child( + h_flex() + .gap_2() + .child(Button::new("sync-download").small().disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx)))) + .child(Button::new("sync-upload").small().disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))), + ) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child(status)) + } + })) + ) + ) .page({ let mut page = SettingPage::new(t!("settings_key_bindings").to_string()) .icon(IconName::SquareTerminal) diff --git a/src/app/mod.rs b/src/app/mod.rs index 5894a41..0664d23 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,3 +1,4 @@ +pub mod config_sync; pub mod constants; pub mod dialogs; pub mod keybinding_recorder; @@ -208,6 +209,19 @@ pub(crate) struct Ashell { pub(crate) key_path_input: Entity, pub(crate) key_inline_input: Entity, pub(crate) passphrase_input: Entity, + pub(crate) sync_endpoint_input: Entity, + pub(crate) sync_username_input: Entity, + pub(crate) sync_webdav_password_input: Entity, + pub(crate) sync_s3_endpoint_input: Entity, + pub(crate) sync_s3_region_input: Entity, + pub(crate) sync_s3_bucket_input: Entity, + pub(crate) sync_s3_object_key_input: Entity, + pub(crate) sync_s3_access_key_input: Entity, + pub(crate) sync_s3_secret_key_input: Entity, + pub(crate) sync_s3_session_token_input: Entity, + pub(crate) sync_encryption_password_input: Entity, + pub(crate) sync_in_progress: bool, + pub(crate) sync_status: SharedString, pub(crate) sftp_path_input: Entity, pub(crate) ssh_auth_method: AuthMethod, pub(crate) editing_session_id: Option, @@ -353,6 +367,63 @@ impl Ashell { let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/")); let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); + let config = ConfigStore::load().unwrap_or_else(|err| { + tracing::warn!("failed to load config: {err:#}"); + ConfigStore::in_memory() + }); + let sync_endpoint_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("https://dav.example.com/ashell/") + .default_value(config.sync_endpoint()) + }); + let sync_username_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_username").to_string()) + .default_value(config.sync_username()) + }); + let sync_webdav_password_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_webdav_password").to_string()) + .masked(true) + }); + let sync_s3_endpoint_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("https://s3.example.com") + .default_value(config.sync_s3_endpoint()) + }); + let sync_s3_region_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("us-east-1") + .default_value(config.sync_s3_region()) + }); + let sync_s3_bucket_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_s3_bucket").to_string()) + .default_value(config.sync_s3_bucket()) + }); + let sync_s3_object_key_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("ashell-sync.json") + .default_value(config.sync_s3_object_key()) + }); + let sync_s3_access_key_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t!("sync_s3_access_key").to_string()) + }); + let sync_s3_secret_key_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_s3_secret_key").to_string()) + .masked(true) + }); + let sync_s3_session_token_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_s3_session_token").to_string()) + .masked(true) + }); + let sync_encryption_password_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("sync_encryption_password").to_string()) + .masked(true) + }); let _subscriptions = vec![ cx.subscribe_in(&host_input, window, Self::on_input_event), @@ -365,6 +436,21 @@ impl Ashell { cx.subscribe_in(&passphrase_input, window, Self::on_input_event), cx.subscribe_in(&sftp_path_input, window, Self::on_input_event), cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event), + cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event), + cx.subscribe_in(&sync_username_input, window, Self::on_input_event), + cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_endpoint_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_region_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_bucket_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_object_key_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_access_key_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_secret_key_input, window, Self::on_input_event), + cx.subscribe_in(&sync_s3_session_token_input, window, Self::on_input_event), + cx.subscribe_in( + &sync_encryption_password_input, + window, + Self::on_input_event, + ), ]; let (events_tx, events_rx) = mpsc::channel(); @@ -374,10 +460,6 @@ impl Ashell { let system = system_sampler.sample(); let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone(); let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone(); - let config = ConfigStore::load().unwrap_or_else(|err| { - tracing::warn!("failed to load config: {err:#}"); - ConfigStore::in_memory() - }); let follow_system_theme = config.follow_system_theme(); let theme_mode = match config.theme_mode() { @@ -421,6 +503,19 @@ impl Ashell { key_path_input, key_inline_input, passphrase_input, + sync_endpoint_input, + sync_username_input, + sync_webdav_password_input, + sync_s3_endpoint_input, + sync_s3_region_input, + sync_s3_bucket_input, + sync_s3_object_key_input, + sync_s3_access_key_input, + sync_s3_secret_key_input, + sync_s3_session_token_input, + sync_encryption_password_input, + sync_in_progress: false, + sync_status: t!("sync_not_run").into(), sftp_path_input, ssh_auth_method: AuthMethod::Password, editing_session_id: None, @@ -799,6 +894,32 @@ impl Ashell { tab.title = title.clone(); } } + BackendEvent::SyncFinished(result) => { + self.sync_in_progress = false; + match result { + crate::sync::SyncResult::Uploaded { etag } => { + if etag.is_some() { + self.config.set_sync_etag(etag); + } + self.sync_status = t!("sync_upload_complete").into(); + let _ = self.config.save(); + } + crate::sync::SyncResult::Downloaded { payload, etag } => { + self.config.replace_sessions(payload.sessions); + self.config.set_sync_etag(etag); + match self.config.save() { + Ok(()) => self.sync_status = t!("sync_download_complete").into(), + Err(err) => { + self.sync_status = + format!("{}: {err:#}", t!("sync_failed")).into() + } + } + } + crate::sync::SyncResult::Failed(error) => { + self.sync_status = format!("{}: {error}", t!("sync_failed")).into(); + } + } + } } } if transfers_changed { diff --git a/src/main.rs b/src/main.rs index 57b06ac..a2a4cee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod app; mod backend; mod session; mod sftp; +mod sync; mod system; mod terminal; diff --git a/src/session/config.rs b/src/session/config.rs index 6b59577..08e24f6 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -150,12 +150,40 @@ pub struct ConfigFile { pub sftp_panel_minimized: bool, #[serde(default)] pub key_bindings: std::collections::HashMap, + #[serde(default)] + pub sync_endpoint: String, + #[serde(default)] + pub sync_username: String, + #[serde(default)] + pub sync_etag: Option, + #[serde(default)] + pub sync_device_id: String, + #[serde(default)] + pub sync_backend: String, + #[serde(default)] + pub sync_etag_backend: String, + #[serde(default)] + pub sync_s3_endpoint: String, + #[serde(default = "default_s3_region")] + pub sync_s3_region: String, + #[serde(default)] + pub sync_s3_bucket: String, + #[serde(default = "default_s3_object_key")] + pub sync_s3_object_key: String, } fn default_monitoring_position() -> String { "Sidebar".to_string() } +fn default_s3_region() -> String { + "us-east-1".to_string() +} + +fn default_s3_object_key() -> String { + "ashell-sync.json".to_string() +} + fn default_follow_system_theme() -> bool { true } @@ -208,7 +236,7 @@ impl ConfigStore { let _ = fs::create_dir_all(&tmp_dir); } - let cache = if path.exists() { + let mut cache = if path.exists() { let raw = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; match serde_json::from_str::(&raw) { @@ -235,13 +263,20 @@ impl ConfigStore { ConfigFile::default() }; + if cache.sync_device_id.is_empty() { + cache.sync_device_id = Uuid::new_v4().to_string(); + } Ok(Self { path, cache }) } pub fn in_memory() -> Self { + let cache = ConfigFile { + sync_device_id: Uuid::new_v4().to_string(), + ..ConfigFile::default() + }; Self { path: PathBuf::new(), - cache: ConfigFile::default(), + cache, } } @@ -258,6 +293,79 @@ impl ConfigStore { &self.cache.sessions } + pub fn replace_sessions(&mut self, sessions: Vec) { + self.cache.sessions = sessions; + } + + pub fn sync_endpoint(&self) -> &str { + &self.cache.sync_endpoint + } + + pub fn sync_username(&self) -> &str { + &self.cache.sync_username + } + + pub fn sync_etag(&self) -> Option<&str> { + (self.cache.sync_etag_backend == self.sync_backend()) + .then_some(self.cache.sync_etag.as_deref()) + .flatten() + } + + pub fn sync_device_id(&self) -> &str { + &self.cache.sync_device_id + } + + pub fn sync_backend(&self) -> &str { + if self.cache.sync_backend == "s3" { + "s3" + } else { + "webdav" + } + } + + pub fn set_sync_backend(&mut self, backend: &str) { + self.cache.sync_backend = if backend == "s3" { "s3" } else { "webdav" }.to_string(); + } + + pub fn sync_s3_endpoint(&self) -> &str { + &self.cache.sync_s3_endpoint + } + + pub fn sync_s3_region(&self) -> &str { + if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region } + } + + pub fn sync_s3_bucket(&self) -> &str { + &self.cache.sync_s3_bucket + } + + pub fn sync_s3_object_key(&self) -> &str { + if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key } + } + + pub fn set_sync_connection(&mut self, endpoint: String, username: String) { + self.cache.sync_endpoint = endpoint; + self.cache.sync_username = username; + } + + pub fn set_sync_s3_connection( + &mut self, + endpoint: String, + region: String, + bucket: String, + object_key: String, + ) { + self.cache.sync_s3_endpoint = endpoint; + self.cache.sync_s3_region = region; + self.cache.sync_s3_bucket = bucket; + self.cache.sync_s3_object_key = object_key; + } + + pub fn set_sync_etag(&mut self, etag: Option) { + self.cache.sync_etag = etag; + self.cache.sync_etag_backend = self.sync_backend().to_string(); + } + pub fn tmp_dir(&self) -> Option { self.path.parent().map(|p| p.join("tmp")) } diff --git a/src/sync/mod.rs b/src/sync/mod.rs new file mode 100644 index 0000000..25ac8a8 --- /dev/null +++ b/src/sync/mod.rs @@ -0,0 +1,577 @@ +use anyhow::{Context, Result, anyhow}; +use argon2::Argon2; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit}, +}; +use hmac::{Hmac, Mac}; +use rand::{RngCore, rngs::OsRng}; +use reqwest::{Client, StatusCode, header}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::session::config::Session; + +const SYNC_FILE_NAME: &str = "ashell-sync.json"; +const FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncPayload { + pub schema_version: u32, + pub revision: String, + pub updated_at: String, + pub device_id: String, + pub sessions: Vec, +} + +impl SyncPayload { + pub fn new(device_id: String, sessions: Vec) -> Self { + Self { + schema_version: FORMAT_VERSION, + revision: Uuid::new_v4().to_string(), + updated_at: chrono::Utc::now().to_rfc3339(), + device_id, + sessions, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EncryptedEnvelope { + format_version: u32, + kdf: String, + cipher: String, + salt: String, + nonce: String, + payload: String, +} + +#[derive(Clone)] +pub struct SyncCredentials { + pub backend: SyncBackendCredentials, + pub encryption_password: String, +} + +#[derive(Clone)] +pub enum SyncBackendCredentials { + WebDav { + endpoint: String, + username: String, + password: String, + }, + S3 { + endpoint: String, + region: String, + bucket: String, + object_key: String, + access_key: String, + secret_key: String, + session_token: String, + }, +} + +#[derive(Debug, Clone)] +pub enum SyncResult { + Uploaded { + etag: Option, + }, + Downloaded { + payload: SyncPayload, + etag: Option, + }, + Failed(String), +} + +pub async fn upload( + credentials: SyncCredentials, + payload: SyncPayload, + expected_etag: Option, +) -> Result> { + validate_credentials(&credentials)?; + let body = encrypt_payload(&payload, &credentials.encryption_password)?; + match credentials.backend { + SyncBackendCredentials::WebDav { + endpoint, + username, + password, + } => upload_webdav(&endpoint, &username, &password, body, expected_etag).await, + SyncBackendCredentials::S3 { + endpoint, + region, + bucket, + object_key, + access_key, + secret_key, + session_token, + } => { + let config = S3Config { + endpoint, + region, + bucket, + object_key, + access_key, + secret_key, + session_token, + }; + upload_s3(&config, body, expected_etag).await + } + } +} + +async fn upload_webdav( + endpoint: &str, + username: &str, + password: &str, + body: Vec, + expected_etag: Option, +) -> Result> { + let client = Client::new(); + let mut request = client + .put(sync_url(endpoint)) + .basic_auth(username, Some(password)) + .header(header::CONTENT_TYPE, "application/json") + .body(body); + request = if let Some(etag) = expected_etag { + request.header(header::IF_MATCH, etag) + } else { + // An uninitialized client may only create a new remote file. This keeps + // it from silently replacing configuration uploaded by another device. + request.header(header::IF_NONE_MATCH, "*") + }; + let response = request.send().await.context("send WebDAV upload")?; + if response.status() == StatusCode::PRECONDITION_FAILED + || response.status() == StatusCode::CONFLICT + { + return Err(anyhow!( + "remote configuration changed; download it before uploading" + )); + } + if !response.status().is_success() { + return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status())); + } + Ok(response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string)) +} + +pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option)> { + validate_credentials(&credentials)?; + let encryption_password = credentials.encryption_password; + let (body, etag) = match credentials.backend { + SyncBackendCredentials::WebDav { + endpoint, + username, + password, + } => download_webdav(&endpoint, &username, &password).await?, + SyncBackendCredentials::S3 { + endpoint, + region, + bucket, + object_key, + access_key, + secret_key, + session_token, + } => { + let config = S3Config { + endpoint, + region, + bucket, + object_key, + access_key, + secret_key, + session_token, + }; + download_s3(&config).await? + } + }; + let payload = decrypt_payload(&body, &encryption_password)?; + Ok((payload, etag)) +} + +async fn download_webdav( + endpoint: &str, + username: &str, + password: &str, +) -> Result<(Vec, Option)> { + let response = Client::new() + .get(sync_url(endpoint)) + .basic_auth(username, Some(password)) + .send() + .await + .context("send WebDAV download")?; + if response.status() == StatusCode::NOT_FOUND { + return Err(anyhow!("no remote configuration exists yet")); + } + if !response.status().is_success() { + return Err(anyhow!( + "WebDAV download failed: HTTP {}", + response.status() + )); + } + let etag = response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response + .bytes() + .await + .context("read WebDAV response")? + .to_vec(); + Ok((body, etag)) +} + +fn validate_credentials(credentials: &SyncCredentials) -> Result<()> { + if credentials.encryption_password.len() < 8 { + return Err(anyhow!( + "encryption password must contain at least 8 characters" + )); + } + match &credentials.backend { + SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => { + Err(anyhow!("WebDAV endpoint is required")) + } + SyncBackendCredentials::S3 { + region, + bucket, + access_key, + secret_key, + .. + } if region.trim().is_empty() + || bucket.trim().is_empty() + || access_key.trim().is_empty() + || secret_key.is_empty() => + { + Err(anyhow!( + "S3 region, bucket, access key and secret key are required" + )) + } + _ => Ok(()), + } +} + +struct S3Config { + endpoint: String, + region: String, + bucket: String, + object_key: String, + access_key: String, + secret_key: String, + session_token: String, +} + +async fn upload_s3( + config: &S3Config, + body: Vec, + expected_etag: Option, +) -> Result> { + let url = s3_url(config)?; + let mut headers = signed_s3_headers("PUT", &url, &body, config)?; + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + if let Some(etag) = expected_etag { + headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?); + } else { + headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*")); + } + let response = Client::new() + .put(url) + .headers(headers) + .body(body) + .send() + .await + .context("send S3 upload")?; + if response.status() == StatusCode::PRECONDITION_FAILED + || response.status() == StatusCode::CONFLICT + { + return Err(anyhow!( + "remote configuration changed; download it before uploading" + )); + } + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}")); + } + Ok(response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string)) +} + +async fn download_s3(config: &S3Config) -> Result<(Vec, Option)> { + let url = s3_url(config)?; + let headers = signed_s3_headers("GET", &url, &[], config)?; + let response = Client::new() + .get(url) + .headers(headers) + .send() + .await + .context("send S3 download")?; + if response.status() == StatusCode::NOT_FOUND { + return Err(anyhow!("no remote configuration exists yet")); + } + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(anyhow!("S3 download failed: HTTP {status}: {detail}")); + } + let etag = response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response.bytes().await.context("read S3 response")?.to_vec(); + Ok((body, etag)) +} + +fn s3_url(config: &S3Config) -> Result { + let endpoint = if config.endpoint.trim().is_empty() { + format!("https://s3.{}.amazonaws.com", config.region.trim()) + } else { + config.endpoint.trim().trim_end_matches('/').to_string() + }; + let key = if config.object_key.trim().is_empty() { + SYNC_FILE_NAME + } else { + config.object_key.trim().trim_start_matches('/') + }; + let url = format!( + "{}/{}/{}", + endpoint, + aws_uri_encode(config.bucket.trim(), true), + aws_uri_encode(key, false) + ); + reqwest::Url::parse(&url).context("parse S3 object URL") +} + +fn signed_s3_headers( + method: &str, + url: &reqwest::Url, + body: &[u8], + config: &S3Config, +) -> Result { + let now = chrono::Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date = now.format("%Y%m%d").to_string(); + let host = url + .host_str() + .ok_or_else(|| anyhow!("S3 endpoint has no host"))?; + let host = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let payload_hash = hex_sha256(body); + let mut canonical_headers = + format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"); + let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string(); + if !config.session_token.is_empty() { + canonical_headers.push_str(&format!( + "x-amz-security-token:{}\n", + config.session_token.trim() + )); + signed_headers.push_str(";x-amz-security-token"); + } + let canonical_request = format!( + "{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}", + url.path() + ); + let scope = format!("{date}/{}/s3/aws4_request", config.region.trim()); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", + hex_sha256(canonical_request.as_bytes()) + ); + let date_key = hmac_sha256( + format!("AWS4{}", config.secret_key).as_bytes(), + date.as_bytes(), + )?; + let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?; + let service_key = hmac_sha256(®ion_key, b"s3")?; + let signing_key = hmac_sha256(&service_key, b"aws4_request")?; + let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}", + config.access_key.trim() + ); + let mut headers = header::HeaderMap::new(); + headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?); + headers.insert( + "x-amz-content-sha256", + header_value(&payload_hash, "S3 payload hash")?, + ); + headers.insert( + header::AUTHORIZATION, + header_value(&authorization, "S3 authorization")?, + ); + if !config.session_token.is_empty() { + headers.insert( + "x-amz-security-token", + header_value(config.session_token.trim(), "S3 session token")?, + ); + } + Ok(headers) +} + +fn header_value(value: &str, name: &str) -> Result { + header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}")) +} + +fn hmac_sha256(key: &[u8], value: &[u8]) -> Result> { + let mut mac = + as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?; + mac.update(value); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn hex_sha256(value: &[u8]) -> String { + hex::encode(Sha256::digest(value)) +} + +fn aws_uri_encode(value: &str, encode_slash: bool) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'_' | b'.' | b'~') + || (!encode_slash && byte == b'/') + { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn sync_url(endpoint: &str) -> String { + let endpoint = endpoint.trim(); + if endpoint.ends_with('/') { + format!("{endpoint}{SYNC_FILE_NAME}") + } else if endpoint.ends_with(".json") { + endpoint.to_string() + } else { + format!("{endpoint}/{SYNC_FILE_NAME}") + } +} + +fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result> { + let mut salt = [0u8; 16]; + let mut nonce = [0u8; 24]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce); + let key = derive_key(password, &salt)?; + let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?; + let ciphertext = XChaCha20Poly1305::new((&key).into()) + .encrypt(XNonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| anyhow!("encrypt sync payload"))?; + serde_json::to_vec_pretty(&EncryptedEnvelope { + format_version: FORMAT_VERSION, + kdf: "argon2id".to_string(), + cipher: "xchacha20poly1305".to_string(), + salt: STANDARD.encode(salt), + nonce: STANDARD.encode(nonce), + payload: STANDARD.encode(ciphertext), + }) + .context("serialize encrypted sync envelope") +} + +fn decrypt_payload(raw: &[u8], password: &str) -> Result { + let envelope: EncryptedEnvelope = + serde_json::from_slice(raw).context("parse encrypted sync envelope")?; + if envelope.format_version != FORMAT_VERSION + || envelope.kdf != "argon2id" + || envelope.cipher != "xchacha20poly1305" + { + return Err(anyhow!("unsupported remote sync format")); + } + let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?; + let nonce = STANDARD + .decode(envelope.nonce) + .context("decode sync nonce")?; + if nonce.len() != 24 { + return Err(anyhow!("invalid sync nonce")); + } + let ciphertext = STANDARD + .decode(envelope.payload) + .context("decode encrypted sync payload")?; + let key = derive_key(password, &salt)?; + let plaintext = XChaCha20Poly1305::new((&key).into()) + .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref()) + .map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?; + let payload: SyncPayload = + serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?; + if payload.schema_version != FORMAT_VERSION { + return Err(anyhow!("unsupported synchronized configuration version")); + } + Ok(payload) +} + +fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|err| anyhow!("derive encryption key: {err}"))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypted_payload_round_trip() { + let payload = SyncPayload::new("test-device".into(), Vec::new()); + let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap(); + assert!(!String::from_utf8_lossy(&encrypted).contains("test-device")); + let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap(); + assert_eq!(decrypted.revision, payload.revision); + } + + #[test] + fn wrong_password_is_rejected() { + let payload = SyncPayload::new("test-device".into(), Vec::new()); + let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap(); + assert!(decrypt_payload(&encrypted, "incorrect password").is_err()); + } + + #[test] + fn endpoint_can_be_a_collection_or_file() { + assert_eq!( + sync_url("https://example.test/dav/"), + "https://example.test/dav/ashell-sync.json" + ); + assert_eq!( + sync_url("https://example.test/config.json"), + "https://example.test/config.json" + ); + } + + #[test] + fn s3_url_uses_path_style_and_encodes_object_key() { + let config = S3Config { + endpoint: "https://s3.example.test".into(), + region: "us-east-1".into(), + bucket: "my-bucket".into(), + object_key: "configs/my file.json".into(), + access_key: "access".into(), + secret_key: "secret".into(), + session_token: String::new(), + }; + assert_eq!( + s3_url(&config).unwrap().as_str(), + "https://s3.example.test/my-bucket/configs/my%20file.json" + ); + } + + #[test] + fn aws_uri_encoding_preserves_only_object_key_slashes() { + assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c"); + assert_eq!(aws_uri_encode("a/b", true), "a%2Fb"); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index a03edc5..1b82893 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -90,6 +90,7 @@ pub enum BackendEvent { tab_id: String, title: String, }, + SyncFinished(crate::sync::SyncResult), } #[derive(Clone)] From 42c408f63f05b3968281ecf35dd45fb3756e4c51 Mon Sep 17 00:00:00 2001 From: TomZz Date: Mon, 22 Jun 2026 10:53:14 +0800 Subject: [PATCH 2/2] style: change sync backend selection to segmented buttons --- src/app/dialogs.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 50f44de..9d2141d 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1685,12 +1685,24 @@ impl Ashell { v_flex() .w_full() .gap_3() - .child(Button::new("sync-backend").small().label(if is_s3 { "S3" } else { "WebDAV" }).dropdown_menu_with_anchor(Anchor::BottomLeft, { - let view = view.clone(); - move |menu, window, _| menu - .item(PopupMenuItem::new("WebDAV").checked(!is_s3).on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx)))) - .item(PopupMenuItem::new("S3").checked(is_s3).on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx)))) - })) + .child( + h_flex() + .gap_2() + .child( + Button::new("sync-backend-webdav") + .small() + .label("WebDAV") + .when(!is_s3, |button| button.primary()) + .on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx))) + ) + .child( + Button::new("sync-backend-s3") + .small() + .label("S3") + .when(is_s3, |button| button.primary()) + .on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx))) + ) + ) .when(!is_s3, |this| this .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_endpoint").to_string())).child(Input::new(&endpoint).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_username").to_string())).child(Input::new(&username).w_full()))