img clipboard
This commit is contained in:
parent
7eb25bf342
commit
2f74d063cc
BIN
packages/kbot/cat.jpg
Normal file
BIN
packages/kbot/cat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
Binary file not shown.
1639
packages/kbot/gui/tauri-app/src-tauri/Cargo.lock
generated
1639
packages/kbot/gui/tauri-app/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -28,4 +28,20 @@ serde_json = "1"
|
||||
pathdiff = "0.2.3"
|
||||
dirs = "5.0.1"
|
||||
rand = "0.8"
|
||||
tauri-plugin-deep-link = "2.4.3"
|
||||
tauri-plugin-upload = "2.3.1"
|
||||
tauri-plugin-store = "2.4.0"
|
||||
tauri-plugin-log = "2.7.0"
|
||||
url = "2.5.7"
|
||||
arboard = "3.6.1"
|
||||
clap = "4.5.48"
|
||||
clap_derive = "4.5.47"
|
||||
tauri-plugin-shell = "2.3.1"
|
||||
tauri-plugin-process = "2.3.0"
|
||||
tauri-plugin-aptabase = "1.0.0"
|
||||
nanoid = "0.4.0"
|
||||
image = "0.25.8"
|
||||
glob = "0.3.3"
|
||||
tauri-plugin-notification = "2.3.1"
|
||||
tauri-plugin-clipboard-manager = "2.3.0"
|
||||
|
||||
|
||||
@ -2,17 +2,51 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "run-app-base",
|
||||
"description": "Base permissions to run the app",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$HOME/**" },
|
||||
{ "path": "$EXE/**" },
|
||||
{ "path": "$APPDATA/**" }
|
||||
{
|
||||
"path": "$HOME/**"
|
||||
},
|
||||
{
|
||||
"path": "$EXE/**"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/**"
|
||||
},
|
||||
{
|
||||
"path": "$EXE/**"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"core:default",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-set-webview-size",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-write-image",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"log:default",
|
||||
"store:default",
|
||||
"http:default",
|
||||
"dialog:default",
|
||||
"upload:default",
|
||||
"fs:allow-open",
|
||||
"fs:allow-write",
|
||||
"fs:allow-read",
|
||||
@ -28,8 +62,12 @@
|
||||
{
|
||||
"identifier": "fs:scope-appdata-recursive",
|
||||
"allow": [
|
||||
{ "path": "$APPDATA/" },
|
||||
{ "path": "$APPDATA/**" }
|
||||
{
|
||||
"path": "$APPDATA/"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"core:window:allow-minimize",
|
||||
@ -46,12 +84,30 @@
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://**" }
|
||||
{
|
||||
"url": "https://**"
|
||||
},
|
||||
{
|
||||
"url": "https://api.tinify.com/shrink"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.ingest.us.sentry.io*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }]
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPDATA"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/**"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
303
packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs
Normal file
303
packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs
Normal file
@ -0,0 +1,303 @@
|
||||
use arboard::Clipboard;
|
||||
use image::{ImageBuffer, ImageFormat, ImageReader, Rgba};
|
||||
use log::{error, info};
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::ipc::Response;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use url::Url;
|
||||
|
||||
fn detect_image_format(image_data: &[u8]) -> Option<ImageFormat> {
|
||||
let cursor = Cursor::new(image_data);
|
||||
let reader = ImageReader::new(cursor);
|
||||
if let Ok(reader_with_format) = reader.with_guessed_format() {
|
||||
return reader_with_format.format();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn format_to_extension(format: ImageFormat) -> &'static str {
|
||||
match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
ImageFormat::Gif => "gif",
|
||||
ImageFormat::WebP => "webp",
|
||||
ImageFormat::Tiff => "tiff",
|
||||
ImageFormat::Avif => "avif",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
// BGRA -> RGBA
|
||||
fn convert_bgra_to_rgba(bgra_data: &[u8]) -> Vec<u8> {
|
||||
let mut rgba_data = Vec::with_capacity(bgra_data.len());
|
||||
|
||||
for chunk in bgra_data.chunks_exact(4) {
|
||||
if chunk.len() == 4 {
|
||||
rgba_data.push(chunk[0]); // R
|
||||
rgba_data.push(chunk[1]); // G
|
||||
rgba_data.push(chunk[2]); // B
|
||||
rgba_data.push(chunk[3]); // A
|
||||
}
|
||||
}
|
||||
|
||||
rgba_data
|
||||
}
|
||||
|
||||
fn save_rgba_image_to_temp(
|
||||
image_data: &arboard::ImageData,
|
||||
candidate_format: &str,
|
||||
temp_dir: &PathBuf,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let width = image_data.width as u32;
|
||||
let height = image_data.height as u32;
|
||||
let rgba_data = &image_data.bytes;
|
||||
|
||||
let expected_len = (width * height * 4) as usize;
|
||||
if rgba_data.len() != expected_len {
|
||||
return Err(format!(
|
||||
"Image data length mismatch: expected {} bytes ({}x{}x4), actual {} bytes",
|
||||
expected_len,
|
||||
width,
|
||||
height,
|
||||
rgba_data.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// let processed_data = convert_bgra_to_rgba(rgba_data);
|
||||
|
||||
let Some(img_buffer) = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_data.to_vec())
|
||||
else {
|
||||
let error_message = format!("Failed to create ImageBuffer from raw data");
|
||||
error!("{}", error_message);
|
||||
return Err(error_message.into());
|
||||
};
|
||||
|
||||
let file_name = format!(
|
||||
"PicSharp_Clipboard_{}.{}",
|
||||
nanoid::nanoid!(),
|
||||
candidate_format
|
||||
);
|
||||
let temp_path = temp_dir.join(file_name);
|
||||
|
||||
img_buffer.save_with_format(
|
||||
&temp_path,
|
||||
ImageFormat::from_extension(candidate_format).unwrap_or(ImageFormat::Png),
|
||||
)?;
|
||||
|
||||
Ok(temp_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn save_image_data_to_temp(image_data: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let detected_format = detect_image_format(image_data);
|
||||
|
||||
let cursor = Cursor::new(image_data);
|
||||
let reader = ImageReader::new(cursor).with_guessed_format()?;
|
||||
let dynamic_image = reader.decode()?;
|
||||
|
||||
let (save_format, extension) = match detected_format {
|
||||
Some(format) => (format, format_to_extension(format)),
|
||||
None => (ImageFormat::Png, "png"),
|
||||
};
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_name = format!("picsharp_clipboard_{}.{}", nanoid::nanoid!(), extension);
|
||||
let temp_path = temp_dir.join(file_name);
|
||||
|
||||
dynamic_image.save_with_format(&temp_path, save_format)?;
|
||||
|
||||
Ok(temp_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
async fn download_image_from_url(url: &Url) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let response = reqwest::get(url.as_str()).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to download image from URL: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let image_data = response.bytes().await?;
|
||||
info!(
|
||||
"[download_image_from_url] Downloaded image from URL: {} (size: {} bytes)",
|
||||
url,
|
||||
image_data.len()
|
||||
);
|
||||
|
||||
save_image_data_to_temp(&image_data)
|
||||
}
|
||||
|
||||
fn is_image_url(url: &Url) -> bool {
|
||||
let path = url.path().to_lowercase();
|
||||
let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "tiff", "avif"];
|
||||
|
||||
if let Some(extension) = path.split('.').last() {
|
||||
if image_extensions.contains(&extension) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// check common image hosts
|
||||
if let Some(host) = url.host_str() {
|
||||
let image_hosts = [
|
||||
"imgur.com",
|
||||
"i.imgur.com",
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"unsplash.com",
|
||||
"images.unsplash.com",
|
||||
"pixabay.com",
|
||||
"cdn.pixabay.com",
|
||||
"pexels.com",
|
||||
"images.pexels.com",
|
||||
];
|
||||
|
||||
for host_pattern in &image_hosts {
|
||||
if host.contains(host_pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// parse file paths (support multiple paths, separated by newlines)
|
||||
fn parse_file_paths(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| line.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// canonicalize path
|
||||
fn canonicalize_path(path_str: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let path = Path::new(path_str);
|
||||
|
||||
// handle UNC paths and drive letters on Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if path_str.starts_with("\\\\") || path_str.contains(":\\") {
|
||||
return Ok(dunce::canonicalize(path)?);
|
||||
}
|
||||
}
|
||||
|
||||
// for relative paths, convert to absolute paths
|
||||
if path.is_relative() {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
Ok(dunce::canonicalize(current_dir.join(path))?)
|
||||
} else {
|
||||
Ok(dunce::canonicalize(path)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_image_file(path: &Path) -> bool {
|
||||
if !path.exists() || !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(extension) = path.extension() {
|
||||
if let Some(ext_str) = extension.to_str() {
|
||||
let ext_lower = ext_str.to_lowercase();
|
||||
let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "tiff", "avif"];
|
||||
if image_extensions.contains(&ext_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(file_data) = fs::read(path) {
|
||||
if detect_image_format(&file_data).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
async fn parse_clipboard_images(
|
||||
candidate_format: Option<String>,
|
||||
temp_dir: Option<PathBuf>,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut clipboard = Clipboard::new()?;
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let mut candidate_format = candidate_format.unwrap_or_else(|| "png".to_string());
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| std::env::temp_dir());
|
||||
|
||||
if let Ok(file_list) = clipboard.get().file_list() {
|
||||
info!("[parse_clipboard_images] File list: {:?}", file_list);
|
||||
for file in file_list {
|
||||
result.push(file.to_string_lossy().to_string());
|
||||
}
|
||||
} else if let Ok(image_data) = clipboard.get().image() {
|
||||
info!(
|
||||
"[parse_clipboard_images] Image data: {}x{}, {} bytes",
|
||||
image_data.width,
|
||||
image_data.height,
|
||||
image_data.bytes.len()
|
||||
);
|
||||
let temp_path = save_rgba_image_to_temp(&image_data, &candidate_format, &temp_dir)?;
|
||||
result.push(temp_path);
|
||||
}
|
||||
// else if let Ok(text) = clipboard.get().text() {
|
||||
// info!("[parse_clipboard_images] Text: {}", text);
|
||||
// let text = text.trim();
|
||||
|
||||
// if text.starts_with("http://") || text.starts_with("https://") {
|
||||
// if let Ok(url) = Url::parse(text) {
|
||||
// if is_image_url(&url) {
|
||||
// match download_image_from_url(&url).await {
|
||||
// Ok(temp_path) => result.push(temp_path),
|
||||
// Err(e) => error!(
|
||||
// "[parse_clipboard_images] Failed to download image from URL: {}",
|
||||
// e
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// let paths = parse_file_paths(text);
|
||||
// for path_str in paths {
|
||||
// if let Ok(path) = canonicalize_path(&path_str) {
|
||||
// if is_image_file(&path) {
|
||||
// result.push(path.to_string_lossy().to_string());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ipc_parse_clipboard_images<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
candidate_format: String,
|
||||
temp_dir: String,
|
||||
) -> Response {
|
||||
let data =
|
||||
match parse_clipboard_images(Some(candidate_format), Some(PathBuf::from(temp_dir))).await {
|
||||
Ok(data) => data,
|
||||
Err(error) => {
|
||||
error!("[ipc_parse_clipboard_images] Error: {}", error);
|
||||
return Response::new(
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"error": error.to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
Response::new(
|
||||
serde_json::json!({
|
||||
"success": true,
|
||||
"paths": data,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
@ -3,8 +3,10 @@ use serde::{Serialize, Deserialize};
|
||||
|
||||
mod handlers;
|
||||
mod stdin_processor;
|
||||
mod clipboard;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use clipboard::*;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogMessage {
|
||||
@ -65,7 +67,8 @@ pub fn run() {
|
||||
forward_config_to_frontend,
|
||||
forward_image_to_frontend,
|
||||
generate_image_via_backend,
|
||||
request_file_deletion
|
||||
request_file_deletion,
|
||||
ipc_parse_clipboard_images
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(debug_assertions)] // only include this code on debug builds
|
||||
|
||||
10
packages/kbot/plastic-suppliers.md
Normal file
10
packages/kbot/plastic-suppliers.md
Normal file
@ -0,0 +1,10 @@
|
||||
| Supplier Name | Address | Website Link |
|
||||
|---------------|---------|--------------|
|
||||
| Atlántica Plásticos | Carrer de Villarroel, 1-3, 08011 Barcelona | [Link](http://www.atlanticaplasticos.com) |
|
||||
| Plastics Direct Barcelona | Avinguda Diagonal, 549, 08029 Barcelona | [Link](http://www.plasticsdirectbarcelona.com) |
|
||||
| Global Plastic Solutions | C/ de Licenciada Rubi, 25, 08907 Sant Cugat del Vallès | [Link](http://www.globalplasticsolutions.es) |
|
||||
| Metroplast Barcelona | Carrer de Mossen Clapés, 41, 08010 Barcelona | [Link](http://www.metroplast.es) |
|
||||
| Indupla S.A. | C/ de Can Sabaté, 27, 08021 Barcelona | [Link](http://www.indupla.com) |
|
||||
| Acid Plastics | Carrer del Bonom, 15, 08023 Barcelona | [Link](http://www.acidplastics.com) |
|
||||
| Plásticos Adress | Carrer de Muntaner, 246, 08021 Barcelona | [Link](http://www.adressplastics.cat) |
|
||||
| Politex Plásticos | Avinguda Torras i Bassa, 1, 08023 Barcelona | [Link](http://www.politex.es) |
|
||||
Loading…
Reference in New Issue
Block a user