feat: add webdav backup

This commit is contained in:
huzibaca 2024-11-09 23:11:02 +08:00
parent 4ec0b1d6e4
commit 3759239dac
No known key found for this signature in database
GPG Key ID: D4364EE4851DC302
16 changed files with 703 additions and 577 deletions

View File

@ -55,7 +55,7 @@ tauri-plugin-deep-link = "2.0.1"
tauri-plugin-devtools = "2.0.0-rc" tauri-plugin-devtools = "2.0.0-rc"
url = "2.5.2" url = "2.5.2"
zip = "2.2.0" zip = "2.2.0"
reqwest_dav = "=0.1.14" reqwest_dav = "0.1.14"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
runas = "=1.2.0" runas = "=1.2.0"
deelevate = "0.2.0" deelevate = "0.2.0"

View File

@ -185,7 +185,7 @@ pub async fn change_clash_core(clash_core: Option<String>) -> CmdResult {
/// restart the sidecar /// restart the sidecar
#[tauri::command] #[tauri::command]
pub async fn restart_sidecar() -> CmdResult { pub async fn restart_core() -> CmdResult {
wrap_err!(CoreManager::global().restart_core().await) wrap_err!(CoreManager::global().restart_core().await)
} }
@ -396,15 +396,28 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
#[tauri::command] #[tauri::command]
pub async fn create_webdav_backup() -> CmdResult<()> { pub async fn create_webdav_backup() -> CmdResult<()> {
feat::create_backup_and_upload_webdav() wrap_err!(feat::create_backup_and_upload_webdav().await)
.await
.map_err(|err| err.to_string())?;
Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> { pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
feat::list_wevdav_backup().await.map_err(|e| e.to_string()) wrap_err!(feat::list_wevdav_backup().await)
}
#[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::delete_webdav_backup(filename).await)
}
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::restore_webdav_backup(filename).await)
}
#[tauri::command]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app();
Ok(())
} }
pub mod service { pub mod service {

View File

@ -40,14 +40,27 @@ impl WebDavClient {
let url = verge.webdav_url.unwrap_or_default(); let url = verge.webdav_url.unwrap_or_default();
let username = verge.webdav_username.unwrap_or_default(); let username = verge.webdav_username.unwrap_or_default();
let password = verge.webdav_password.unwrap_or_default(); let password = verge.webdav_password.unwrap_or_default();
let url = url.trim_end_matches('/');
let client = reqwest_dav::ClientBuilder::new() let client = reqwest_dav::ClientBuilder::new()
.set_agent(
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap(),
)
.set_host(url.to_owned()) .set_host(url.to_owned())
.set_auth(reqwest_dav::Auth::Basic( .set_auth(reqwest_dav::Auth::Basic(
username.to_owned(), username.to_owned(),
password.to_owned(), password.to_owned(),
)) ))
.build()?; .build()?;
if let Err(_) = client
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(0))
.await
{
client.mkcol(dirs::BACKUP_DIR).await?;
}
*self.client.lock() = Some(client.clone()); *self.client.lock() = Some(client.clone());
} }
Ok(self.client.lock().clone().unwrap()) Ok(self.client.lock().clone().unwrap())
@ -61,10 +74,6 @@ impl WebDavClient {
pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> { pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> {
let client = self.get_client().await?; let client = self.get_client().await?;
if client.get(dirs::BACKUP_DIR).await.is_err() {
client.mkcol(dirs::BACKUP_DIR).await?;
}
let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name); let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name);
client client
.put(webdav_path.as_ref(), fs::read(file_path)?) .put(webdav_path.as_ref(), fs::read(file_path)?)
@ -72,7 +81,16 @@ impl WebDavClient {
Ok(()) Ok(())
} }
pub async fn list_files(&self) -> Result<Vec<ListFile>, Error> { pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> {
let client = self.get_client().await?;
let path = format!("{}/{}", dirs::BACKUP_DIR, filename);
let response = client.get(&path.as_str()).await?;
let content = response.bytes().await?;
fs::write(&storage_path, &content)?;
Ok(())
}
pub async fn list(&self) -> Result<Vec<ListFile>, Error> {
let client = self.get_client().await?; let client = self.get_client().await?;
let files = client let files = client
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(1)) .list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(1))
@ -85,6 +103,13 @@ impl WebDavClient {
} }
Ok(final_files) Ok(final_files)
} }
pub async fn delete(&self, file_name: String) -> Result<(), Error> {
let client = self.get_client().await?;
let path = format!("{}/{}", dirs::BACKUP_DIR, file_name);
client.delete(&path).await?;
Ok(())
}
} }
pub fn create_backup() -> Result<(String, PathBuf), Error> { pub fn create_backup() -> Result<(String, PathBuf), Error> {
@ -109,8 +134,17 @@ pub fn create_backup() -> Result<(String, PathBuf), Error> {
} }
zip.start_file(dirs::CLASH_CONFIG, options)?; zip.start_file(dirs::CLASH_CONFIG, options)?;
zip.write_all(fs::read(dirs::clash_path()?)?.as_slice())?; zip.write_all(fs::read(dirs::clash_path()?)?.as_slice())?;
let mut verge_config: serde_json::Value =
serde_yaml::from_str(&fs::read_to_string(dirs::verge_path()?)?)?;
if let Some(obj) = verge_config.as_object_mut() {
obj.remove("webdav_username");
obj.remove("webdav_password");
obj.remove("webdav_url");
}
zip.start_file(dirs::VERGE_CONFIG, options)?; zip.start_file(dirs::VERGE_CONFIG, options)?;
zip.write_all(fs::read(dirs::verge_path()?)?.as_slice())?; zip.write_all(serde_yaml::to_string(&verge_config)?.as_bytes())?;
zip.start_file(dirs::PROFILE_YAML, options)?; zip.start_file(dirs::PROFILE_YAML, options)?;
zip.write_all(fs::read(dirs::profiles_path()?)?.as_slice())?; zip.write_all(fs::read(dirs::profiles_path()?)?.as_slice())?;
zip.finish()?; zip.finish()?;

View File

@ -87,6 +87,7 @@ impl CoreManager {
service::stop_core_by_service().await?; service::stop_core_by_service().await?;
} }
*running = false; *running = false;
Ok(()) Ok(())
} }

