Host: developer mode (dev_* commands and the watcher) is served for every app that sets AppConfig::with_dev; SiteHooks::dev_theme_apply and dev_theme_end carry the app's theme file; commands::DEV, testkit::assert_dev_guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 04:32:54 +07:00
parent c83f541006
commit 3402c1d85d
5 changed files with 396 additions and 5 deletions

View file

@ -92,6 +92,10 @@ pub struct AppConfig {
/// Encrypted folder sync of the settings backup: serves [`commands::SYNC`] and needs
/// [`SiteHooks::backup_sections`]. ([`commands::WORKSPACES`] is served for every app.)
pub sync: bool,
/// Developer mode (live reload of plugins, custom CSS and a theme file): serves
/// [`commands::DEV`] and runs the watcher while it is on; the theme file is the app's
/// ([`SiteHooks::dev_theme_apply`]).
pub dev: bool,
/// Native updates and the hotfix channel: serves [`commands::UPDATE`] and runs the update loop.
/// Needs `updater` (the plugin) and the `net` feature.
#[cfg(feature = "net")]
@ -121,6 +125,7 @@ impl AppConfig {
media: false,
fonts: false,
sync: false,
dev: false,
#[cfg(feature = "net")]
updates: None,
}
@ -133,6 +138,12 @@ impl AppConfig {
self
}
/// Serves the developer-mode commands ([`commands::DEV`]).
pub const fn with_dev(mut self) -> Self {
self.dev = true;
self
}
/// Serves the sync commands ([`commands::SYNC`]).
pub const fn with_sync(mut self) -> Self {
self.sync = true;
@ -251,6 +262,16 @@ pub trait SiteHooks: Send + Sync + 'static {
&[]
}
/// Developer mode: show the theme file the owner picked (read `path`, make it the theme preview
/// in every site tab). An error is shown with its file and line. By default themes are not
/// supported and this does nothing.
fn dev_theme_apply(&self, _app: &AppHandle, _path: &std::path::Path) -> Result<(), crate::devmode::DevError> {
Ok(())
}
/// Developer mode ended (or the theme file was cleared): end the preview [`Self::dev_theme_apply`] made.
fn dev_theme_end(&self, _app: &AppHandle) {}
/// `tab` was laid out for `item` (existing or new), before it is placed.
fn tab_placed(&self, _app: &AppHandle, _tab: &Webview, _item: &TabLayout) {}
@ -1169,6 +1190,11 @@ fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + '
|_| false
}
/// The developer-mode commands ([`commands::DEV`]).
fn dev_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::dev_commands::handler()
}
/// The workspaces and sync commands ([`commands::WORKSPACES`], [`commands::SYNC`]).
fn sync_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::sync_commands::handler()
@ -1236,6 +1262,7 @@ impl App {
let fonts_handler = fonts_handler();
let update_handler = update_handler();
let sync_handler = sync_handler();
let dev_handler = dev_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).
@ -1317,6 +1344,7 @@ impl App {
crate::store_commands::init(&handle, &config);
crate::media_commands::init(&handle);
crate::sync_commands::init(&handle);
crate::dev_commands::init(&handle);
#[cfg(feature = "net")]
crate::fonts_commands::init(&handle);
#[cfg(feature = "net")]
@ -1345,6 +1373,8 @@ impl App {
update_handler(invoke)
} else if commands::WORKSPACES.contains(&invoke.message.command()) || commands::SYNC.contains(&invoke.message.command()) {
sync_handler(invoke)
} else if commands::DEV.contains(&invoke.message.command()) {
dev_handler(invoke)
} else if commands::is_host(invoke.message.command()) {
host_handler(invoke)
} else {

View file

@ -109,9 +109,13 @@ pub const SYNC: &[&str] = &[
"sync_mark_pulled",
];
/// Shell-only, optional: developer mode (live reload of plugins, custom CSS and one theme file),
/// served when the app sets `AppConfig::dev`.
pub const DEV: &[&str] = &["dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error"];
/// 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) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command) || WORKSPACES.contains(&command) || SYNC.contains(&command)
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command) || WORKSPACES.contains(&command) || SYNC.contains(&command) || DEV.contains(&command)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -126,8 +130,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() + MEDIA.len() + GFONTS.len() + UPDATE.len() + WORKSPACES.len() + SYNC.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC) {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len() + WORKSPACES.len() + SYNC.len() + DEV.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV) {
if !all.contains(command) {
all.push(command);
}
@ -144,7 +148,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).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC) {
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -154,7 +158,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) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command) || WORKSPACES.contains(command) || SYNC.contains(command)));
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command) || WORKSPACES.contains(command) || SYNC.contains(command) || DEV.contains(command)));
assert!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

