init: Sprout desktop app launcher
Co-authored-by: Cursor <cursoragent@cursor.com>
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5646
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "sprout-launcher"
|
||||
version = "1.0.0"
|
||||
description = "萌芽桌面启动器"
|
||||
authors = ["smyhub"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[[bin]]
|
||||
name = "SproutLauncher"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
urlencoding = "2"
|
||||
base64 = "0.22"
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
890
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,890 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
// Suppress the console window that Windows shows when spawning child processes.
|
||||
#[cfg(target_os = "windows")]
|
||||
trait NoWindow {
|
||||
fn no_window(&mut self) -> &mut Self;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
impl NoWindow for Command {
|
||||
fn no_window(&mut self) -> &mut Self {
|
||||
use std::os::windows::process::CommandExt;
|
||||
self.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
||||
}
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Emitter, Manager, State, WebviewWindow,
|
||||
};
|
||||
|
||||
// ─── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AppItem {
|
||||
id: String,
|
||||
name: String,
|
||||
target_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
shortcut_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon_key: Option<String>,
|
||||
category: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct Category {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct Config {
|
||||
apps: Vec<AppItem>,
|
||||
categories: Vec<Category>,
|
||||
}
|
||||
|
||||
// Snapshot stored when a context menu is shown so the app-level event handler
|
||||
// can act on it without any extra state passing.
|
||||
#[derive(Clone)]
|
||||
struct ContextMenuSnapshot {
|
||||
app_id: String,
|
||||
open_path: String, // shortcut if exists, else target
|
||||
shortcut_path: Option<String>,
|
||||
target_path: String,
|
||||
}
|
||||
|
||||
// ─── App state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
config_path: PathBuf,
|
||||
shortcuts_dir: PathBuf,
|
||||
icons_dir: PathBuf,
|
||||
context_menu: Mutex<Option<ContextMenuSnapshot>>,
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn sanitize_filename(input: &str) -> String {
|
||||
let bad = "<>:\"/\\|?*";
|
||||
let mut out = String::new();
|
||||
for ch in input.chars() {
|
||||
if (ch as u32) < 32 || bad.contains(ch) {
|
||||
out.push('_');
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
let s: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
"item".to_string()
|
||||
} else {
|
||||
trimmed.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_dir(p: &Path) {
|
||||
let _ = fs::create_dir_all(p);
|
||||
}
|
||||
|
||||
/// Read a PNG file from the icon cache and return it as a base64 data URL.
|
||||
fn png_to_data_url(path: &Path) -> Option<String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.map(|data| format!("data:image/png;base64,{}", STANDARD.encode(&data)))
|
||||
}
|
||||
|
||||
/// Resolve a .lnk shortcut to its target path via PowerShell.
|
||||
fn resolve_lnk_target(lnk: &str) -> Option<String> {
|
||||
if !lnk.to_lowercase().ends_with(".lnk") {
|
||||
return None;
|
||||
}
|
||||
let escaped = lnk.replace('\'', "''");
|
||||
let ps = format!(
|
||||
"(New-Object -ComObject WScript.Shell).CreateShortcut('{}').TargetPath",
|
||||
escaped
|
||||
);
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &format!("& {{ {} }}", ps)])
|
||||
.no_window()
|
||||
.output()
|
||||
.ok()?;
|
||||
let target = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !target.is_empty() && Path::new(&target).exists() {
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the file's associated icon to a PNG using .NET System.Drawing.
|
||||
fn extract_icon_with_powershell(src: &str, dest: &str) -> bool {
|
||||
let src_esc = src.replace('\'', "''");
|
||||
let dst_esc = dest.replace('\'', "''");
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Drawing\n\
|
||||
$inPath = '{}'\n\
|
||||
$outPath = '{}'\n\
|
||||
try {{\n\
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($inPath)\n\
|
||||
if ($icon) {{\n\
|
||||
$bmp = $icon.ToBitmap()\n\
|
||||
$bmp.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)\n\
|
||||
$bmp.Dispose()\n\
|
||||
$icon.Dispose()\n\
|
||||
}}\n\
|
||||
}} catch {{ exit 1 }}\n",
|
||||
src_esc, dst_esc
|
||||
);
|
||||
let script_path = std::env::temp_dir().join("sprout-extract-icon.ps1");
|
||||
if fs::write(&script_path, &script).is_err() {
|
||||
return false;
|
||||
}
|
||||
let ok = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
script_path.to_str().unwrap_or(""),
|
||||
])
|
||||
.no_window()
|
||||
.output()
|
||||
.map(|o| o.status.success() && Path::new(dest).exists())
|
||||
.unwrap_or(false);
|
||||
let _ = fs::remove_file(&script_path);
|
||||
ok
|
||||
}
|
||||
|
||||
/// Ensure an icon PNG exists in the cache.
|
||||
/// Returns a base64 data URL (`data:image/png;base64,...`) on success.
|
||||
fn ensure_icon_file(icon_key: &str, from_path: &str, icons_dir: &Path) -> Option<String> {
|
||||
ensure_dir(icons_dir);
|
||||
let out_path = icons_dir.join(format!("{}.png", sanitize_filename(icon_key)));
|
||||
|
||||
if out_path.exists() {
|
||||
// Already cached — read and return data URL.
|
||||
return png_to_data_url(&out_path);
|
||||
}
|
||||
|
||||
if !Path::new(from_path).exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon_src = if from_path.to_lowercase().ends_with(".lnk") {
|
||||
resolve_lnk_target(from_path).unwrap_or_else(|| from_path.to_string())
|
||||
} else {
|
||||
from_path.to_string()
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if extract_icon_with_powershell(&icon_src, out_path.to_str().unwrap_or("")) {
|
||||
return png_to_data_url(&out_path);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn launch_app(path: String) -> Result<(), String> {
|
||||
if !Path::new(&path).exists() {
|
||||
return Err("应用程序不存在".into());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &path])
|
||||
.no_window()
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("启动失败: {}", e))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("启动失败: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_target(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_folder(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.blocking_pick_folder()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_single_executable(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_exe_file(app: AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("可执行文件", &["exe"])
|
||||
.blocking_pick_file()
|
||||
.map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExecutableEntry {
|
||||
path: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_executables(app: AppHandle) -> Vec<ExecutableEntry> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let paths = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("可执行与脚本", &["exe", "bat", "cmd", "ps1", "vbs"])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.blocking_pick_files()
|
||||
.unwrap_or_default();
|
||||
|
||||
let exe_exts = [".exe", ".bat", ".cmd", ".ps1", ".vbs"];
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|fp| {
|
||||
let p = fp.to_string();
|
||||
let base = Path::new(&p)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = {
|
||||
let lower = base.to_lowercase();
|
||||
let stripped = exe_exts
|
||||
.iter()
|
||||
.find(|ext| lower.ends_with(*ext))
|
||||
.map(|ext| &base[..base.len() - ext.len()])
|
||||
.unwrap_or(&base);
|
||||
stripped.to_string()
|
||||
};
|
||||
ExecutableEntry { path: p, name }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ShortcutResult {
|
||||
success: bool,
|
||||
#[serde(rename = "shortcutPath")]
|
||||
shortcut_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_shortcut(
|
||||
id: String,
|
||||
target_path: String,
|
||||
name: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ShortcutResult, String> {
|
||||
if !Path::new(&target_path).exists() {
|
||||
return Err("目标不存在".into());
|
||||
}
|
||||
ensure_dir(&state.shortcuts_dir);
|
||||
|
||||
let lnk_path = state
|
||||
.shortcuts_dir
|
||||
.join(format!("{}.lnk", sanitize_filename(&id)));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let is_dir = fs::metadata(&target_path)
|
||||
.map(|m| m.is_dir())
|
||||
.unwrap_or(false);
|
||||
let cwd = if is_dir {
|
||||
String::new()
|
||||
} else {
|
||||
Path::new(&target_path)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
let desc = name.unwrap_or_else(|| {
|
||||
Path::new(&target_path)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let lnk_esc = lnk_path.to_str().unwrap_or("").replace('\'', "''");
|
||||
let target_esc = target_path.replace('\'', "''");
|
||||
let cwd_esc = cwd.replace('\'', "''");
|
||||
let desc_esc = desc.replace('\'', "''");
|
||||
|
||||
let ps = format!(
|
||||
"$ws = New-Object -ComObject WScript.Shell; \
|
||||
$s = $ws.CreateShortcut('{}'); \
|
||||
$s.TargetPath = '{}'; \
|
||||
$s.WorkingDirectory = '{}'; \
|
||||
$s.Description = '{}'; \
|
||||
$s.Save()",
|
||||
lnk_esc, target_esc, cwd_esc, desc_esc
|
||||
);
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps])
|
||||
.no_window()
|
||||
.output()
|
||||
.map_err(|e| format!("创建快捷方式失败: {}", e))?;
|
||||
|
||||
if !out.status.success() || !lnk_path.exists() {
|
||||
return Err("创建快捷方式失败".into());
|
||||
}
|
||||
return Ok(ShortcutResult {
|
||||
success: true,
|
||||
shortcut_path: lnk_path.to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
// Non-Windows fallback: return the target path as-is.
|
||||
#[allow(unreachable_code)]
|
||||
Ok(ShortcutResult { success: true, shortcut_path: target_path })
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IconResult {
|
||||
success: bool,
|
||||
#[serde(rename = "iconUrl")]
|
||||
icon_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_icon(
|
||||
icon_key: String,
|
||||
from_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> IconResult {
|
||||
if icon_key.is_empty() || from_path.is_empty() {
|
||||
return IconResult { success: false, icon_url: None };
|
||||
}
|
||||
match ensure_icon_file(&icon_key, &from_path, &state.icons_dir) {
|
||||
Some(url) => IconResult { success: true, icon_url: Some(url) },
|
||||
None => IconResult { success: false, icon_url: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all cached icon files at once and return them as base64 data URLs.
|
||||
/// Keys that don't have a cached PNG are simply omitted from the result.
|
||||
#[tauri::command]
|
||||
fn batch_read_icons(
|
||||
icon_keys: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> HashMap<String, String> {
|
||||
icon_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let path = state.icons_dir.join(format!("{}.png", sanitize_filename(&key)));
|
||||
png_to_data_url(&path).map(|url| (key, url))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_app_artifacts(
|
||||
shortcut_path: Option<String>,
|
||||
icon_key: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> serde_json::Value {
|
||||
if let Some(p) = shortcut_path {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.extension().and_then(|e| e.to_str()) == Some("lnk")
|
||||
&& pb.starts_with(&state.shortcuts_dir)
|
||||
{
|
||||
let _ = fs::remove_file(&pb);
|
||||
}
|
||||
}
|
||||
if let Some(key) = icon_key {
|
||||
let f = state.icons_dir.join(format!("{}.png", sanitize_filename(&key)));
|
||||
if f.starts_with(&state.icons_dir) {
|
||||
let _ = fs::remove_file(&f);
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "success": true })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ContextMenuPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
shortcut_path: Option<String>,
|
||||
target_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn show_app_context_menu(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
payload: ContextMenuPayload,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let open_path = payload
|
||||
.shortcut_path
|
||||
.as_deref()
|
||||
.filter(|p| Path::new(p).exists())
|
||||
.unwrap_or(&payload.target_path)
|
||||
.to_string();
|
||||
|
||||
let has_shortcut = payload
|
||||
.shortcut_path
|
||||
.as_deref()
|
||||
.map(|p| Path::new(p).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Store context so the app-level menu event handler can act on it.
|
||||
*state.context_menu.lock().unwrap() = Some(ContextMenuSnapshot {
|
||||
app_id: payload.id.clone(),
|
||||
open_path: open_path.clone(),
|
||||
shortcut_path: payload.shortcut_path.clone(),
|
||||
target_path: payload.target_path.clone(),
|
||||
});
|
||||
|
||||
let open_item =
|
||||
MenuItemBuilder::with_id("ctx_open", format!("打开: {}", payload.name))
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let runas_item =
|
||||
MenuItemBuilder::with_id("ctx_runas", "以管理员身份运行")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let show_shortcut =
|
||||
MenuItemBuilder::with_id("ctx_show_shortcut", "在资源管理器中显示快捷方式")
|
||||
.enabled(has_shortcut)
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let show_target =
|
||||
MenuItemBuilder::with_id("ctx_show_target", "在资源管理器中显示目标")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sep = PredefinedMenuItem::separator(&app).map_err(|e| e.to_string())?;
|
||||
let edit_item =
|
||||
MenuItemBuilder::with_id("ctx_edit", "编辑")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let remove_item =
|
||||
MenuItemBuilder::with_id("ctx_remove", "从启动器移除")
|
||||
.build(&app)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let menu = MenuBuilder::new(&app)
|
||||
.item(&open_item)
|
||||
.item(&runas_item)
|
||||
.item(&show_shortcut)
|
||||
.item(&show_target)
|
||||
.item(&sep)
|
||||
.item(&edit_item)
|
||||
.item(&remove_item)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
window.popup_menu(&menu).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_exe_icon(exe_path: String, state: State<'_, AppState>) -> Option<String> {
|
||||
if !Path::new(&exe_path).exists() {
|
||||
return None;
|
||||
}
|
||||
let key = format!("exe_{}", sanitize_filename(&exe_path));
|
||||
ensure_icon_file(&key, &exe_path, &state.icons_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_config(state: State<'_, AppState>) -> Result<Config, String> {
|
||||
if !state.config_path.exists() {
|
||||
let default = Config {
|
||||
apps: vec![],
|
||||
categories: vec![
|
||||
Category { id: "default".into(), name: "默认分类".into() },
|
||||
Category { id: "productivity".into(), name: "办公软件".into() },
|
||||
Category { id: "entertainment".into(), name: "娱乐软件".into() },
|
||||
Category { id: "development".into(), name: "开发工具".into() },
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&default).map_err(|e| e.to_string())?;
|
||||
fs::write(&state.config_path, &json).map_err(|e| e.to_string())?;
|
||||
return Ok(default);
|
||||
}
|
||||
let data = fs::read_to_string(&state.config_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(config: Config, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
|
||||
fs::write(&state.config_path, &json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_config(app: AppHandle, state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let dest = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_file_name("sprout-launcher-config.json")
|
||||
.add_filter("JSON文件", &["json"])
|
||||
.blocking_save_file();
|
||||
|
||||
let Some(dest_path) = dest else {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
};
|
||||
if !state.config_path.exists() {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
}
|
||||
let data = fs::read_to_string(&state.config_path).map_err(|e| e.to_string())?;
|
||||
fs::write(dest_path.to_string(), &data).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_config(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let src = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("JSON文件", &["json"])
|
||||
.blocking_pick_file();
|
||||
|
||||
let Some(src_path) = src else {
|
||||
return Ok(serde_json::json!({ "success": false }));
|
||||
};
|
||||
let data = fs::read_to_string(src_path.to_string()).map_err(|e| e.to_string())?;
|
||||
let config: Config =
|
||||
serde_json::from_str(&data).map_err(|_| "无效的配置文件格式".to_string())?;
|
||||
fs::write(&state.config_path, &data).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({ "success": true, "config": config }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_auto_launch(app: AppHandle) -> bool {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
app.autolaunch().is_enabled().unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_auto_launch(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
if enabled {
|
||||
app.autolaunch().enable().map_err(|e| e.to_string())
|
||||
} else {
|
||||
app.autolaunch().disable().map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn minimize_window(window: WebviewWindow) {
|
||||
let _ = window.minimize();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn maximize_window(window: WebviewWindow) {
|
||||
if window.is_maximized().unwrap_or(false) {
|
||||
let _ = window.unmaximize();
|
||||
} else {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn close_window(window: WebviewWindow) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RefreshItem {
|
||||
id: String,
|
||||
#[serde(rename = "targetPath", default)]
|
||||
target_path: Option<String>,
|
||||
#[serde(rename = "shortcutPath", default)]
|
||||
shortcut_path: Option<String>,
|
||||
#[serde(rename = "iconKey", default)]
|
||||
icon_key: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_icons(
|
||||
apps: Vec<RefreshItem>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
apps.into_iter()
|
||||
.map(|item| {
|
||||
let id = item.id.clone();
|
||||
let from = item
|
||||
.shortcut_path
|
||||
.filter(|p| Path::new(p).exists())
|
||||
.or_else(|| item.target_path.filter(|p| Path::new(p).exists()));
|
||||
|
||||
let Some(from_path) = from else {
|
||||
return serde_json::json!({ "id": id, "success": false, "reason": "文件不存在" });
|
||||
};
|
||||
|
||||
let icon_key = item.icon_key.unwrap_or_else(|| id.clone());
|
||||
// Delete cached file to force re-extraction.
|
||||
let cached = state
|
||||
.icons_dir
|
||||
.join(format!("{}.png", sanitize_filename(&icon_key)));
|
||||
let _ = fs::remove_file(&cached);
|
||||
|
||||
match ensure_icon_file(&icon_key, &from_path, &state.icons_dir) {
|
||||
Some(url) => {
|
||||
serde_json::json!({ "id": id, "success": true, "iconUrl": url })
|
||||
}
|
||||
None => {
|
||||
serde_json::json!({ "id": id, "success": false, "reason": "无法获取图标" })
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
// ── Plugins ──────────────────────────────────────────────────────────
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
.plugin(if cfg!(debug_assertions) {
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build()
|
||||
} else {
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Warn)
|
||||
.build()
|
||||
})
|
||||
// ── Setup ─────────────────────────────────────────────────────────────
|
||||
.setup(|app| {
|
||||
let app_data = app.path().app_data_dir()?;
|
||||
let config_path = app_data.join("launcher-config.json");
|
||||
let data_dir = app_data.join("data");
|
||||
let shortcuts_dir = data_dir.join("shortcuts");
|
||||
let icons_dir = data_dir.join("icons");
|
||||
|
||||
ensure_dir(&shortcuts_dir);
|
||||
ensure_dir(&icons_dir);
|
||||
ensure_dir(&app_data);
|
||||
|
||||
app.manage(AppState {
|
||||
config_path,
|
||||
shortcuts_dir,
|
||||
icons_dir,
|
||||
context_menu: Mutex::new(None),
|
||||
});
|
||||
|
||||
// System tray — use public/logo.png embedded at compile time.
|
||||
let logo_bytes = include_bytes!("../../public/logo.png");
|
||||
let logo_icon = tauri::image::Image::from_bytes(logo_bytes)?;
|
||||
|
||||
let tray_menu = MenuBuilder::new(app)
|
||||
.text("tray_show", "显示主窗口")
|
||||
.separator()
|
||||
.text("tray_quit", "退出 SproutLauncher")
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(logo_icon)
|
||||
.tooltip("SproutLauncher")
|
||||
.menu(&tray_menu)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Only show the window on left-click.
|
||||
// Right-click shows the context menu automatically (OS handles it).
|
||||
if let TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
button_state: tauri::tray::MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Single app-level handler for ALL menu events (tray + context menu).
|
||||
let app_handle = app.handle().clone();
|
||||
app.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
// ── Tray menu ─────────────────────────────────────────────
|
||||
"tray_show" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"tray_quit" => {
|
||||
// Use process::exit to bypass CloseRequested handler.
|
||||
std::process::exit(0);
|
||||
}
|
||||
// ── App context menu ──────────────────────────────────────
|
||||
id => {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let snapshot = state.context_menu.lock().unwrap().clone();
|
||||
let Some(snap) = snapshot else { return };
|
||||
match id {
|
||||
"ctx_open" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", "start", "", &snap.open_path])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
"ctx_runas" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let esc = snap.open_path.replace('\'', "''");
|
||||
let _ = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"Start-Process -FilePath '{}' -Verb RunAs",
|
||||
esc
|
||||
),
|
||||
])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
"ctx_show_shortcut" => {
|
||||
if let Some(ref sp) = snap.shortcut_path {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("explorer")
|
||||
.args(["/select,", sp])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
"ctx_show_target" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("explorer")
|
||||
.args(["/select,", &snap.target_path])
|
||||
.no_window()
|
||||
.spawn();
|
||||
}
|
||||
"ctx_edit" => {
|
||||
if let Some(w) = app_handle.get_webview_window("main") {
|
||||
let _ = w.emit(
|
||||
"app-context-action",
|
||||
serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": snap.app_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
"ctx_remove" => {
|
||||
if let Some(w) = app_handle.get_webview_window("main") {
|
||||
let _ = w.emit(
|
||||
"app-context-action",
|
||||
serde_json::json!({
|
||||
"action": "remove",
|
||||
"id": snap.app_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// ── Close → hide to tray ──────────────────────────────────────────────
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
// ── Commands ──────────────────────────────────────────────────────────
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
launch_app,
|
||||
select_target,
|
||||
select_folder,
|
||||
select_single_executable,
|
||||
select_exe_file,
|
||||
select_executables,
|
||||
create_shortcut,
|
||||
ensure_icon,
|
||||
batch_read_icons,
|
||||
delete_app_artifacts,
|
||||
show_app_context_menu,
|
||||
get_exe_icon,
|
||||
load_config,
|
||||
save_config,
|
||||
export_config,
|
||||
import_config,
|
||||
get_auto_launch,
|
||||
set_auto_launch,
|
||||
minimize_window,
|
||||
maximize_window,
|
||||
close_window,
|
||||
refresh_icons,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
49
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SproutLauncher",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.smyhub.sprout-launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "SproutLauncher",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"visible": false,
|
||||
"skipTaskbar": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": ["SimpChinese"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||