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:
CRTkafa 2026-10-01 04:02:35 +07:00
parent 419f60b5b1
commit 873f8561ca
5 changed files with 312 additions and 5 deletions

View file

@ -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 {

View file

@ -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)));
}
}

View file

@ -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")]

View 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]
}

View file

@ -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 {