@ -0,0 +1,346 @@
//! Developer mode (live reload), native side, for every app that sets
//! [`crate::app::AppConfig::dev`] ([`crate::commands::DEV`]). Off by default. While on, a polling
//! watcher ([`crate::devmode`]) sees the plugins folder, `custom.css` and one theme file the owner
//! picked, and reloads what changed in every site tab: the same plugin start and stop scripts, the
//! same custom-CSS script and the app's own theme preview the settings use. Syntax and start
//! errors are kept (and shown as a toast in the site tabs) with the file and line.
//!
//! Every command is shell-only; site tabs gain no command, they only receive scripts native code
//! already evaluates. The theme file is the app's ([`crate::app::SiteHooks::dev_theme_apply`]).
use crate::app::{config, hooks, page, tr};
use crate::devmode::{self, DevError};
use crate::host::{emit_shell, eval_in_site_tabs, read_json, shell_only, write_json};
use crate::plugins;
use serde::Serialize;
use std::collections::VecDeque;
use std::path::Path;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, Webview};
use tauri_plugin_dialog::DialogExt;
const MAX_DEV_ERRORS: usize = 20;
#[derive(Default)]
pub(crate) struct DevState {
inner: Mutex<DevInner>,
}
#[derive(Default)]
struct DevInner {
settings: devmode::DevSettings,
errors: VecDeque<DevErrorEntry>,
/// The theme preview in every site tab is developer mode's (so turning it off ends it).
owns_preview: bool,
/// Bumped when what is watched changes; the watcher then takes a new baseline.
generation: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DevErrorEntry {
#[serde(flatten)]
error: DevError,
at: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
enum DevEvent {
/// A plugin file changed; the shell checks its syntax, then asks `dev_reload_plugin`.
Check { file: String, source: String },
Reloaded { target: String, file: Option<String> },
Error { error: DevError },
Changed,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DevStatus {
enabled: bool,
theme_file: Option<String>,
plugins_folder: String,
errors: Vec<DevErrorEntry>,
}
fn dev_state(app: &AppHandle) -> Result<tauri::State<'_, DevState>, String> {
app.try_state::<DevState>().ok_or_else(|| "UNAVAILABLE: this app has no developer mode".to_owned())
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
fn eval_in_site(app: &AppHandle, script: &str) {
eval_in_site_tabs(app, config(app).site, script);
}
fn dev_status(app: &AppHandle) -> Result<DevStatus, String> {
let dev = dev_state(app)?;
let inner = dev.inner.lock().map_err(|_| "DEV: busy")?;
let folder = page(app)?.plugins.dir().to_string_lossy().into_owned();
Ok(DevStatus { enabled: inner.settings.enabled, theme_file: inner.settings.theme_file.clone(), plugins_folder: folder, errors: inner.errors.iter().cloned().collect() })
}
/// A danger toast through the plugin UI kit of the page runtime, in the top frame of a site tab.
fn toast_script(app: &AppHandle, message: &str) -> String {
let site = config(app).site;
let message = serde_json::to_string(message).unwrap_or_else(|_| "\"\"".into());
let origin = serde_json::to_string(&site.primary_origin()).unwrap_or_else(|_| "\"\"".into());
format!(
"(()=>{{if(window.top!==window||location.origin!=={origin})return;const W=window.{};\
try{{W&&W.api&&W.api.ui&&W.api.ui.toast({message},{{tone:'danger',duration:12000}});}}catch(e){{}}}})();",
site.runtime_global
)
}
fn record_error(app: &AppHandle, error: DevError) {
let where_ = match (error.line, error.column) {
(Some(line), Some(column)) => format!("{}:{line}:{column}", error.file),
(Some(line), None) => format!("{}:{line}", error.file),
_ => error.file.clone(),
};
eval_in_site(app, &toast_script(app, &format!("{where_} — {}", error.message)));
if let Ok(dev) = dev_state(app) {
if let Ok(mut inner) = dev.inner.lock() {
inner.errors.push_front(DevErrorEntry { error: error.clone(), at: now() });
inner.errors.truncate(MAX_DEV_ERRORS);
}
}
emit_shell(app, devmode::EVENT, DevEvent::Error { error });
}
fn clear_errors_for(app: &AppHandle, file: &str) {
if let Ok(dev) = dev_state(app) {
if let Ok(mut inner) = dev.inner.lock() {
inner.errors.retain(|entry| entry.error.file != file);
}
}
}
/// Loads the saved settings and starts the watcher (idle until developer mode is turned on). Does
/// nothing unless the app set [`crate::app::AppConfig::dev`].
pub(crate) fn init(app: &AppHandle) {
if !config(app).dev {
return;
}
let settings = read_json::<devmode::DevSettings>(app, devmode::FILE).and_then(|settings| settings.validated().ok()).unwrap_or_default();
app.manage(DevState { inner: Mutex::new(DevInner { settings, ..Default::default() }) });
let handle = app.clone();
std::thread::Builder::new().name("wb-dev-watch".into()).spawn(move || watch_loop(handle)).ok();
}
fn watch_loop(app: AppHandle) {
let mut watcher = devmode::Watcher::new();
let mut generation = u64::MAX;
loop {
std::thread::sleep(devmode::POLL);
let Ok(dev) = dev_state(&app) else { return };
let (enabled, theme_file, current) = match dev.inner.lock() {
Ok(inner) => (inner.settings.enabled, inner.settings.theme_file.clone(), inner.generation),
Err(_) => continue,
};
drop(dev);
if !enabled {
generation = u64::MAX;
std::thread::sleep(std::time::Duration::from_millis(800));
continue;
}
if current != generation {
watcher.reset();
generation = current;
}
let Ok(plugins_dir) = page(&app).map(|page| page.plugins.dir().to_path_buf()) else { continue };
let scan = devmode::scan(&plugins_dir, theme_file.as_deref().map(Path::new));
for change in watcher.observe(std::time::Instant::now(), scan) {
handle_change(&app, &change, theme_file.as_deref());
}
}
}
fn apply_dev_theme(app: &AppHandle, path: &str) {
match hooks(app).dev_theme_apply(app, Path::new(path)) {
Ok(()) => {
if let Ok(dev) = dev_state(app) {
if let Ok(mut inner) = dev.inner.lock() {
inner.owns_preview = true;
}
}
let file = Path::new(path).file_name().map(|name| name.to_string_lossy().into_owned());
if let Some(file) = &file {
clear_errors_for(app, file);
}
emit_shell(app, devmode::EVENT, DevEvent::Reloaded { target: "theme".into(), file });
}
Err(error) => record_error(app, error),
}
}
fn handle_change(app: &AppHandle, change: &devmode::Change, theme_file: Option<&str>) {
use devmode::{Change, Target};
let plugins = config(app).plugins;
match change {
Change::Changed(Target::Theme) => {
if let Some(path) = theme_file {
apply_dev_theme(app, path);
}
}
Change::Removed(Target::Theme) => {
let file = theme_file.and_then(|path| Path::new(path).file_name()).map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
record_error(app, DevError { file, line: None, column: None, message: "The theme file was deleted or renamed.".into() });
}
Change::Changed(Target::Css) | Change::Removed(Target::Css) => {
let css = page(app).map(|page| page.plugins.css()).unwrap_or_default();
if let Ok(script) = plugins::css_script(plugins, &css) {
eval_in_site(app, &script);
}
emit_shell(app, devmode::EVENT, DevEvent::Reloaded { target: "css".into(), file: Some("custom.css".into()) });
}
Change::Changed(Target::Plugin(file)) => {
let (enabled, source) = match page(app) {
Ok(page) => {
let enabled = page.plugins.list().map(|list| list.iter().any(|plugin| &plugin.file == file && plugin.enabled)).unwrap_or(false);
(enabled, std::fs::read_to_string(page.plugins.dir().join(file)).ok())
}
Err(_) => return,
};
match (enabled, source) {
(true, Some(source)) if source.len() as u64 <= plugins::MAX_PLUGIN_BYTES => {
emit_shell(app, devmode::EVENT, DevEvent::Check { file: file.clone(), source });
}
(true, _) => record_error(app, DevError { file: file.clone(), line: None, column: None, message: "Not readable as UTF-8 text under 2 MiB.".into() }),
(false, _) => emit_shell(app, devmode::EVENT, DevEvent::Changed),
}
}
Change::Removed(Target::Plugin(file)) => {
if let Ok(script) = plugins::stop_script(plugins, file) {
eval_in_site(app, &script);
}
emit_shell(app, devmode::EVENT, DevEvent::Changed);
}
}
}
fn save_dev(app: &AppHandle, settings: &devmode::DevSettings) -> Result<(), String> {
write_json(app, devmode::FILE, settings)
}
fn end_dev_preview(app: &AppHandle) {
let owned = dev_state(app).ok().and_then(|dev| dev.inner.lock().ok().map(|mut inner| std::mem::take(&mut inner.owns_preview))).unwrap_or(false);
if owned {
hooks(app).dev_theme_end(app);
}
}
#[tauri::command]
pub(crate) async fn dev_get(webview: Webview, app: AppHandle) -> Result<DevStatus, String> {
shell_only(&webview)?;
dev_status(&app)
}
/// Turns developer mode on or off. Off ends the watched theme's preview.
#[tauri::command]
pub(crate) async fn dev_set(webview: Webview, app: AppHandle, enabled: bool) -> Result<DevStatus, String> {
shell_only(&webview)?;
let (settings, theme_file) = {
let dev = dev_state(&app)?;
let mut inner = dev.inner.lock().map_err(|_| "DEV: busy")?;
inner.settings.enabled = enabled;
inner.generation = inner.generation.wrapping_add(1);
(inner.settings.clone(), inner.settings.theme_file.clone())
};
save_dev(&app, &settings)?;
emit_shell(&app, devmode::EVENT, DevEvent::Changed);
if enabled {
if let Some(path) = theme_file {
apply_dev_theme(&app, &path);
}
} else {
end_dev_preview(&app);
}
dev_status(&app)
}
/// Picks the theme JSON file to live-apply (shown in every site tab while developer mode is on).
#[tauri::command]
pub(crate) async fn dev_pick_theme(webview: Webview, app: AppHandle) -> Result<DevStatus, String> {
shell_only(&webview)?;
let title = tr(&app, "native.dialog.themeFile", "Choose a theme file to live-reload");
let filter = tr(&app, "native.dialog.themeFilter", "Theme");
let picked = app.dialog().file().set_parent(&webview.window()).set_title(title).add_filter(filter, &["json"]).blocking_pick_file();
let Some(picked) = picked else { return dev_status(&app) };
let path = picked.into_path().map_err(|_| "DEV: not a local file")?;
let settings = {
let dev = dev_state(&app)?;
let mut inner = dev.inner.lock().map_err(|_| "DEV: busy")?;
let next = devmode::DevSettings { theme_file: Some(path.to_string_lossy().into_owned()), ..inner.settings.clone() }.validated()?;
inner.settings = next.clone();
inner.generation = inner.generation.wrapping_add(1);
next
};
save_dev(&app, &settings)?;
if settings.enabled {
apply_dev_theme(&app, settings.theme_file.as_deref().unwrap_or_default());
}
dev_status(&app)
}
#[tauri::command]
pub(crate) async fn dev_clear_theme(webview: Webview, app: AppHandle) -> Result<DevStatus, String> {
shell_only(&webview)?;
let settings = {
let dev = dev_state(&app)?;
let mut inner = dev.inner.lock().map_err(|_| "DEV: busy")?;
inner.settings.theme_file = None;
inner.generation = inner.generation.wrapping_add(1);
inner.settings.clone()
};
save_dev(&app, &settings)?;
end_dev_preview(&app);
dev_status(&app)
}
/// Reloads plugin `file` in every site tab after the shell checked its syntax: stop, then start the
/// new version (evaluated with a sourceURL so errors name the file and line). Developer mode only.
#[tauri::command]
pub(crate) async fn dev_reload_plugin(webview: Webview, app: AppHandle, file: String) -> Result<(), String> {
shell_only(&webview)?;
if !dev_state(&app)?.inner.lock().map(|inner| inner.settings.enabled).unwrap_or(false) {
return Err("DEV: developer mode is off".into());
}
let host = config(&app).plugins;
let (stop, start) = {
let page = page(&app)?;
let enabled = page.plugins.list()?.iter().any(|plugin| plugin.file == file && plugin.enabled);
if !enabled {
return Err("DEV: that plugin is not enabled".into());
}
let source = std::fs::read_to_string(page.plugins.dir().join(&file)).map_err(|_| "DEV: plugin file not readable")?;
(plugins::stop_script(host, &file)?, plugins::dev_start_script(host, &file, &source, devmode::SOURCE_PREFIX)?)
};
// The old version is stopped, then the new one starts, evaluated on its own so its sourceURL
// line numbers are the file's. Scripts run in order in each tab.
eval_in_site(&app, &stop);
eval_in_site(&app, &start);
clear_errors_for(&app, &file);
emit_shell(&app, devmode::EVENT, DevEvent::Reloaded { target: "plugin".into(), file: Some(file) });
Ok(())
}
/// The shell found a syntax error in a changed plugin (it was not reloaded): recorded, and shown
/// as a toast in the site tabs.
#[tauri::command]
pub(crate) async fn dev_report_error(webview: Webview, app: AppHandle, file: String, line: Option<usize>, column: Option<usize>, message: String) -> Result<(), String> {
shell_only(&webview)?;
if !plugins::valid_file_name(&file) {
return Err("VALIDATION: invalid plugin file name".into());
}
let message: String = message.chars().filter(|ch| !ch.is_control()).take(300).collect();
record_error(&app, DevError { file, line, column, message });
Ok(())
}
/// The developer-mode commands ([`crate::commands::DEV`]).
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![dev_get, dev_set, dev_pick_theme, dev_clear_theme, dev_reload_plugin, dev_report_error]
}

View file

@ -87,6 +87,8 @@ mod store_commands;
pub mod media_commands;
#[cfg(feature = "app")]
mod sync_commands;
#[cfg(feature = "app")]
mod dev_commands;
#[cfg(all(feature = "app", feature = "net"))]
mod fonts_commands;
#[cfg(all(feature = "app", feature = "net"))]

View file

@ -251,6 +251,14 @@ pub fn assert_sync_guards() {
}
}
/// The developer-mode commands' wrappers check their caller first. Runs against the host's source.
pub fn assert_dev_guards() {
let source = include_str!("dev_commands.rs");
for command in commands::DEV {
assert_guarded(source, command, "shell_only(&webview)?");
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -262,6 +270,7 @@ mod tests {
assert_fonts_guards();
assert_update_guards();
assert_sync_guards();
assert_dev_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {