Host: the media panel's commands (gallery folders, previews, the clipboard history and its watcher) are served for every app that sets AppConfig::with_media; commands::MEDIA, media_commands::gallery_file/clip_image for an app's attach command, testkit::assert_media_guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
419f60b5b1
commit
873f8561ca
5 changed files with 312 additions and 5 deletions
|
|
@ -84,6 +84,8 @@ pub struct AppConfig {
|
|||
/// files, the signed catalog): serves [`commands::STORE`]. Needs `deep_link` for the links and
|
||||
/// the `net` feature for downloads.
|
||||
pub store: Option<&'static crate::store::StoreConfig>,
|
||||
/// The shell's media panel (gallery folders, the clipboard history): serves [`commands::MEDIA`].
|
||||
pub media: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
|
|
@ -106,9 +108,16 @@ impl AppConfig {
|
|||
plugins,
|
||||
home_url: None,
|
||||
store: None,
|
||||
media: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the media panel's commands ([`commands::MEDIA`]).
|
||||
pub const fn with_media(mut self) -> Self {
|
||||
self.media = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Serves the store's commands ([`commands::STORE`]) with this configuration.
|
||||
pub const fn with_store(mut self, store: &'static crate::store::StoreConfig) -> Self {
|
||||
self.store = Some(store);
|
||||
|
|
@ -1085,6 +1094,11 @@ fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + '
|
|||
|_| false
|
||||
}
|
||||
|
||||
/// The media panel's commands ([`commands::MEDIA`]).
|
||||
fn media_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
||||
crate::media_commands::handler()
|
||||
}
|
||||
|
||||
type Setup = Box<dyn FnOnce(&AppHandle) -> Result<(), Box<dyn std::error::Error>> + Send>;
|
||||
|
||||
/// The app builder: config, site hooks, the app's own setup, then [`App::run`].
|
||||
|
|
@ -1116,6 +1130,7 @@ impl App {
|
|||
let App { config, hooks, setup } = self;
|
||||
let host_handler = host_handler();
|
||||
let store_handler = store_handler();
|
||||
let media_handler = media_handler();
|
||||
let builder = tauri::Builder::<Wry>::default()
|
||||
// Must be first: a second launch focuses the running window instead of fighting over
|
||||
// the app's data and WebView profile (and hands over its link).
|
||||
|
|
@ -1195,6 +1210,7 @@ impl App {
|
|||
tauri_app.manage(Host { config, hooks });
|
||||
#[cfg(feature = "net")]
|
||||
crate::store_commands::init(&handle, &config);
|
||||
crate::media_commands::init(&handle);
|
||||
// Safe mode for this run: the launch argument, or Shift held while starting (Windows).
|
||||
crate::safe::set(crate::safe::at_launch(std::env::args(), host::shift_held()));
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
@ -1207,6 +1223,8 @@ impl App {
|
|||
.invoke_handler(move |invoke| {
|
||||
if commands::STORE.contains(&invoke.message.command()) {
|
||||
store_handler(invoke)
|
||||
} else if commands::MEDIA.contains(&invoke.message.command()) {
|
||||
media_handler(invoke)
|
||||
} else if commands::is_host(invoke.message.command()) {
|
||||
host_handler(invoke)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -66,9 +66,23 @@ pub const STORE: &[&str] = &[
|
|||
"strings_apply",
|
||||
];
|
||||
|
||||
/// Shell-only, optional: the media panel's commands (gallery folders, previews, the clipboard
|
||||
/// history), served when the app sets `AppConfig::media`. An app that uses them grants these to the
|
||||
/// shell (`testkit::Capabilities::assert_shell_only_group`).
|
||||
pub const MEDIA: &[&str] = &[
|
||||
"media_get",
|
||||
"media_set",
|
||||
"gallery_add_folder",
|
||||
"gallery_list",
|
||||
"gallery_thumb",
|
||||
"clip_list",
|
||||
"clip_thumb",
|
||||
"clip_remove",
|
||||
];
|
||||
|
||||
/// Whether `command` is one of the host's (dispatched to the host, never to the app's handler).
|
||||
pub fn is_host(command: &str) -> bool {
|
||||
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command)
|
||||
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command)
|
||||
}
|
||||
|
||||
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
|
||||
|
|
@ -83,8 +97,8 @@ pub fn permission(command: &str) -> String {
|
|||
/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["draft_list"]))
|
||||
/// ```
|
||||
pub fn manifest(app: &[&'static str]) -> &'static [&'static str] {
|
||||
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len());
|
||||
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE) {
|
||||
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len());
|
||||
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA) {
|
||||
if !all.contains(command) {
|
||||
all.push(command);
|
||||
}
|
||||
|
|
@ -101,7 +115,7 @@ mod tests {
|
|||
let all = manifest(&["draft_list", "tabs_sync"]);
|
||||
assert_eq!(all[0], "draft_list");
|
||||
assert_eq!(all.iter().filter(|c| **c == "tabs_sync").count(), 1);
|
||||
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE) {
|
||||
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA) {
|
||||
assert!(all.contains(command), "{command}");
|
||||
assert!(is_host(command));
|
||||
}
|
||||
|
|
@ -111,7 +125,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn shell_and_tab_commands_do_not_overlap() {
|
||||
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command)));
|
||||
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command)));
|
||||
assert!(!TAB.iter().any(|command| SITE.contains(command)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ pub mod zoom;
|
|||
pub mod app;
|
||||
#[cfg(all(feature = "app", feature = "net"))]
|
||||
mod store_commands;
|
||||
#[cfg(feature = "app")]
|
||||
pub mod media_commands;
|
||||
#[cfg(all(feature = "app", debug_assertions))]
|
||||
pub mod devloop;
|
||||
#[cfg(feature = "tauri")]
|
||||
|
|
|
|||
264
crates/waybetter-host/src/media_commands.rs
Normal file
264
crates/waybetter-host/src/media_commands.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
//! The shell's Media panel, native side, for every app that sets [`crate::app::AppConfig::media`]:
|
||||
//! gallery folders picked through the native dialog, image previews read only from inside those
|
||||
//! folders, and the opt-in local clipboard history ([`crate::commands::MEDIA`]). Every command is
|
||||
//! shell-only; site tabs get nothing. The rules (path containment, size caps, the private-copy
|
||||
//! rule of the clipboard watcher) are [`crate::media`]'s; this module keeps the state, the files
|
||||
//! and the polling thread.
|
||||
//!
|
||||
//! Attaching an image to a site's composer is the app's (it knows the site's composer); it reads
|
||||
//! the bytes through [`gallery_file`] and [`clip_image`], which apply the same checks.
|
||||
|
||||
use crate::app::{config, tr};
|
||||
use crate::host::{data_file, emit_shell, read_json, shell_only, write_json};
|
||||
use crate::media::clipboard::{self, Seen};
|
||||
use crate::media::{self, ClipHistory, ClipItem, ImageEntry, MediaSettings};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, State, Webview};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
const MEDIA_FILE: &str = "media.json";
|
||||
const CLIP_DIR: &str = "clipboard";
|
||||
const POLL: Duration = Duration::from_millis(1_000);
|
||||
|
||||
struct MediaData {
|
||||
settings: MediaSettings,
|
||||
history: ClipHistory,
|
||||
}
|
||||
|
||||
pub(crate) struct MediaState(Mutex<MediaData>);
|
||||
|
||||
fn lock(state: &MediaState) -> Result<MutexGuard<'_, MediaData>, String> {
|
||||
state.0.lock().map_err(|_| "STATE: media state poisoned".to_string())
|
||||
}
|
||||
|
||||
fn media_state(app: &AppHandle) -> Result<State<'_, MediaState>, String> {
|
||||
app.try_state::<MediaState>().ok_or_else(|| "UNAVAILABLE: this app has no media panel".to_owned())
|
||||
}
|
||||
|
||||
fn clip_dir(app: &AppHandle) -> Option<PathBuf> {
|
||||
data_file(app, CLIP_DIR)
|
||||
}
|
||||
|
||||
fn load_history(app: &AppHandle) -> ClipHistory {
|
||||
clip_dir(app).map(|dir| media::load_history(&dir)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_history(app: &AppHandle, history: &ClipHistory) -> Result<(), String> {
|
||||
media::save_history(&clip_dir(app).ok_or("STORAGE: no application data directory")?, history)
|
||||
}
|
||||
|
||||
fn delete_files(app: &AppHandle, items: &[ClipItem]) {
|
||||
if let Some(dir) = clip_dir(app) {
|
||||
media::delete_files(&dir, items);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the settings and the history and starts the clipboard watcher (idle until the owner turns
|
||||
/// the history on). Does nothing unless the app set [`crate::app::AppConfig::media`].
|
||||
pub(crate) fn init(app: &AppHandle) {
|
||||
if !config(app).media {
|
||||
return;
|
||||
}
|
||||
let settings = read_json::<MediaSettings>(app, MEDIA_FILE)
|
||||
.and_then(|settings| settings.validated().ok())
|
||||
.unwrap_or_default();
|
||||
let history = load_history(app);
|
||||
app.manage(MediaState(Mutex::new(MediaData { settings, history })));
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || poll_clipboard(handle));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Clipboard polling (opt-in; the watcher never reads a copy marked private)
|
||||
|
||||
fn poll_clipboard(app: AppHandle) {
|
||||
let enabled = || {
|
||||
app.try_state::<MediaState>()
|
||||
.and_then(|state| lock(&state).ok().map(|data| data.settings.clipboard_enabled))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let title = config(&app).title;
|
||||
clipboard::watch(POLL, enabled, |seen| {
|
||||
if let Err(error) = record(&app, seen) {
|
||||
eprintln!("{title}: clipboard history: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 32 hex characters (what [`media::valid_clip_id`] accepts): not a secret, only unique.
|
||||
fn new_clip_id(hash: &str) -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
||||
let digest = Sha256::digest(format!("{nanos}:{}:{hash}", COUNTER.fetch_add(1, Ordering::Relaxed)));
|
||||
digest.iter().take(16).map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn record(app: &AppHandle, seen: Seen) -> Result<(), String> {
|
||||
let at = chrono::Utc::now().timestamp_millis();
|
||||
let item = match seen {
|
||||
Seen::Text(hash, text) => ClipItem { id: new_clip_id(&hash), kind: "text".into(), text: Some(text), file: None, hash, at },
|
||||
Seen::Image(hash, width, height, rgba) => {
|
||||
let id = new_clip_id(&hash);
|
||||
let state = media_state(app)?;
|
||||
if lock(&state)?.history.has_hash(&hash) {
|
||||
// Already stored: just move it to the front, no new file.
|
||||
ClipItem { id, kind: "image".into(), text: None, file: None, hash, at }
|
||||
} else {
|
||||
let png = media::encode_png(width, height, rgba).ok_or("image encoding failed")?;
|
||||
let dir = clip_dir(app).ok_or("STORAGE: no application data directory")?;
|
||||
std::fs::create_dir_all(&dir).map_err(|error| format!("STORAGE: {error}"))?;
|
||||
let file = format!("{id}.png");
|
||||
std::fs::write(dir.join(&file), png).map_err(|error| format!("STORAGE: {error}"))?;
|
||||
ClipItem { id, kind: "image".into(), text: None, file: Some(file), hash, at }
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = media_state(app)?;
|
||||
let evicted = {
|
||||
let mut data = lock(&state)?;
|
||||
let max = data.settings.clipboard_max;
|
||||
let evicted = data.history.push(item, max);
|
||||
save_history(app, &data.history)?;
|
||||
evicted
|
||||
};
|
||||
delete_files(app, &evicted);
|
||||
emit_shell(app, "clipboard-changed", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What an app needs to attach an image
|
||||
|
||||
/// The bytes of a gallery image, only from inside a gallery folder the owner added (symlinks and
|
||||
/// `..` cannot leave it), with its resolved path (for the name and the type).
|
||||
pub fn gallery_file(app: &AppHandle, path: &str) -> Result<(PathBuf, Vec<u8>), String> {
|
||||
let state = media_state(app)?;
|
||||
let folders = lock(&state)?.settings.folders.clone();
|
||||
let real = media::resolve_in_folders(&folders, path)?;
|
||||
let bytes = std::fs::read(&real).map_err(|error| format!("STORAGE: {error}"))?;
|
||||
Ok((real, bytes))
|
||||
}
|
||||
|
||||
/// The PNG of a clipboard-history image.
|
||||
pub fn clip_image(app: &AppHandle, id: &str) -> Result<Vec<u8>, String> {
|
||||
if !media::valid_clip_id(id) {
|
||||
return Err("VALIDATION: invalid clipboard item".into());
|
||||
}
|
||||
let state = media_state(app)?;
|
||||
let file = lock(&state)?
|
||||
.history
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == id)
|
||||
.and_then(|item| item.file.clone())
|
||||
.ok_or("CLIPBOARD: image not found")?;
|
||||
let dir = clip_dir(app).ok_or("STORAGE: no application data directory")?;
|
||||
std::fs::read(dir.join(file)).map_err(|error| format!("STORAGE: {error}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands (shell only)
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn media_get(webview: Webview, app: AppHandle) -> Result<MediaSettings, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let settings = lock(&state)?.settings.clone();
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Changes the clipboard options and removes folders. New folders are only added through the
|
||||
/// native dialog (`gallery_add_folder`), so the shell cannot widen the file scope by itself.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn media_set(webview: Webview, app: AppHandle, settings: MediaSettings) -> Result<MediaSettings, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let settings = settings.validated()?;
|
||||
let evicted = {
|
||||
let mut data = lock(&state)?;
|
||||
if settings.folders.iter().any(|f| !data.settings.folders.contains(f)) {
|
||||
return Err("FORBIDDEN: add gallery folders with the folder picker".into());
|
||||
}
|
||||
write_json(&app, MEDIA_FILE, &settings)?;
|
||||
data.settings = settings.clone();
|
||||
let evicted = data.history.trim(settings.clipboard_max);
|
||||
if !evicted.is_empty() {
|
||||
save_history(&app, &data.history)?;
|
||||
}
|
||||
evicted
|
||||
};
|
||||
delete_files(&app, &evicted);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn gallery_add_folder(webview: Webview, app: AppHandle) -> Result<MediaSettings, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let title = tr(&app, "native.dialog.galleryFolder", "Add a gallery folder");
|
||||
let picked = app.dialog().file().set_parent(&webview.window()).set_title(title).blocking_pick_folder();
|
||||
let Some(picked) = picked else { return Ok(lock(&state)?.settings.clone()) };
|
||||
let path = picked.into_path().map_err(|_| "VALIDATION: not a local folder")?;
|
||||
let mut data = lock(&state)?;
|
||||
let mut next = data.settings.clone();
|
||||
if next.add_folder(&path)? {
|
||||
write_json(&app, MEDIA_FILE, &next)?;
|
||||
data.settings = next;
|
||||
}
|
||||
Ok(data.settings.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn gallery_list(webview: Webview, app: AppHandle, limit: Option<usize>) -> Result<Vec<ImageEntry>, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let folders = lock(&state)?.settings.folders.clone();
|
||||
Ok(media::list_recent(&folders, limit.unwrap_or(60)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn gallery_thumb(webview: Webview, app: AppHandle, path: String) -> Result<String, String> {
|
||||
shell_only(&webview)?;
|
||||
let (real, bytes) = gallery_file(&app, &path)?;
|
||||
Ok(media::data_url(media::mime_for(&real), &bytes))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clip_list(webview: Webview, app: AppHandle) -> Result<Vec<ClipItem>, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let items = lock(&state)?.history.items.clone();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clip_thumb(webview: Webview, app: AppHandle, id: String) -> Result<String, String> {
|
||||
shell_only(&webview)?;
|
||||
Ok(media::data_url("image/png", &clip_image(&app, &id)?))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clip_remove(webview: Webview, app: AppHandle, id: Option<String>) -> Result<Vec<ClipItem>, String> {
|
||||
shell_only(&webview)?;
|
||||
let state = media_state(&app)?;
|
||||
let (removed, items) = {
|
||||
let mut data = lock(&state)?;
|
||||
let removed = match id {
|
||||
Some(id) => data.history.remove(&id).into_iter().collect(),
|
||||
None => std::mem::take(&mut data.history.items),
|
||||
};
|
||||
save_history(&app, &data.history)?;
|
||||
(removed, data.history.items.clone())
|
||||
};
|
||||
delete_files(&app, &removed);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// The media commands ([`crate::commands::MEDIA`]).
|
||||
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
|
||||
tauri::generate_handler![media_get, media_set, gallery_add_folder, gallery_list, gallery_thumb, clip_list, clip_thumb, clip_remove]
|
||||
}
|
||||
|
|
@ -219,6 +219,14 @@ pub fn assert_store_guards() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The media commands' wrappers check their caller first. Runs against the host's source.
|
||||
pub fn assert_media_guards() {
|
||||
let source = include_str!("media_commands.rs");
|
||||
for command in commands::MEDIA {
|
||||
assert_guarded(source, command, "shell_only(&webview)?");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -226,6 +234,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_store_commands_check_their_caller_first() {
|
||||
assert_store_guards();
|
||||
assert_media_guards();
|
||||
}
|
||||
|
||||
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue