feat: add mainland-reachable desktop distribution

This commit is contained in:
2026-08-16 23:00:54 +08:00
parent e8eeb4ac72
commit a3e1f11e85
42 changed files with 3661 additions and 191 deletions

View File

@@ -1,6 +1,9 @@
mod shell_updater;
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
@@ -38,7 +41,9 @@ pub struct Args {
impl Args {
pub fn parse() -> Self {
let mut args = Args {
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()),
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE)
.ok()
.filter(|s| !s.is_empty()),
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
.ok()
.filter(|s| !s.is_empty())
@@ -91,6 +96,7 @@ struct AppState {
paths: BundledPaths,
process: Mutex<Option<Arc<DshProcess>>>,
url: Mutex<Option<String>>,
initial_boot_started: AtomicBool,
}
impl AppState {
@@ -123,6 +129,41 @@ struct ReadyPayload {
fn restart(app: AppHandle) {
thread::spawn(move || boot(app));
}
fn start_initial_boot(app: AppHandle) {
let Some(state) = app.try_state::<AppState>() else {
return;
};
if state.initial_boot_started.swap(true, Ordering::AcqRel) {
return;
}
thread::spawn(move || boot(app));
}
#[tauri::command]
async fn check_shell_update(app: AppHandle) -> Option<shell_updater::UpdateInfo> {
match shell_updater::check(&app).await {
Ok(Some(update)) => Some(update),
Ok(None) => {
start_initial_boot(app);
None
}
Err(error) => {
log::warn!("shell update check failed: {error}");
start_initial_boot(app);
None
}
}
}
#[tauri::command]
async fn install_shell_update(app: AppHandle) -> Result<(), String> {
shell_updater::install(&app).await
}
#[tauri::command]
fn skip_shell_update(app: AppHandle) {
start_initial_boot(app);
}
#[tauri::command]
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
@@ -204,9 +245,18 @@ fn read_cli_image() -> Option<ClipboardFile> {
("wl-paste", &["--type", "image/png"]),
("wl-paste", &["--type", "image/jpeg"]),
("wl-paste", &["--type", "image/webp"]),
("xclip", &["-selection", "clipboard", "-t", "image/png", "-o"]),
("xclip", &["-selection", "clipboard", "-t", "image/jpeg", "-o"]),
("xclip", &["-selection", "clipboard", "-t", "image/webp", "-o"]),
(
"xclip",
&["-selection", "clipboard", "-t", "image/png", "-o"],
),
(
"xclip",
&["-selection", "clipboard", "-t", "image/jpeg", "-o"],
),
(
"xclip",
&["-selection", "clipboard", "-t", "image/webp", "-o"],
),
];
for (bin, args) in ATTEMPTS {
let output = match Command::new(bin).args(*args).output() {
@@ -225,6 +275,33 @@ fn read_cli_image() -> Option<ClipboardFile> {
None
}
fn enable_microphone(window: &tauri::WebviewWindow) {
#[cfg(target_os = "linux")]
{
let _ = window.with_webview(|platform| {
use gtk::glib::{object::ObjectExt, StaticType};
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt};
let webview = platform.inner();
if let Some(settings) = webview.settings() {
settings.set_enable_media_stream(true);
settings.set_enable_mediasource(true);
}
webview.connect_permission_request(|_, request| {
if request
.type_()
.is_a(webkit2gtk::UserMediaPermissionRequest::static_type())
{
request.allow();
true
} else {
false
}
});
});
}
let _ = window;
}
fn is_internal(url: &Url) -> bool {
matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob")
|| matches!(
@@ -359,17 +436,21 @@ fn boot(app: AppHandle) {
pub fn run() {
let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose {
"debug"
} else {
"info"
}))
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(if args.verbose { "debug" } else { "info" }),
)
.init();
let mut paths = BundledPaths::discover();
let dev = args.dev;
let updater = match shell_updater::public_key() {
Some(public_key) => tauri_plugin_updater::Builder::new().pubkey(public_key),
None => tauri_plugin_updater::Builder::new(),
}
.build();
tauri::Builder::default()
.plugin(updater)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -386,23 +467,25 @@ pub fn run() {
paths,
process: Mutex::new(None),
url: Mutex::new(None),
initial_boot_started: AtomicBool::new(false),
});
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0)
.decorations(false)
.resizable(true)
.initialization_script(INJECT)
.on_navigation(|url| {
if is_internal(&url) {
true
} else {
let _ = open::that(url.as_str());
false
}
});
let mut builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0)
.decorations(false)
.resizable(true)
.initialization_script(INJECT)
.on_navigation(|url| {
if is_internal(&url) {
true
} else {
let _ = open::that(url.as_str());
false
}
});
if let Some(icon) = app.default_window_icon().cloned() {
builder = builder.icon(icon)?;
@@ -416,16 +499,18 @@ pub fn run() {
if dev {
window.open_devtools();
}
enable_microphone(&window);
let app_handle = app.handle().clone();
let _ = window;
thread::spawn(move || boot(app_handle));
Ok(())
})
.invoke_handler(tauri::generate_handler![
restart,
open_in_browser,
read_clipboard_images
read_clipboard_images,
check_shell_update,
install_shell_update,
skip_shell_update,
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {

View File

@@ -0,0 +1,126 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::{Update, UpdaterExt};
use url::Url;
const UPDATE_ENDPOINT: &str = "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater/latest/latest.json";
const UPDATE_TIMEOUT_SECONDS: u64 = 8;
pub fn public_key() -> Option<&'static str> {
option_env!("DSH_DESKTOP_UPDATER_PUBKEY").filter(|key| !key.trim().is_empty())
}
#[derive(Clone, Serialize)]
pub struct UpdateInfo {
pub version: String,
pub notes: Option<String>,
}
#[derive(Clone, Serialize)]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
percent: Option<u8>,
}
fn supported_install() -> bool {
#[cfg(target_os = "linux")]
{
// Tauri's Linux updater replaces the running AppImage. A deb, rpm, or
// Flatpak install must be updated by its package manager instead.
std::env::var_os("APPIMAGE").is_some()
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
async fn find_update(app: &AppHandle) -> Result<Option<Update>, String> {
let Some(public_key) = public_key() else {
return Ok(None);
};
if !supported_install() {
return Ok(None);
}
let endpoint = Url::parse(UPDATE_ENDPOINT).map_err(|error| error.to_string())?;
app.updater_builder()
.endpoints(vec![endpoint])
.map_err(|error| error.to_string())?
.pubkey(public_key)
.timeout(Duration::from_secs(UPDATE_TIMEOUT_SECONDS))
.build()
.map_err(|error| error.to_string())?
.check()
.await
.map_err(|error| error.to_string())
}
pub async fn check(app: &AppHandle) -> Result<Option<UpdateInfo>, String> {
Ok(find_update(app).await?.map(|update| UpdateInfo {
version: update.version,
notes: update.body,
}))
}
pub async fn install(app: &AppHandle) -> Result<(), String> {
let update = find_update(app)
.await?
.ok_or_else(|| "没有可安装的壳更新".to_string())?;
let downloaded = Arc::new(AtomicU64::new(0));
let progress_app = app.clone();
let progress_downloaded = Arc::clone(&downloaded);
update
.download_and_install(
move |chunk, total| {
let downloaded =
progress_downloaded.fetch_add(chunk as u64, Ordering::Relaxed) + chunk as u64;
let percent = total
.filter(|total| *total > 0)
.map(|total| ((downloaded.saturating_mul(100) / total).min(100)) as u8);
let _ = progress_app.emit(
"shell-update-progress",
UpdateProgress {
downloaded,
total,
percent,
},
);
},
{
let app = app.clone();
move || {
let total = downloaded.load(Ordering::Relaxed);
let _ = app.emit(
"shell-update-progress",
UpdateProgress {
downloaded: total,
total: Some(total),
percent: Some(100),
},
);
}
},
)
.await
.map_err(|error| error.to_string())?;
app.restart();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_is_the_public_gitea_package() {
let endpoint = Url::parse(UPDATE_ENDPOINT).unwrap();
assert_eq!(endpoint.scheme(), "https");
assert_eq!(endpoint.host_str(), Some("git.fangsiyuan.top"));
assert!(endpoint.path().ends_with("/latest/latest.json"));
}
}