refactor: extract shared sync logic from gist into sync module

This commit is contained in:
2026-05-27 00:29:19 +08:00
parent 093cf153d3
commit c9f218f2e3
7 changed files with 211 additions and 204 deletions
+3 -3
View File
@@ -77,9 +77,9 @@ impl App {
pub fn pull_sync_with_toast(&mut self) { pub fn pull_sync_with_toast(&mut self) {
let result = match self.config.settings.backend { let result = match self.config.settings.backend {
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
}; };
match result { match result {
Ok(count) => self.toast(format!("pulled {count} items"), true), Ok(count) => self.toast(format!("pulled {count} items"), true),
+3 -3
View File
@@ -1,5 +1,5 @@
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary}; use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
use crate::sync::{gist, s3, webdav}; use crate::sync::{self, gist, s3, webdav};
use crate::{connection, import, ui}; use crate::{connection, import, ui};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -75,8 +75,8 @@ pub fn run() -> Result<()> {
fn run_sync(command: SyncCommand) -> Result<()> { fn run_sync(command: SyncCommand) -> Result<()> {
let mut cfg = SshellConfig::load()?; let mut cfg = SshellConfig::load()?;
let strat = |s: PullStrategy| match s { let strat = |s: PullStrategy| match s {
PullStrategy::Merge => gist::PullStrategy::Merge, PullStrategy::Merge => sync::PullStrategy::Merge,
PullStrategy::Overwrite => gist::PullStrategy::Overwrite, PullStrategy::Overwrite => sync::PullStrategy::Overwrite,
}; };
match command { match command {
SyncCommand::Push => match cfg.settings.backend { SyncCommand::Push => match cfg.settings.backend {
+201
View File
@@ -1,3 +1,204 @@
mod crypto;
pub mod gist; pub mod gist;
pub mod s3; pub mod s3;
pub mod webdav; pub mod webdav;
use crate::config::ConnectionType;
use crate::config::{ConnectionSource, CredentialStore, SshellConfig};
use anyhow::{Context, Result};
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
#[derive(Debug, Clone, Copy)]
pub enum PullStrategy {
Merge,
Overwrite,
}
pub(crate) fn localize_shell_profile(
cfg: &SshellConfig,
name: &str,
profile: &mut crate::config::ConnectionProfile,
) -> bool {
let ConnectionType::Shell {
shell_name,
command,
local_args,
auth_ref,
..
} = &mut profile.kind
else {
return true;
};
local_args.clear();
*auth_ref = None;
let Some(local_command) = cfg.local_shell_command(shell_name) else {
return false;
};
*command = local_command;
if let Some(local_profile) = cfg.connections.get(name) {
profile.local_tags = local_profile.local_tags.clone();
profile.added_order = local_profile.added_order;
profile.usage_count = local_profile.usage_count;
if let ConnectionType::Shell {
local_args: existing_local_args,
..
} = &local_profile.kind
{
*local_args = existing_local_args.clone();
}
}
true
}
pub(crate) fn build_sync_payload(
cfg: &SshellConfig,
sync_password: Option<&str>,
) -> Result<toml::Value> {
let mut payload = cfg.clone();
payload.settings.sync_password = None;
payload.settings.webdav_password = None;
payload.settings.s3_secret_key = None;
let synced_refs: Vec<String> = payload
.connections
.iter()
.filter(|(_, profile)| profile.sync())
.filter_map(|(_, profile)| profile.auth_ref().map(String::from))
.collect();
payload.credentials.entries.retain(|name, _| {
name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name)
});
let encrypted = if !payload.credentials.entries.is_empty() {
if let Some(pw) = sync_password {
Some(crypto::encrypt_credentials(&payload.credentials, pw)?)
} else {
None
}
} else {
None
};
let mut table = toml::map::Map::new();
table.insert(
"version".to_string(),
toml::Value::Integer(payload.version as i64),
);
table.insert("settings".to_string(), to_toml_value(&payload.settings)?);
let mut conns = toml::map::Map::new();
for (name, profile) in &mut payload.connections {
profile.local_tags.clear();
if !payload.settings.sync_usage_count {
profile.usage_count = 0;
}
match &mut profile.kind {
ConnectionType::Shell {
auth_ref,
command,
shell_name: _,
local_args,
sync,
..
} => {
if !*sync {
continue;
}
local_args.clear();
*command = String::new();
*auth_ref = None;
}
ConnectionType::Ssh { sync, .. } => {
if !*sync {
continue;
}
profile.source = ConnectionSource::Manual;
}
}
conns.insert(name.clone(), to_toml_value(&*profile)?);
}
table.insert("connections".to_string(), toml::Value::Table(conns));
if let Some(enc) = encrypted {
table.insert(
"credentials_encrypted".to_string(),
toml::Value::String(enc),
);
} else if !payload.credentials.entries.is_empty() {
table.insert(
"credentials".to_string(),
to_toml_value(&payload.credentials)?,
);
}
Ok(toml::Value::Table(table))
}
pub(crate) fn merge_remote(
cfg: &mut SshellConfig,
remote: toml::Value,
strategy: PullStrategy,
) -> Result<usize> {
let mut count = 0;
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
for (name, profile_val) in conns {
let should_insert = match strategy {
PullStrategy::Merge => !cfg.connections.contains_key(name),
PullStrategy::Overwrite => true,
};
if should_insert
&& let Ok(mut profile) = profile_val
.clone()
.try_into::<crate::config::ConnectionProfile>()
&& localize_shell_profile(cfg, name, &mut profile)
{
cfg.connections.insert(name.clone(), profile);
count += 1;
}
}
}
let remote_credentials =
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
let sync_password = cfg
.settings
.sync_password
.as_deref()
.context("sync_password not set; needed to decrypt remote credentials")?;
Some(crypto::decrypt_credentials(enc, sync_password)?)
} else if let Some(creds_val) = remote.get("credentials") {
creds_val.clone().try_into::<CredentialStore>().ok()
} else {
None
};
if let Some(remote_creds) = remote_credentials {
for (name, credential) in remote_creds.entries {
if name == GIST_TOKEN_REF {
continue;
}
let should_insert = match strategy {
PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name),
PullStrategy::Overwrite => true,
};
if should_insert {
cfg.credentials.entries.insert(name, credential);
count += 1;
}
}
}
cfg.save()?;
Ok(count)
}
pub(crate) fn to_toml_value<T: serde::Serialize>(val: &T) -> Result<toml::Value> {
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
}
+2 -196
View File
@@ -1,19 +1,10 @@
use crate::config::ConnectionType; use crate::config::{CredentialEntry, SshellConfig};
use crate::config::{ConnectionSource, CredentialEntry, CredentialStore, SshellConfig}; use super::{GIST_TOKEN_REF, PullStrategy, build_sync_payload, merge_remote};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde_json::json; use serde_json::json;
mod crypto;
const FILE_NAME: &str = "sshell-config.toml"; const FILE_NAME: &str = "sshell-config.toml";
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
#[derive(Debug, Clone, Copy)]
pub enum PullStrategy {
Merge,
Overwrite,
}
pub fn push(cfg: &mut SshellConfig) -> Result<String> { pub fn push(cfg: &mut SshellConfig) -> Result<String> {
let token = gist_token(cfg)?; let token = gist_token(cfg)?;
@@ -96,188 +87,3 @@ fn gist_token(cfg: &SshellConfig) -> Result<String> {
_ => bail!("set GITHUB_TOKEN or create password credential __gist_token"), _ => bail!("set GITHUB_TOKEN or create password credential __gist_token"),
} }
} }
pub(crate) fn localize_shell_profile(
cfg: &SshellConfig,
name: &str,
profile: &mut crate::config::ConnectionProfile,
) -> bool {
let ConnectionType::Shell {
shell_name,
command,
local_args,
auth_ref,
..
} = &mut profile.kind
else {
return true;
};
local_args.clear();
*auth_ref = None;
let Some(local_command) = cfg.local_shell_command(shell_name) else {
return false;
};
*command = local_command;
if let Some(local_profile) = cfg.connections.get(name) {
profile.local_tags = local_profile.local_tags.clone();
profile.added_order = local_profile.added_order;
profile.usage_count = local_profile.usage_count;
if let ConnectionType::Shell {
local_args: existing_local_args,
..
} = &local_profile.kind
{
*local_args = existing_local_args.clone();
}
}
true
}
pub(crate) fn build_sync_payload(cfg: &SshellConfig, sync_password: Option<&str>) -> Result<toml::Value> {
let mut payload = cfg.clone();
payload.settings.sync_password = None;
payload.settings.webdav_password = None;
payload.settings.s3_secret_key = None;
// Collect auth_refs from synced connections
let synced_refs: Vec<String> = payload
.connections
.iter()
.filter(|(_, profile)| profile.sync())
.filter_map(|(_, profile)| profile.auth_ref().map(String::from))
.collect();
payload.credentials.entries.retain(|name, _| {
name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name)
});
let encrypted = if !payload.credentials.entries.is_empty() {
if let Some(pw) = sync_password {
Some(crypto::encrypt_credentials(&payload.credentials, pw)?)
} else {
None
}
} else {
None
};
let mut table = toml::map::Map::new();
table.insert(
"version".to_string(),
toml::Value::Integer(payload.version as i64),
);
table.insert("settings".to_string(), to_toml_value(&payload.settings)?);
let mut conns = toml::map::Map::new();
for (name, profile) in &mut payload.connections {
profile.local_tags.clear();
if !payload.settings.sync_usage_count {
profile.usage_count = 0;
}
match &mut profile.kind {
ConnectionType::Shell {
auth_ref,
command,
shell_name: _,
local_args,
sync,
..
} => {
if !*sync {
continue;
}
local_args.clear();
*command = String::new();
*auth_ref = None;
}
ConnectionType::Ssh { sync, .. } => {
if !*sync {
continue;
}
profile.source = ConnectionSource::Manual;
}
}
conns.insert(name.clone(), to_toml_value(&*profile)?);
}
table.insert("connections".to_string(), toml::Value::Table(conns));
if let Some(enc) = encrypted {
table.insert(
"credentials_encrypted".to_string(),
toml::Value::String(enc),
);
} else if !payload.credentials.entries.is_empty() {
table.insert("credentials".to_string(), to_toml_value(&payload.credentials)?);
}
Ok(toml::Value::Table(table))
}
pub(crate) fn merge_remote(
cfg: &mut SshellConfig,
remote: toml::Value,
strategy: PullStrategy,
) -> Result<usize> {
let mut count = 0;
// Merge connections
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
for (name, profile_val) in conns {
let should_insert = match strategy {
PullStrategy::Merge => !cfg.connections.contains_key(name),
PullStrategy::Overwrite => true,
};
if should_insert
&& let Ok(mut profile) = profile_val
.clone()
.try_into::<crate::config::ConnectionProfile>()
&& localize_shell_profile(cfg, name, &mut profile)
{
cfg.connections.insert(name.clone(), profile);
count += 1;
}
}
}
// Merge credentials: try encrypted first, then plaintext fallback
let remote_credentials =
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
let sync_password = cfg
.settings
.sync_password
.as_deref()
.context("sync_password not set; needed to decrypt remote credentials")?;
Some(crypto::decrypt_credentials(enc, sync_password)?)
} else if let Some(creds_val) = remote.get("credentials") {
creds_val.clone().try_into::<CredentialStore>().ok()
} else {
None
};
if let Some(remote_creds) = remote_credentials {
for (name, credential) in remote_creds.entries {
if name == GIST_TOKEN_REF {
continue;
}
let should_insert = match strategy {
PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name),
PullStrategy::Overwrite => true,
};
if should_insert {
cfg.credentials.entries.insert(name, credential);
count += 1;
}
}
}
cfg.save()?;
Ok(count)
}
pub(crate) fn to_toml_value<T: serde::Serialize>(val: &T) -> Result<toml::Value> {
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
}
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::config::SshellConfig; use crate::config::SshellConfig;
use super::gist::{PullStrategy, build_sync_payload, merge_remote}; use super::{PullStrategy, build_sync_payload, merge_remote};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use reqwest::blocking::Client; use reqwest::blocking::Client;
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::config::SshellConfig; use crate::config::SshellConfig;
use super::gist::{PullStrategy, build_sync_payload, merge_remote}; use super::{PullStrategy, build_sync_payload, merge_remote};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, CONTENT_TYPE}; use reqwest::header::{ACCEPT, CONTENT_TYPE};