View File

@ -1,14 +1,14 @@
use crate::{ use crate::{
cmds, cmds,
config::Config, config::Config,
core::CoreManager, feat, t,
feat, log_err, t,
utils::{ utils::{
dirs, dirs,
resolve::{self, VERSION}, resolve::{self, VERSION},
}, },
}; };
use anyhow::Result; use anyhow::Result;
use tauri::AppHandle;
use tauri::{ use tauri::{
menu::CheckMenuItem, menu::CheckMenuItem,
tray::{MouseButton, MouseButtonState, TrayIconEvent, TrayIconId}, tray::{MouseButton, MouseButtonState, TrayIconEvent, TrayIconId},
@ -17,7 +17,6 @@ use tauri::{
menu::{MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
Wry, Wry,
}; };
use tauri::{AppHandle, Manager};
use super::handle; use super::handle;
pub struct Tray {} pub struct Tray {}
@ -408,7 +407,7 @@ fn create_tray_menu(
Ok(menu) Ok(menu)
} }
fn on_menu_event(app_handle: &AppHandle, event: MenuEvent) { fn on_menu_event(_: &AppHandle, event: MenuEvent) {
match event.id.as_ref() { match event.id.as_ref() {
mode @ ("rule_mode" | "global_mode" | "direct_mode") => { mode @ ("rule_mode" | "global_mode" | "direct_mode") => {
let mode = &mode[0..mode.len() - 5]; let mode = &mode[0..mode.len() - 5];
@ -423,15 +422,7 @@ fn on_menu_event(app_handle: &AppHandle, event: MenuEvent) {
"open_core_dir" => crate::log_err!(cmds::open_core_dir()), "open_core_dir" => crate::log_err!(cmds::open_core_dir()),
"open_logs_dir" => crate::log_err!(cmds::open_logs_dir()), "open_logs_dir" => crate::log_err!(cmds::open_logs_dir()),
"restart_clash" => feat::restart_clash_core(), "restart_clash" => feat::restart_clash_core(),
"restart_app" => { "restart_app" => feat::restart_app(),
tauri::async_runtime::block_on(async move {
log_err!(CoreManager::global().stop_core().await);
});
resolve::resolve_reset();
//睡1秒再重启
std::thread::sleep(std::time::Duration::from_secs(1));
tauri::process::restart(&app_handle.env());
}
"quit" => { "quit" => {
println!("quit"); println!("quit");
feat::quit(Some(0)); feat::quit(Some(0));

View File

@ -7,10 +7,13 @@
use crate::config::*; use crate::config::*;
use crate::core::*; use crate::core::*;
use crate::log_err; use crate::log_err;
use crate::utils::dirs::app_home_dir;
use crate::utils::resolve; use crate::utils::resolve;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use reqwest_dav::list_cmd::ListFile; use reqwest_dav::list_cmd::ListFile;
use serde_yaml::{Mapping, Value}; use serde_yaml::{Mapping, Value};
use std::fs;
use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
// 打开面板 // 打开面板
@ -40,6 +43,18 @@ pub fn restart_clash_core() {
}); });
} }
pub fn restart_app() {
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
log_err!(CoreManager::global().stop_core().await);
});
resolve::resolve_reset();
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
tauri::process::restart(&app_handle.env());
});
}
// 切换模式 rule/global/direct/script mode // 切换模式 rule/global/direct/script mode
pub fn change_clash_mode(mode: String) { pub fn change_clash_mode(mode: String) {
let mut mapping = Mapping::new(); let mut mapping = Mapping::new();
@ -425,11 +440,37 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> {
} }
pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> { pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
backup::WebDavClient::global().list().await.map_err(|err| {
log::error!(target: "app", "Failed to list WebDAV backup files: {:#?}", err);
err
})
}
pub async fn delete_webdav_backup(filename: String) -> Result<()> {
backup::WebDavClient::global() backup::WebDavClient::global()
.list_files() .delete(filename)
.await .await
.map_err(|err| { .map_err(|err| {
log::error!(target: "app", "Failed to list WebDAV backup files: {:#?}", err); log::error!(target: "app", "Failed to delete WebDAV backup file: {:#?}", err);
err err
}) })
} }
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let backup_storage_path = app_home_dir().unwrap().join(&filename);
backup::WebDavClient::global()
.download(filename, backup_storage_path.clone())
.await
.map_err(|err| {
log::error!(target: "app", "Failed to download WebDAV backup file: {:#?}", err);
err
})?;
// extract zip file
let mut zip = zip::ZipArchive::new(fs::File::open(backup_storage_path.clone())?)?;
zip.extract(app_home_dir()?)?;
// 最后删除临时文件
fs::remove_file(backup_storage_path)?;
Ok(())
}

View File

@ -84,7 +84,8 @@ pub fn run() {
cmds::get_portable_flag, cmds::get_portable_flag,
cmds::get_network_interfaces, cmds::get_network_interfaces,
// cmds::kill_sidecar, // cmds::kill_sidecar,
cmds::restart_sidecar, cmds::restart_core,
cmds::restart_app,
// clash // clash
cmds::get_clash_info, cmds::get_clash_info,
cmds::get_clash_logs, cmds::get_clash_logs,
@ -128,6 +129,8 @@ pub fn run() {
cmds::create_webdav_backup, cmds::create_webdav_backup,
cmds::save_webdav_config, cmds::save_webdav_config,
cmds::list_webdav_backup, cmds::list_webdav_backup,
cmds::delete_webdav_backup,
cmds::restore_webdav_backup,
]); ]);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View File

@ -0,0 +1,220 @@
import { useState, useRef, memo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { Notice } from "@/components/base";
import { isValidUrl } from "@/utils/helper";
import { useLockFn } from "ahooks";
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from "@mui/material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
onSaveSuccess: () => Promise<void>;
onInit: () => Promise<void>;
setLoading: (loading: boolean) => void;
}
export const BackupConfigViewer = memo(
({
onBackupSuccess,
onSaveSuccess,
onInit,
setLoading,
}: BackupConfigViewerProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {};
const [showPassword, setShowPassword] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
});
const url = watch("url");
const username = watch("username");
const password = watch("password");
const webdavChanged =
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev);
};
useEffect(() => {
if (webdav_url && webdav_username && webdav_password) {
onInit();
}
}, []);
const checkForm = () => {
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
const url = urlRef.current?.value;
if (!url) {
urlRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("WebDAV URL Required"));
} else if (!isValidUrl(url)) {
urlRef.current?.focus();
Notice.error(t("Invalid WebDAV URL"));
throw new Error(t("Invalid WebDAV URL"));
}
if (!username) {
usernameRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("Username Required"));
}
if (!password) {
passwordRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("Password Required"));
}
};
const save = useLockFn(async (data: IWebDavConfig) => {
checkForm();
try {
setLoading(true);
await saveWebdavConfig(data.url, data.username, data.password).then(
() => {
Notice.success(t("WebDAV Config Saved"));
onSaveSuccess();
}
);
} catch (error) {
Notice.error(t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
});
const handleBackup = useLockFn(async () => {
checkForm();
try {
setLoading(true);
await createWebdavBackup().then(async () => {
await onBackupSuccess();
Notice.success(t("Backup Created"));
});
} catch (error) {
Notice.error(t("Backup Failed", { error }));
} finally {
setLoading(false);
}
});
return (
<form onSubmit={(e) => e.preventDefault()}>
<Grid container spacing={2}>
<Grid item xs={12} sm={9}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
variant="outlined"
size="small"
{...register("url")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={urlRef}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
variant="outlined"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={passwordRef}
{...register("password")}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
</Grid>
<Grid item xs={12} sm={3}>
<Stack
direction="column"
justifyContent="center"
alignItems="stretch"
sx={{ height: "100%" }}
>
{webdavChanged ||
webdav_url === null ||
webdav_username == null ||
webdav_password == null ? (
<Button
variant="contained"
color={"primary"}
sx={{ height: "100%" }}
type="button"
onClick={handleSubmit(save)}
>
{t("Save")}
</Button>
) : (
<Button
variant="contained"
color="success"
sx={{ height: "100%" }}
onClick={handleBackup}
type="button"
>
{t("Backup")}
</Button>
)}
</Stack>
</Grid>
</Grid>
</form>
);
}
);

View File

@ -0,0 +1,266 @@
import { SVGProps, memo } from "react";
import {
Box,
Paper,
IconButton,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
} from "@mui/material";
import { Notice } from "@/components/base";
import { Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Dayjs } from "dayjs";
import {
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
export type BackupFile = IWebDavFile & {
platform: string;
backup_time: Dayjs;
allow_apply: boolean;
};
export const DEFAULT_ROWS_PER_PAGE = 5;
export interface BackupTableViewerProps {
datasource: BackupFile[];
page: number;
onPageChange: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number
) => void;
total: number;
onRefresh: () => Promise<void>;
}
export const BackupTableViewer = memo(
({
datasource,
page,
onPageChange,
total,
onRefresh,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
Notice.success(t("Restore Success, App will restart in 1s"));
});
await restartApp();
});
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Filename")}</TableCell>
<TableCell>{t("Backup Time")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to delete this backup file?")
);
if (confirmed) {
await handleDelete(file.filename);
}
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to restore this backup file?")
);
if (confirmed) {
await handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} align="center">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 150,
}}
>
<Typography
variant="body1"
color="textSecondary"
align="center"
>
{t("No Backups")}
</Typography>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[]}
component="div"
count={total}
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
page={page}
onPageChange={onPageChange}
labelRowsPerPage={t("Rows per page")}
/>
</TableContainer>
);
}
);
function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 48 48"
{...props}
>
<path
fill="#ECEFF1"
d="m20.1 16.2l.1 2.3l-1.6 3l-2.5 4.9l-.5 4.1l1.8 5.8l4.1 2.3h6.2l5.8-4.4l2.6-6.9l-6-7.3l-1.7-4.1z"
/>
<path
fill="#263238"
d="M34.3 21.9c-1.6-2.3-2.9-3.7-3.6-6.6s.2-2.1-.4-4.6c-.3-1.3-.8-2.2-1.3-2.9c-.6-.7-1.3-1.1-1.7-1.2c-.9-.5-3-1.3-5.6.1c-2.7 1.4-2.4 4.4-1.9 10.5c0 .4-.1.9-.3 1.3c-.4.9-1.1 1.7-1.7 2.4c-.7 1-1.4 2-1.9 3.1c-1.2 2.3-2.3 5.2-2 6.3c.5-.1 6.8 9.5 6.8 9.7c.4-.1 2.1-.1 3.6-.1c2.1-.1 3.3-.2 5 .2c0-.3-.1-.6-.1-.9c0-.6.1-1.1.2-1.8c.1-.5.2-1 .3-1.6c-1 .9-2.8 1.9-4.5 2.2c-1.5.3-4-.2-5.2-1.7c.1 0 .3 0 .4-.1c.3-.1.6-.2.7-.4c.3-.5.1-1-.1-1.3s-1.7-1.4-2.4-2s-1.1-.9-1.5-1.3l-.8-.8c-.2-.2-.3-.4-.4-.5c-.2-.5-.3-1.1-.2-1.9c.1-1.1.5-2 1-3c.2-.4.7-1.2.7-1.2s-1.7 4.2-.8 5.5c0 0 .1-1.3.5-2.6c.3-.9.8-2.2 1.4-2.9s2.1-3.3 2.2-4.9c0-.7.1-1.4.1-1.9c-.4-.4 6.6-1.4 7-.3c.1.4 1.5 4 2.3 5.9c.4.9.9 1.7 1.2 2.7c.3 1.1.5 2.6.5 4.1c0 .3 0 .8-.1 1.3c.2 0 4.1-4.2-.5-7.7c0 0 2.8 1.3 2.9 3.9c.1 2.1-.8 3.8-1 4.1c.1 0 2.1.9 2.2.9c.4 0 1.2-.3 1.2-.3c.1-.3.4-1.1.4-1.4c.7-2.3-1-6-2.6-8.3"
/>
<g fill="#ECEFF1" transform="translate(0 -2)">
<ellipse cx="21.6" cy="15.3" rx="1.3" ry="2" />
<ellipse cx="26.1" cy="15.2" rx="1.7" ry="2.3" />
</g>
<g fill="#212121" transform="translate(0 -2)">
<ellipse
cx="21.7"
cy="15.5"
rx="1.2"
ry=".7"
transform="rotate(-97.204 21.677 15.542)"
/>
<ellipse cx="26" cy="15.6" rx="1" ry="1.3" />
</g>
<path
fill="#FFC107"
d="M39.3 35.6c-.4-.2-1.1-.5-1.7-1.4c-.3-.5-.2-1.9-.7-2.5c-.3-.4-.7-.2-.8-.2c-.9.2-3 1.6-4.4 0c-.2-.2-.5-.5-1-.5s-.7.2-.9.6s-.2.7-.2 1.7c0 .8 0 1.7-.1 2.4c-.2 1.7-.5 2.7-.5 3.7c0 1.1.3 1.8.7 2.1c.3.3.8.5 1.9.5s1.8-.4 2.5-1.1c.5-.5.9-.7 2.3-1.7c1.1-.7 2.8-1.6 3.1-1.9c.2-.2.5-.3.5-.9c0-.5-.4-.7-.7-.8m-20.1.3c-1-1.6-1.1-1.9-1.8-2.9c-.6-1-1.9-2.9-2.7-2.9c-.6 0-.9.3-1.3.7s-.8 1.3-1.5 1.8c-.6.5-2.3.4-2.7 1s.4 1.5.4 3c0 .6-.5 1-.6 1.4c-.1.5-.2.8 0 1.2c.4.6.9.8 4.3 1.5c1.8.4 3.5 1.4 4.6 1.5s3 0 3-2.7c.1-1.6-.8-2-1.7-3.6m1.9-18.1c-.6-.4-1.1-.8-1.1-1.4s.4-.8 1-1.3c.1-.1 1.2-1.1 2.3-1.1s2.4.7 2.9.9c.9.2 1.8.4 1.7 1.1c-.1 1-.2 1.2-1.2 1.7c-.7.2-2 1.3-2.9 1.3c-.4 0-1 0-1.4-.1c-.3-.1-.8-.6-1.3-1.1"
/>
<path
fill="#634703"
d="M20.9 17c.2.2.5.4.8.5c.2.1.5.2.5.2h.9c.5 0 1.2-.2 1.9-.6c.7-.3.8-.5 1.3-.7c.5-.3 1-.6.8-.7s-.4 0-1.1.4c-.6.4-1.1.6-1.7.9c-.3.1-.7.3-1 .3h-.9c-.3 0-.5-.1-.8-.2c-.2-.1-.3-.2-.4-.2c-.2-.1-.6-.5-.8-.6c0 0-.2 0-.1.1zm3-2.2c.1.2.3.2.4.3s.2.1.2.1c.1-.1 0-.3-.1-.3c0-.2-.5-.2-.5-.1m-1.6.2c0 .1.2.2.2.1c.1-.1.2-.2.3-.2c.2-.1.1-.2-.2-.2c-.2.1-.2.2-.3.3"
/>
<path
fill="#455A64"
d="M32 32.7v.3c.2.4.7.5 1.1.5c.6 0 1.2-.4 1.5-.8c0-.1.1-.2.2-.3c.2-.3.3-.5.4-.6c0 0-.1-.1-.1-.2c-.1-.2-.4-.4-.8-.5c-.3-.1-.8-.2-1-.2c-.9-.1-1.4.2-1.7.5c0 0 .1 0 .1.1c.2.2.3.4.3.7c.1.2 0 .3 0 .5"
/>
</svg>
);
}
function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 16 16"
{...props}
>
<path
fill="#0284c7"
d="M6.555 1.375L0 2.237v5.45h6.555zM0 13.795l6.555.933V8.313H0zm7.278-5.4l.026 6.378L16 16V8.395zM16 0L7.33 1.244v6.414H16z"
/>
</svg>
);
}
function MacIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 26 26"
{...props}
>
<path
fill="#000"
d="M23.934 18.947c-.598 1.324-.884 1.916-1.652 3.086c-1.073 1.634-2.588 3.673-4.461 3.687c-1.666.014-2.096-1.087-4.357-1.069c-2.261.011-2.732 1.089-4.4 1.072c-1.873-.017-3.307-1.854-4.381-3.485c-3.003-4.575-3.32-9.937-1.464-12.79C4.532 7.425 6.61 6.237 8.561 6.237c1.987 0 3.236 1.092 4.879 1.092c1.594 0 2.565-1.095 4.863-1.095c1.738 0 3.576.947 4.889 2.581c-4.296 2.354-3.598 8.49.742 10.132M16.559 4.408c.836-1.073 1.47-2.587 1.24-4.131c-1.364.093-2.959.964-3.891 2.092c-.844 1.027-1.544 2.553-1.271 4.029c1.488.048 3.028-.839 3.922-1.99"
/>
</svg>
);
}

View File

@ -2,87 +2,25 @@ import {
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
useState, useState,
useRef,
SVGProps,
useCallback, useCallback,
useMemo, useEffect,
memo,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { BaseDialog, DialogRef } from "@/components/base";
import { Typography } from "@mui/material";
import { useForm, UseFormRegister } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { isValidUrl } from "@/utils/helper";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from "@/components/base"; import { BaseLoadingOverlay } from "@/components/base";
import dayjs, { Dayjs } from "dayjs"; import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import {
BackupTableViewer,
BackupFile,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { BackupConfigViewer } from "./backup-config-viewer";
import { Box, Paper, Divider } from "@mui/material";
import { listWebDavBackup } from "@/services/cmds";
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
import {
TextField,
Button,
Grid,
Box,
Paper,
Stack,
IconButton,
InputAdornment,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
} from "@mui/material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
import {
createWebdavBackup,
listWebDavBackup,
saveWebdavConfig,
} from "@/services/cmds";
type BackupFile = IWebDavFile & {
platform: string;
backup_time: Dayjs;
allow_apply: boolean;
};
interface BackupTableProps {
datasource: BackupFile[];
page: number;
rowsPerPage: number;
onPageChange: (event: any, newPage: number) => void;
onRowsPerPageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
totalCount: number;
}
interface WebDAVConfigFormProps {
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
initialValues: Partial<IWebDavConfig>;
urlRef: React.RefObject<HTMLInputElement>;
usernameRef: React.RefObject<HTMLInputElement>;
passwordRef: React.RefObject<HTMLInputElement>;
showPassword: boolean;
onShowPasswordClick: () => void;
webdavChanged: boolean;
webdavUrl: string | undefined | null;
webdavUsername: string | undefined | null;
webdavPassword: string | undefined | null;
handleBackup: () => Promise<void>;
register: UseFormRegister<IWebDavConfig>;
}
// 将魔法数字和配置提取为常量
const DEFAULT_ROWS_PER_PAGE = 5;
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
@ -90,43 +28,17 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { verge, mutateVerge } = useVerge(); const [isLoading, setIsLoading] = useState(false);
const { webdav_url, webdav_username, webdav_password } = verge || {}; const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [showPassword, setShowPassword] = useState(false); const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const usernameRef = useRef<HTMLInputElement>(null); const [total, setTotal] = useState(0);
const passwordRef = useRef<HTMLInputElement>(null); const [page, setPage] = useState(0);
const [backupState, setBackupState] = useState({
files: [] as BackupFile[],
page: 0,
rowsPerPage: DEFAULT_ROWS_PER_PAGE,
isLoading: false,
});
const OS = getSystem(); const OS = getSystem();
const urlRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
});
const url = watch("url");
const username = watch("username");
const password = watch("password");
const webdavChanged =
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setOpen(true); setOpen(true);
if (webdav_url && webdav_username && webdav_password) {
fetchAndSetBackupFiles();
}
}, },
close: () => setOpen(false), close: () => setOpen(false),
})); }));
@ -134,111 +46,37 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
// Handle page change // Handle page change
const handleChangePage = useCallback( const handleChangePage = useCallback(
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => { (_: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
setBackupState((prev) => ({ ...prev, page })); setPage(page);
},
[]
);
// Handle rows per page change
const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setBackupState((prev) => ({
...prev,
rowsPerPage: parseInt(event.target.value, 10),
page: 0,
}));
}, },
[] []
); );
const fetchAndSetBackupFiles = async () => { const fetchAndSetBackupFiles = async () => {
try { try {
setBackupState((prev) => ({ ...prev, isLoading: true })); setIsLoading(true);
const files = await getAllBackupFiles(); const files = await getAllBackupFiles();
setBackupState((prev) => ({ ...prev, files })); setBackupFiles(files);
setTotal(files.length);
} catch (error) { } catch (error) {
console.error("Failed to fetch backup files:", error); console.error(error);
Notice.error(t("Failed to fetch backup files")); // Notice.error(t("Failed to fetch backup files"));
} finally { } finally {
setBackupState((prev) => ({ ...prev, isLoading: false })); setIsLoading(false);
} }
}; };
const checkForm = () => {
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
const url = urlRef.current?.value;
if (!url) {
Notice.error(t("WebDAV URL Required"));
urlRef.current?.focus();
return;
} else if (!isValidUrl(url)) {
Notice.error(t("Invalid WebDAV URL"));
urlRef.current?.focus();
return;
}
if (!username) {
Notice.error(t("Username Required"));
usernameRef.current?.focus();
return;
}
if (!password) {
Notice.error(t("Password Required"));
passwordRef.current?.focus();
return;
}
};
const submit = async (data: IWebDavConfig) => {
checkForm();
setBackupState((prev) => ({ ...prev, isLoading: true }));
await saveWebdavConfig(data.url, data.username, data.password)
.then(() => {
mutateVerge(
{
webdav_url: data.url,
webdav_username: data.username,
webdav_password: data.password,
},
false
);
Notice.success(t("WebDAV Config Saved"));
})
.catch((e) => {
Notice.error(t("WebDAV Config Save Failed", { error: e }), 3000);
})
.finally(() => {
setBackupState((prev) => ({ ...prev, isLoading: false }));
fetchAndSetBackupFiles();
});
};
const handleClickShowPassword = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
const handleBackup = useLockFn(async () => {
try {
checkForm();
setBackupState((prev) => ({ ...prev, isLoading: true }));
await createWebdavBackup();
Notice.success(t("Backup Created"));
await fetchAndSetBackupFiles();
} catch (error) {
console.error("Backup failed:", error);
Notice.error(t("Backup Failed", { error }));
} finally {
setBackupState((prev) => ({ ...prev, isLoading: false }));
}
});
const getAllBackupFiles = async () => { const getAllBackupFiles = async () => {
const files = await listWebDavBackup(); const files = await listWebDavBackup();
return files return files
.map((file) => { .map((file) => {
const platform = file.filename.split("-")[0]; const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!; const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
console.log(file, fileBackupTimeStr);
if (fileBackupTimeStr === null) {
return null;
}
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT); const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = OS === platform; const allowApply = OS === platform;
return { return {
@ -248,365 +86,55 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
allow_apply: allowApply, allow_apply: allowApply,
} as BackupFile; } as BackupFile;
}) })
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
}; };
const datasource = useMemo(() => { useEffect(() => {
return backupState.files.slice( setDataSource(
backupState.page * backupState.rowsPerPage, backupFiles.slice(
backupState.page * backupState.rowsPerPage + backupState.rowsPerPage page * DEFAULT_ROWS_PER_PAGE,
page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE
)
); );
}, [backupState.files, backupState.page, backupState.rowsPerPage]); }, [page, backupFiles]);
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSubmit(submit)(e);
};
return ( return (
<BaseDialog <BaseDialog
open={open} open={open}
title={t("Backup Setting")} title={t("Backup Setting")}
contentSx={{ width: 600, maxHeight: 800 }} contentSx={{ width: 600, maxHeight: 800 }}
okBtn={t("Save")} okBtn={t("")}
cancelBtn={t("Cancel")} cancelBtn={t("Close")}
disableFooter={true}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
disableOk
> >
<Box sx={{ maxWidth: 800 }}> <Box sx={{ maxWidth: 800 }}>
<BaseLoadingOverlay isLoading={backupState.isLoading} /> <BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}> <Paper elevation={2} sx={{ padding: 2 }}>
<WebDAVConfigForm <BackupConfigViewer
onSubmit={onFormSubmit} setLoading={setIsLoading}
initialValues={{ onBackupSuccess={async () => {
url: webdav_url, fetchAndSetBackupFiles();
username: webdav_username, }}
password: webdav_password, onSaveSuccess={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}} }}
urlRef={urlRef}
usernameRef={usernameRef}
passwordRef={passwordRef}
showPassword={showPassword}
onShowPasswordClick={handleClickShowPassword}
webdavChanged={webdavChanged}
webdavUrl={webdav_url}
webdavUsername={webdav_username}
webdavPassword={webdav_password}
handleBackup={handleBackup}
register={register}
/> />
<Divider sx={{ marginY: 2 }} /> <Divider sx={{ marginY: 2 }} />
<BackupTable <BackupTableViewer
datasource={datasource} datasource={dataSource}
page={backupState.page} page={page}
rowsPerPage={backupState.rowsPerPage} onPageChange={handleChangePage}
onPageChange={(_, page) => handleChangePage(null, page)} total={total}
onRowsPerPageChange={handleChangeRowsPerPage} onRefresh={fetchAndSetBackupFiles}
totalCount={backupState.files.length}
/> />
</Paper> </Paper>
</Box> </Box>
</BaseDialog> </BaseDialog>
); );
}); });
const BackupTable = memo(
({
datasource,
page,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
totalCount,
}: BackupTableProps) => {
const { t } = useTranslation();
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Filename")}</TableCell>
<TableCell>{t("Backup Time")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} align="center">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 150,
}}
>
<Typography
variant="body1"
color="textSecondary"
align="center"
>
{t("No Backups")}
</Typography>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[]}
component="div"
count={totalCount}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
labelRowsPerPage={t("Rows per page")}
/>
</TableContainer>
);
}
);
const WebDAVConfigForm = memo(
({
onSubmit,
initialValues,
urlRef,
usernameRef,
passwordRef,
showPassword,
onShowPasswordClick,
webdavChanged,
webdavUrl,
webdavUsername,
webdavPassword,
handleBackup,
register,
}: WebDAVConfigFormProps) => {
const { t } = useTranslation();
return (
<form onSubmit={onSubmit}>
<Grid container spacing={2}>
<Grid item xs={12} sm={9}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
variant="outlined"
size="small"
{...register("url")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={urlRef}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
variant="outlined"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={passwordRef}
{...register("password")}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={onShowPasswordClick} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
</Grid>
<Grid item xs={12} sm={3}>
<Stack
direction="column"
justifyContent="center"
alignItems="stretch"
sx={{ height: "100%" }}
>
{webdavChanged ||
webdavUrl === null ||
webdavUsername == null ||
webdavPassword == null ? (
<Button
variant="contained"
color="primary"
sx={{ height: "100%" }}
type="submit"
>
{t("Save")}
</Button>
) : (
<Button
variant="contained"
color="success"
sx={{ height: "100%" }}
onClick={handleBackup}
type="button"
>
{t("Backup")}
</Button>
)}
</Stack>
</Grid>
</Grid>
</form>
);
}
);
export function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 48 48"
{...props}
>
<path
fill="#ECEFF1"
d="m20.1 16.2l.1 2.3l-1.6 3l-2.5 4.9l-.5 4.1l1.8 5.8l4.1 2.3h6.2l5.8-4.4l2.6-6.9l-6-7.3l-1.7-4.1z"
/>
<path
fill="#263238"
d="M34.3 21.9c-1.6-2.3-2.9-3.7-3.6-6.6s.2-2.1-.4-4.6c-.3-1.3-.8-2.2-1.3-2.9c-.6-.7-1.3-1.1-1.7-1.2c-.9-.5-3-1.3-5.6.1c-2.7 1.4-2.4 4.4-1.9 10.5c0 .4-.1.9-.3 1.3c-.4.9-1.1 1.7-1.7 2.4c-.7 1-1.4 2-1.9 3.1c-1.2 2.3-2.3 5.2-2 6.3c.5-.1 6.8 9.5 6.8 9.7c.4-.1 2.1-.1 3.6-.1c2.1-.1 3.3-.2 5 .2c0-.3-.1-.6-.1-.9c0-.6.1-1.1.2-1.8c.1-.5.2-1 .3-1.6c-1 .9-2.8 1.9-4.5 2.2c-1.5.3-4-.2-5.2-1.7c.1 0 .3 0 .4-.1c.3-.1.6-.2.7-.4c.3-.5.1-1-.1-1.3s-1.7-1.4-2.4-2s-1.1-.9-1.5-1.3l-.8-.8c-.2-.2-.3-.4-.4-.5c-.2-.5-.3-1.1-.2-1.9c.1-1.1.5-2 1-3c.2-.4.7-1.2.7-1.2s-1.7 4.2-.8 5.5c0 0 .1-1.3.5-2.6c.3-.9.8-2.2 1.4-2.9s2.1-3.3 2.2-4.9c0-.7.1-1.4.1-1.9c-.4-.4 6.6-1.4 7-.3c.1.4 1.5 4 2.3 5.9c.4.9.9 1.7 1.2 2.7c.3 1.1.5 2.6.5 4.1c0 .3 0 .8-.1 1.3c.2 0 4.1-4.2-.5-7.7c0 0 2.8 1.3 2.9 3.9c.1 2.1-.8 3.8-1 4.1c.1 0 2.1.9 2.2.9c.4 0 1.2-.3 1.2-.3c.1-.3.4-1.1.4-1.4c.7-2.3-1-6-2.6-8.3"
/>
<g fill="#ECEFF1" transform="translate(0 -2)">
<ellipse cx="21.6" cy="15.3" rx="1.3" ry="2" />
<ellipse cx="26.1" cy="15.2" rx="1.7" ry="2.3" />
</g>
<g fill="#212121" transform="translate(0 -2)">
<ellipse
cx="21.7"
cy="15.5"
rx="1.2"
ry=".7"
transform="rotate(-97.204 21.677 15.542)"
/>
<ellipse cx="26" cy="15.6" rx="1" ry="1.3" />
</g>
<path
fill="#FFC107"
d="M39.3 35.6c-.4-.2-1.1-.5-1.7-1.4c-.3-.5-.2-1.9-.7-2.5c-.3-.4-.7-.2-.8-.2c-.9.2-3 1.6-4.4 0c-.2-.2-.5-.5-1-.5s-.7.2-.9.6s-.2.7-.2 1.7c0 .8 0 1.7-.1 2.4c-.2 1.7-.5 2.7-.5 3.7c0 1.1.3 1.8.7 2.1c.3.3.8.5 1.9.5s1.8-.4 2.5-1.1c.5-.5.9-.7 2.3-1.7c1.1-.7 2.8-1.6 3.1-1.9c.2-.2.5-.3.5-.9c0-.5-.4-.7-.7-.8m-20.1.3c-1-1.6-1.1-1.9-1.8-2.9c-.6-1-1.9-2.9-2.7-2.9c-.6 0-.9.3-1.3.7s-.8 1.3-1.5 1.8c-.6.5-2.3.4-2.7 1s.4 1.5.4 3c0 .6-.5 1-.6 1.4c-.1.5-.2.8 0 1.2c.4.6.9.8 4.3 1.5c1.8.4 3.5 1.4 4.6 1.5s3 0 3-2.7c.1-1.6-.8-2-1.7-3.6m1.9-18.1c-.6-.4-1.1-.8-1.1-1.4s.4-.8 1-1.3c.1-.1 1.2-1.1 2.3-1.1s2.4.7 2.9.9c.9.2 1.8.4 1.7 1.1c-.1 1-.2 1.2-1.2 1.7c-.7.2-2 1.3-2.9 1.3c-.4 0-1 0-1.4-.1c-.3-.1-.8-.6-1.3-1.1"
/>
<path
fill="#634703"
d="M20.9 17c.2.2.5.4.8.5c.2.1.5.2.5.2h.9c.5 0 1.2-.2 1.9-.6c.7-.3.8-.5 1.3-.7c.5-.3 1-.6.8-.7s-.4 0-1.1.4c-.6.4-1.1.6-1.7.9c-.3.1-.7.3-1 .3h-.9c-.3 0-.5-.1-.8-.2c-.2-.1-.3-.2-.4-.2c-.2-.1-.6-.5-.8-.6c0 0-.2 0-.1.1zm3-2.2c.1.2.3.2.4.3s.2.1.2.1c.1-.1 0-.3-.1-.3c0-.2-.5-.2-.5-.1m-1.6.2c0 .1.2.2.2.1c.1-.1.2-.2.3-.2c.2-.1.1-.2-.2-.2c-.2.1-.2.2-.3.3"
/>
<path
fill="#455A64"
d="M32 32.7v.3c.2.4.7.5 1.1.5c.6 0 1.2-.4 1.5-.8c0-.1.1-.2.2-.3c.2-.3.3-.5.4-.6c0 0-.1-.1-.1-.2c-.1-.2-.4-.4-.8-.5c-.3-.1-.8-.2-1-.2c-.9-.1-1.4.2-1.7.5c0 0 .1 0 .1.1c.2.2.3.4.3.7c.1.2 0 .3 0 .5"
/>
</svg>
);
}
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 16 16"
{...props}
>
<path
fill="#0284c7"
d="M6.555 1.375L0 2.237v5.45h6.555zM0 13.795l6.555.933V8.313H0zm7.278-5.4l.026 6.378L16 16V8.395zM16 0L7.33 1.244v6.414H16z"
/>
</svg>
);
}
export function MacIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 26 26"
{...props}
>
<path
fill="#000"
d="M23.934 18.947c-.598 1.324-.884 1.916-1.652 3.086c-1.073 1.634-2.588 3.673-4.461 3.687c-1.666.014-2.096-1.087-4.357-1.069c-2.261.011-2.732 1.089-4.4 1.072c-1.873-.017-3.307-1.854-4.381-3.485c-3.003-4.575-3.32-9.937-1.464-12.79C4.532 7.425 6.61 6.237 8.561 6.237c1.987 0 3.236 1.092 4.879 1.092c1.594 0 2.565-1.095 4.863-1.095c1.738 0 3.576.947 4.889 2.581c-4.296 2.354-3.598 8.49.742 10.132M16.559 4.408c.836-1.073 1.47-2.587 1.24-4.131c-1.364.093-2.959.964-3.891 2.092c-.844 1.027-1.544 2.553-1.271 4.029c1.488.048 3.028-.839 3.922-1.99"
/>
</svg>
);
}

