init: Sprout desktop app launcher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 21:57:50 +08:00
commit b8ac8ab8c5
52 changed files with 13106 additions and 0 deletions

4
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

890
src-tauri/src/lib.rs Normal file
View 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
View 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
View 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
}
}
}
}