feat-bootstrap-Snaplet-Windows-screenshot-tool

This commit is contained in:
leefer
2026-08-25 21:04:14 +08:00
commit d8038d292d
78 changed files with 14835 additions and 0 deletions
+6781
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "snaplet"
version = "0.1.0"
description = "A lightweight cross-platform screenshot tool"
authors = ["Snaplet"]
edition = "2021"
[lib]
name = "snaplet_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-autostart = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
image = "0.25"
xcap = "0.7"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = ["Win32_UI_WindowsAndMessaging"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Snaplet desktop permissions",
"windows": ["main", "capture"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"autostart:default",
"clipboard-manager:allow-write-image",
"dialog:allow-save",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Snaplet desktop permissions","local":true,"windows":["main","capture"],"permissions":["core:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","autostart:default","clipboard-manager:allow-write-image","dialog:allow-save","global-shortcut:allow-register","global-shortcut:allow-unregister","global-shortcut:allow-is-registered"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="82" y1="62" x2="426" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#8377FF"/>
<stop offset="1" stop-color="#5849D4"/>
</linearGradient>
<filter id="shadow" x="80" y="114" width="352" height="302" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#2F268E" flood-opacity="0.35"/>
</filter>
</defs>
<rect x="28" y="28" width="456" height="456" rx="120" fill="url(#bg)"/>
<path d="M128 176H184L207 142H305L328 176H384C406.1 176 424 193.9 424 216V350C424 372.1 406.1 390 384 390H128C105.9 390 88 372.1 88 350V216C88 193.9 105.9 176 128 176Z" fill="white" fill-opacity="0.18" filter="url(#shadow)"/>
<path d="M128 176H184L207 142H305L328 176H384C406.1 176 424 193.9 424 216V350C424 372.1 406.1 390 384 390H128C105.9 390 88 372.1 88 350V216C88 193.9 105.9 176 128 176Z" stroke="white" stroke-width="24" stroke-linejoin="round"/>
<circle cx="256" cy="279" r="70" stroke="white" stroke-width="24"/>
<circle cx="256" cy="279" r="26" fill="white"/>
<circle cx="366" cy="226" r="13" fill="#BDB6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+202
View File
@@ -0,0 +1,202 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use image::{DynamicImage, ImageFormat, RgbaImage};
use serde::{Deserialize, Serialize};
use std::{io::Cursor, sync::Mutex};
use tauri::{
image::Image,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
AppHandle, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindowBuilder,
};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use xcap::Monitor;
struct RuntimeState {
shortcut: Mutex<String>,
last_capture: Mutex<Option<CaptureCache>>,
}
struct CaptureCache {
image: RgbaImage,
x: i32,
y: i32,
scale_factor: f64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CapturePayload {
data_url: String,
x: i32,
y: i32,
width: u32,
height: u32,
scale_factor: f64,
}
#[derive(Deserialize)]
struct Rect {
x: f64,
y: f64,
width: f64,
height: f64,
}
fn rgba_to_png(image: &RgbaImage) -> Result<Vec<u8>, String> {
let mut bytes = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image.clone())
.write_to(&mut bytes, ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(bytes.into_inner())
}
#[tauri::command]
fn capture_primary_screen(state: tauri::State<RuntimeState>) -> Result<CapturePayload, String> {
let guard = state.last_capture.lock().map_err(|_| "截图状态不可用")?;
let cached = guard.as_ref().ok_or_else(|| "没有准备好的截图".to_string())?;
let (width, height) = cached.image.dimensions();
let png = rgba_to_png(&cached.image)?;
Ok(CapturePayload {
data_url: format!("data:image/png;base64,{}", STANDARD.encode(png)),
x: cached.x,
y: cached.y,
width,
height,
scale_factor: cached.scale_factor,
})
}
fn prepare_primary_capture(app: &AppHandle) -> Result<(), String> {
let monitor = Monitor::all()
.map_err(|e| e.to_string())?
.into_iter()
.find(|m| m.is_primary().unwrap_or(false))
.ok_or_else(|| "未找到主显示器".to_string())?;
let image = monitor.capture_image().map_err(|e| e.to_string())?;
let cache = CaptureCache {
image,
x: monitor.x().unwrap_or(0),
y: monitor.y().unwrap_or(0),
scale_factor: monitor.scale_factor().unwrap_or(1.0) as f64,
};
let state = app.state::<RuntimeState>();
*state.last_capture.lock().map_err(|_| "截图状态不可用")? = Some(cache);
Ok(())
}
#[tauri::command]
fn start_capture(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("capture") {
window.close().map_err(|e| e.to_string())?;
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
// Capture before creating the overlay. Capturing after the overlay appears
// records the white WebView surface and can leave an unusable topmost window.
std::thread::sleep(std::time::Duration::from_millis(80));
prepare_primary_capture(&app)?;
let monitor = app.primary_monitor().map_err(|e| e.to_string())?.ok_or("未找到主显示器")?;
let position = monitor.position();
let size = monitor.size();
let window = WebviewWindowBuilder::new(&app, "capture", WebviewUrl::App("index.html?mode=capture".into()))
.title("Snaplet Capture")
.decorations(false)
.transparent(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.position(position.x as f64, position.y as f64)
.inner_size(size.width as f64 / monitor.scale_factor(), size.height as f64 / monitor.scale_factor())
.build()
.map_err(|e| e.to_string())?;
window.set_position(PhysicalPosition::new(position.x, position.y)).map_err(|e| e.to_string())?;
window.set_size(PhysicalSize::new(size.width, size.height)).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn finish_capture(
app: AppHandle,
state: tauri::State<RuntimeState>,
rect: Rect,
action: String,
copy_after_capture: bool,
) -> Result<(), String> {
let guard = state.last_capture.lock().map_err(|_| "截图状态不可用")?;
let source = &guard.as_ref().ok_or("没有可用的截图")?.image;
let capture_window = app.get_webview_window("capture").ok_or("截图窗口已关闭")?;
let scale = capture_window.scale_factor().map_err(|e| e.to_string())?;
let x = (rect.x * scale).round().max(0.0) as u32;
let y = (rect.y * scale).round().max(0.0) as u32;
let width = (rect.width * scale).round().max(1.0) as u32;
let height = (rect.height * scale).round().max(1.0) as u32;
let width = width.min(source.width().saturating_sub(x));
let height = height.min(source.height().saturating_sub(y));
let cropped = image::imageops::crop_imm(source, x, y, width, height).to_image();
if action == "copy" || copy_after_capture {
app.clipboard().write_image(&Image::new_owned(cropped.as_raw().clone(), width, height)).map_err(|e| e.to_string())?;
}
if action == "save" {
let bytes = rgba_to_png(&cropped)?;
app.dialog().file().set_file_name("Snaplet.png").add_filter("PNG 图片", &["png"]).save_file(move |path| {
if let Some(path) = path { let _ = std::fs::write(path.as_path().unwrap(), bytes); }
});
}
Ok(())
}
#[tauri::command]
fn set_shortcut(app: AppHandle, state: tauri::State<RuntimeState>, shortcut: String) -> Result<(), String> {
let mut current = state.shortcut.lock().map_err(|_| "快捷键状态不可用")?;
if *current == shortcut && app.global_shortcut().is_registered(shortcut.as_str()) {
return Ok(());
}
let _ = app.global_shortcut().unregister(current.as_str());
app.global_shortcut().register(shortcut.as_str()).map_err(|e| e.to_string())?;
*current = shortcut;
Ok(())
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| {
if event.state == ShortcutState::Pressed { let _ = start_capture(app.clone()); }
}).build())
.plugin(tauri_plugin_autostart::Builder::new().build())
.manage(RuntimeState { shortcut: Mutex::new("Alt+Shift+A".into()), last_capture: Mutex::new(None) })
.setup(|app| {
let show = MenuItem::with_id(app, "show", "打开设置", true, None::<&str>)?;
let capture = MenuItem::with_id(app, "capture", "开始截图", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出 Snaplet", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&capture, &show, &quit])?;
let mut tray = TrayIconBuilder::new().menu(&menu).tooltip("Snaplet 截图");
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.on_menu_event(|app, event| match event.id.as_ref() {
"capture" => { let _ = start_capture(app.clone()); }
"show" => { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } }
"quit" => app.exit(0),
_ => {}
}).build(app)?;
app.global_shortcut().register("Alt+Shift+A")?;
Ok(())
})
.on_window_event(|window, event| {
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); }
}
})
.invoke_handler(tauri::generate_handler![start_capture, capture_primary_screen, finish_capture, set_shortcut])
.run(tauri::generate_context!())
.expect("failed to run Snaplet");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
snaplet_lib::run();
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Snaplet",
"version": "0.1.0",
"identifier": "com.snaplet.capture",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{
"label": "main",
"title": "Snaplet",
"width": 980,
"height": 700,
"minWidth": 760,
"minHeight": 560,
"center": true,
"resizable": true
}],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "轻量、快速、顺手的截图工具",
"windows": { "webviewInstallMode": { "type": "skip" } }
}
}