v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations
- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor (per-token color pickers, live preview, JSON export/import). src/themes.ts. - Plugin platform: JS/ESM plugins loaded via blob-module import (CSP script-src blob:), Host SDK with menu / file-context / view / submit-hook contribution points + permission model. Backend commands to list/read/enable/ install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md. - Settings redesigned into a sectioned hub (General / Appearance / Plugins); Themes and Plugins moved in, removed from the Actions menu. - "NEW" badge on History commits submitted < 3h ago. - Smooth tab transitions: sliding underline + content fade on Changes/History, and matching fades on the view-panel and dock tab groups. - Fix: settings close button used the default browser style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -966,7 +966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
|
||||
@ -2868,6 +2868,169 @@ async fn save_ai_key(key: String) -> Result<(), String> {
|
||||
std::fs::write(&p, key.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ================= plugins =================
|
||||
// Plugins live in <config>/plugins/<id>/ with a plugin.json manifest and a JS
|
||||
// entry. The frontend reads the entry source and imports it as an ES module in a
|
||||
// controlled Host-SDK scope. Enable/disable state is a small enabled.json map.
|
||||
|
||||
fn plugins_dir() -> Option<std::path::PathBuf> {
|
||||
let app = APP.get()?;
|
||||
let dir = app.path().app_config_dir().ok()?.join("plugins");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
fn plugin_enabled_map() -> std::collections::HashMap<String, bool> {
|
||||
plugins_dir()
|
||||
.map(|d| d.join("enabled.json"))
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_plugin_enabled_map(m: &std::collections::HashMap<String, bool>) {
|
||||
if let Some(d) = plugins_dir() {
|
||||
let _ = std::fs::write(d.join("enabled.json"), serde_json::to_string_pretty(m).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
fn find_plugin_dir(id: &str) -> Option<std::path::PathBuf> {
|
||||
let root = plugins_dir()?;
|
||||
for e in std::fs::read_dir(&root).ok()?.flatten() {
|
||||
if !e.path().is_dir() { continue; }
|
||||
let mp = e.path().join("plugin.json");
|
||||
if let Ok(txt) = std::fs::read_to_string(&mp) {
|
||||
if let Ok(m) = serde_json::from_str::<Value>(&txt) {
|
||||
let mid = m.get("id").and_then(|v| v.as_str()).map(String::from)
|
||||
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
|
||||
if mid == id { return Some(e.path()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List installed plugins (manifest + enabled flag).
|
||||
#[tauri::command]
|
||||
async fn plugin_list() -> Result<Vec<Value>, String> {
|
||||
let dir = plugins_dir().ok_or("No config dir")?;
|
||||
let en = plugin_enabled_map();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
if !e.path().is_dir() { continue; }
|
||||
let Ok(txt) = std::fs::read_to_string(e.path().join("plugin.json")) else { continue };
|
||||
let Ok(m) = serde_json::from_str::<Value>(&txt) else { continue };
|
||||
let id = m.get("id").and_then(|v| v.as_str()).map(String::from)
|
||||
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
|
||||
let enabled = *en.get(&id).unwrap_or(&true);
|
||||
out.push(serde_json::json!({
|
||||
"id": id,
|
||||
"name": m.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"),
|
||||
"description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"author": m.get("author").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"permissions": m.get("permissions").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"manifest": m,
|
||||
"enabled": enabled,
|
||||
"dir": e.path().to_string_lossy(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a plugin's JS entry source (guards against path escape).
|
||||
#[tauri::command]
|
||||
async fn plugin_read_entry(id: String) -> Result<String, String> {
|
||||
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
|
||||
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(pdir.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let entry = manifest.get("entry").and_then(|v| v.as_str()).unwrap_or("index.js");
|
||||
if entry.contains("..") || entry.starts_with('/') || entry.starts_with('\\') {
|
||||
return Err("Invalid entry path".into());
|
||||
}
|
||||
let full = pdir.join(entry);
|
||||
// ensure the resolved path stays inside the plugin dir
|
||||
let base = pdir.canonicalize().map_err(|e| e.to_string())?;
|
||||
let target = full.canonicalize().map_err(|e| e.to_string())?;
|
||||
if !target.starts_with(&base) {
|
||||
return Err("Entry escapes plugin folder".into());
|
||||
}
|
||||
std::fs::read_to_string(&target).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn plugin_set_enabled(id: String, enabled: bool) -> Result<(), String> {
|
||||
let mut m = plugin_enabled_map();
|
||||
m.insert(id, enabled);
|
||||
write_plugin_enabled_map(&m);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for e in std::fs::read_dir(from)? {
|
||||
let e = e?;
|
||||
let dest = to.join(e.file_name());
|
||||
if e.file_type()?.is_dir() {
|
||||
copy_dir_recursive(&e.path(), &dest)?;
|
||||
} else {
|
||||
std::fs::copy(e.path(), dest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a plugin from a local folder that contains plugin.json → copies it
|
||||
/// into <config>/plugins/<id>/. Returns the plugin id.
|
||||
#[tauri::command]
|
||||
async fn plugin_install(src: String) -> Result<String, String> {
|
||||
let srcp = std::path::PathBuf::from(&src);
|
||||
if !srcp.join("plugin.json").exists() {
|
||||
return Err("Folder has no plugin.json".into());
|
||||
}
|
||||
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(srcp.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let id = manifest.get("id").and_then(|v| v.as_str()).ok_or("plugin.json missing \"id\"")?.to_string();
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
||||
return Err("Invalid plugin id (use letters, digits, - _ .)".into());
|
||||
}
|
||||
let dest = plugins_dir().ok_or("No config dir")?.join(&id);
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(&dest); // reinstall / update
|
||||
}
|
||||
copy_dir_recursive(&srcp, &dest).map_err(|e| e.to_string())?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn plugin_remove(id: String) -> Result<(), String> {
|
||||
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
|
||||
std::fs::remove_dir_all(&pdir).map_err(|e| e.to_string())?;
|
||||
let mut m = plugin_enabled_map();
|
||||
m.remove(&id);
|
||||
write_plugin_enabled_map(&m);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn plugins_dir_path() -> Result<String, String> {
|
||||
plugins_dir().map(|d| d.to_string_lossy().to_string()).ok_or_else(|| "No config dir".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_plugins_dir() -> Result<(), String> {
|
||||
let dir = plugins_dir().ok_or("No config dir")?;
|
||||
let mut c = Command::new("explorer");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000);
|
||||
}
|
||||
c.arg(dir);
|
||||
c.spawn().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default()
|
||||
@ -2959,6 +3122,13 @@ pub fn run() {
|
||||
p4_scan_stream,
|
||||
p4_scan_cancel,
|
||||
p4_transfer_cancel,
|
||||
plugin_list,
|
||||
plugin_read_entry,
|
||||
plugin_set_enabled,
|
||||
plugin_install,
|
||||
plugin_remove,
|
||||
plugins_dir_path,
|
||||
open_plugins_dir,
|
||||
p4_undo_change,
|
||||
open_in_explorer,
|
||||
find_uproject,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -24,8 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user