View File

@ -17,7 +17,7 @@ import {
ListItemButton, ListItemButton,
ListItemText, ListItemText,
} from "@mui/material"; } from "@mui/material";
import { changeClashCore, restartSidecar } from "@/services/cmds"; import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api"; import { closeAllConnections, upgradeCore } from "@/services/api";
const VALID_CORE = [ const VALID_CORE = [
@ -59,7 +59,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const onRestart = useLockFn(async () => { const onRestart = useLockFn(async () => {
try { try {
await restartSidecar(); await restartCore();
Notice.success(t(`Clash Core Restarted`), 1000); Notice.success(t(`Clash Core Restarted`), 1000);
} catch (err: any) { } catch (err: any) {
Notice.error(err?.message || err.toString()); Notice.error(err?.message || err.toString());

View File

@ -386,10 +386,14 @@
"Password Required": "Password cannot be empty", "Password Required": "Password cannot be empty",
"Failed to Fetch Backups": "Failed to fetch backup files", "Failed to Fetch Backups": "Failed to fetch backup files",
"WebDAV Config Saved": "WebDAV configuration saved successfully", "WebDAV Config Saved": "WebDAV configuration saved successfully",
"WebDAV Config Save Failed": "Failed to save WebDAV configuration", "WebDAV Config Save Failed": "Failed to save WebDAV configuration: {{error}}",
"Backup Created": "Backup created successfully", "Backup Created": "Backup created successfully",
"Backup Failed": "Failed to create backup", "Backup Failed": "Backup failed: {{error}}",
"Delete Backup": "Delete Backup", "Delete Backup": "Delete Backup",
"Restore Backup": "Restore Backup", "Restore Backup": "Restore Backup",
"Backup Time": "Backup Time" "Backup Time": "Backup Time",
"Confirm to delete this backup file?": "Confirm to delete this backup file?",
"Confirm to restore this backup file?": "Confirm to restore this backup file?",
"Restore Success, App will restart in 1s": "Restore Success, App will restart in 1s",
"Failed to fetch backup files": "Failed to fetch backup files"
} }

View File

@ -386,10 +386,14 @@
"Password Required": "رمز عبور نمی‌تواند خالی باشد", "Password Required": "رمز عبور نمی‌تواند خالی باشد",
"Failed to Fetch Backups": "دریافت فایل‌های پشتیبان ناموفق بود", "Failed to Fetch Backups": "دریافت فایل‌های پشتیبان ناموفق بود",
"WebDAV Config Saved": "پیکربندی WebDAV با موفقیت ذخیره شد", "WebDAV Config Saved": "پیکربندی WebDAV با موفقیت ذخیره شد",
"WebDAV Config Save Failed": "ذخیره پیکربندی WebDAV ناموفق بود", "WebDAV Config Save Failed": "خطا در ذخیره تنظیمات WebDAV: {{error}}",
"Backup Created": "پشتیبان‌گیری با موفقیت ایجاد شد", "Backup Created": "پشتیبان‌گیری با موفقیت ایجاد شد",
"Backup Failed": "ایجاد پشتیبان ناموفق بود", "Backup Failed": "خطا در پشتیبان‌گیری: {{error}}",
"Delete Backup": "حذف پشتیبان", "Delete Backup": "حذف پشتیبان",
"Restore Backup": "بازیابی پشتیبان", "Restore Backup": "بازیابی پشتیبان",
"Backup Time": "زمان پشتیبان‌گیری" "Backup Time": "زمان پشتیبان‌گیری",
"Confirm to delete this backup file?": "آیا از حذف این فایل پشتیبان اطمینان دارید؟",
"Confirm to restore this backup file?": "آیا از بازیابی این فایل پشتیبان اطمینان دارید؟",
"Restore Success, App will restart in 1s": "بازیابی با موفقیت انجام شد، برنامه در 1 ثانیه راه‌اندازی مجدد می‌شود",
"Failed to fetch backup files": "دریافت فایل‌های پشتیبان ناموفق بود"
} }

View File

@ -386,10 +386,14 @@
"Password Required": "Пароль не может быть пустым", "Password Required": "Пароль не может быть пустым",
"Failed to Fetch Backups": "Не удалось получить файлы резервных копий", "Failed to Fetch Backups": "Не удалось получить файлы резервных копий",
"WebDAV Config Saved": "Конфигурация WebDAV успешно сохранена", "WebDAV Config Saved": "Конфигурация WebDAV успешно сохранена",
"WebDAV Config Save Failed": "Не удалось сохранить конфигурацию WebDAV", "WebDAV Config Save Failed": "Не удалось сохранить конфигурацию WebDAV: {{error}}",
"Backup Created": "Резервная копия успешно создана", "Backup Created": "Резервная копия успешно создана",
"Backup Failed": "Не удалось создать резервную копию", "Backup Failed": "Ошибка резервного копирования: {{error}}",
"Delete Backup": "Удалить резервную копию", "Delete Backup": "Удалить резервную копию",
"Restore Backup": "Восстановить резервную копию", "Restore Backup": "Восстановить резервную копию",
"Backup Time": "Время резервного копирования" "Backup Time": "Время резервного копирования",
"Confirm to delete this backup file?": "Вы уверены, что хотите удалить этот файл резервной копии?",
"Confirm to restore this backup file?": "Вы уверены, что хотите восстановить этот файл резервной копии?",
"Restore Success, App will restart in 1s": "Восстановление успешно выполнено, приложение перезапустится через 1 секунду",
"Failed to fetch backup files": "Не удалось получить файлы резервных копий"
} }

View File

@ -380,16 +380,20 @@
"Actions": "操作", "Actions": "操作",
"Restore": "恢复", "Restore": "恢复",
"No Backups": "暂无备份", "No Backups": "暂无备份",
"WebDAV URL Required": "WebDAV 地址不能为空", "WebDAV URL Required": "WebDAV 服务器地址不能为空",
"Invalid WebDAV URL": "无效的 WebDAV 地址格式", "Invalid WebDAV URL": "无效的 WebDAV 服务器地址格式",
"Username Required": "用户名不能为空", "Username Required": "用户名不能为空",
"Password Required": "密码不能为空", "Password Required": "密码不能为空",
"Failed to Fetch Backups": "获取备份文件失败", "Failed to Fetch Backups": "获取备份文件失败",
"WebDAV Config Saved": "WebDAV 配置保存成功", "WebDAV Config Saved": "WebDAV 配置保存成功",
"WebDAV Config Save Failed": "WebDAV 配置保存失败", "WebDAV Config Save Failed": "保存 WebDAV 配置失败: {{error}}",
"Backup Created": "备份创建成功", "Backup Created": "备份创建成功",
"Backup Failed": "备份创建失败", "Backup Failed": "备份失败: {{error}}",
"Delete Backup": "删除备份", "Delete Backup": "删除备份",
"Restore Backup": "恢复备份", "Restore Backup": "恢复备份",
"Backup Time": "备份时间" "Backup Time": "备份时间",
"Confirm to delete this backup file?": "确认删除此备份文件吗?",
"Confirm to restore this backup file?": "确认恢复此 份文件吗?",
"Restore Success, App will restart in 1s": "恢复成功应用将在1秒后重启",
"Failed to fetch backup files": "获取备份文件失败"
} }

View File

@ -141,8 +141,12 @@ export async function changeClashCore(clashCore: string) {
return invoke<any>("change_clash_core", { clashCore }); return invoke<any>("change_clash_core", { clashCore });
} }
export async function restartSidecar() { export async function restartCore() {
return invoke<void>("restart_sidecar"); return invoke<void>("restart_core");
}
export async function restartApp() {
return invoke<void>("restart_app");
} }
export async function getAppDir() { export async function getAppDir() {
@ -240,6 +244,15 @@ export async function getNetworkInterfacesInfo() {
export async function createWebdavBackup() { export async function createWebdavBackup() {
return invoke<void>("create_webdav_backup"); return invoke<void>("create_webdav_backup");
} }
export async function deleteWebdavBackup(filename: string) {
return invoke<void>("delete_webdav_backup", { filename });
}
export async function restoreWebDavBackup(filename: string) {
return invoke<void>("restore_webdav_backup", { filename });
}
export async function saveWebdavConfig( export async function saveWebdavConfig(
url: string, url: string,
username: string, username: string,