feat-bootstrap-Snaplet-Windows-screenshot-tool
@@ -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"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"]}}
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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 |
|
After Width: | Height: | Size: 796 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
snaplet_lib::run();
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||