mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
feat: add mainland-reachable desktop distribution
This commit is contained in:
@@ -22,6 +22,7 @@ dsh-core = { path = "../crates/dsh-core" }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
@@ -34,3 +35,4 @@ env_logger = "0.11"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gdk = "0.18"
|
||||
gtk = "0.18"
|
||||
webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
|
||||
|
||||
8
src-tauri/Info.plist
Normal file
8
src-tauri/Info.plist
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>语音输入需要使用麦克风,以便把说话内容写进对话框。</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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 {
|
||||
|
||||
126
src-tauri/src/shell_updater.rs
Normal file
126
src-tauri/src/shell_updater.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,13 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": ""
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
|
||||
"publisher": "TommyFang2077",
|
||||
@@ -23,7 +29,7 @@
|
||||
"copyright": "Copyright © 2026 TommyFang2077",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Give DeepSeek eyes; Anchored Standard ~+8%",
|
||||
"longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). Credentials stay in ~/.dsh.",
|
||||
"longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). On-demand offline SenseVoice dictation writes speech into the composer (Ctrl+E); its model is not bundled. Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). Credentials stay in ~/.dsh.",
|
||||
"licenseFile": "../LICENSE",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -36,7 +42,9 @@
|
||||
],
|
||||
"resources": [
|
||||
"../plugins/dsh-desktop-vision/",
|
||||
"../plugins/dsh-desktop-voice/",
|
||||
"../vendor/modlens/",
|
||||
"../vendor/dshmarket/",
|
||||
"../vendor/anchored-standard/",
|
||||
"../vendor/zero-anchored-standard/"
|
||||
],
|
||||
@@ -62,6 +70,7 @@
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"infoPlist": "Info.plist",
|
||||
"dmg": {
|
||||
"appPosition": { "x": 180, "y": 170 },
|
||||
"applicationFolderPosition": { "x": 480, "y": 170 }
|
||||
|
||||
Reference in New Issue
Block a user