Store: Open With (files the OS hands over) and the icons and builtin catalog kinds
Open With: store_commands keeps each existing file the OS hands over (launch
argument, a second launch forwarded by single-instance with its working
directory, a macOS file:// open-URL) under a random id (Store::receive_file:
name says what it is, regular file, size cap, 8 kept, 15 minutes) and queues
LinkEvent.opened {id, name, kind}. The shell-only install_stage_opened {id}
reads that one file once and stages it exactly like a dropped file; it takes
an id only (testkit asserts that), so the shell cannot name a path. The shell
shows the same install screen; nothing installs without it. The NSIS template
lists the app under "Open with" for the declared extensions (OpenWithProgids)
and never makes it the default program, unlike Tauri's own macro.
Catalog kinds: icons (an icon pack, *.icons.json, at most 120 KB; validate_icons,
staged and committed as text for the shell, the install screen draws every icon,
.icons.json is no longer read as a theme) and builtin (a feature the app
already has, such as an area plugin: no url, sha256, size or file; never staged;
the Store view lists it as Built in with the app's own switch via the areas
prop). Rust: Kind, CatalogItem.check, verify_item, install links refuse builtin.
JS: storeModel, installController (requestOpened, icons adapter), InstallDialog,
StoreView, StoreSection (remove an icon pack, area switches), words. CLI and
JSON Schema: kinds, optional url/sha256/size on builtin, icons size cap; the
test schema validator learns not and if/then/else. docs/STORE.md.
This commit is contained in:
parent
1fae0a7236
commit
7091bca25d
21 changed files with 1015 additions and 90 deletions
|
|
@ -13,6 +13,7 @@ use crate::host::{emit_shell, eval_in_site_tabs, shell_only};
|
|||
use crate::plugins::ScriptPlugin;
|
||||
use crate::policy;
|
||||
use crate::store::{delivery_script, InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, StringsList, EVENT_LINK};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, State, Webview};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
|
|
@ -40,18 +41,63 @@ pub(crate) fn init(app: &AppHandle, config: &AppConfig) {
|
|||
received(app, url.as_str());
|
||||
}
|
||||
}
|
||||
// Launched by a double-click or "Open with" on a file (Windows/Linux pass its path as an argument).
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
opened_args(app, &args, "");
|
||||
}
|
||||
|
||||
/// Most files taken from one launch's arguments.
|
||||
const MAX_OPENED_ARGS: usize = 8;
|
||||
|
||||
/// The files named in a launch's arguments (the first launch's own, or a second launch forwarded
|
||||
/// by single-instance, with its working directory): flags and links are not files and are skipped;
|
||||
/// each file that exists is kept under a random id and offered to the shell
|
||||
/// ([`Store::receive_file`]), and the window comes forward. Nothing is read or installed here.
|
||||
pub(crate) fn opened_args(app: &AppHandle, args: &[String], cwd: &str) {
|
||||
let paths = file_args(args, cwd);
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(store) = app.try_state::<Store>() else { return };
|
||||
for path in paths {
|
||||
emit_shell(app, EVENT_LINK, store.receive_file(&path));
|
||||
}
|
||||
bring_forward(app);
|
||||
}
|
||||
|
||||
/// The existing files among a launch's arguments (the first argument is the program itself).
|
||||
fn file_args(args: &[String], cwd: &str) -> Vec<PathBuf> {
|
||||
args.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| !arg.starts_with('-') && !arg.contains("://"))
|
||||
.map(|arg| if Path::new(arg).is_absolute() || cwd.is_empty() { PathBuf::from(arg) } else { Path::new(cwd).join(arg) })
|
||||
.filter(|path| path.is_file())
|
||||
.take(MAX_OPENED_ARGS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bring_forward(app: &AppHandle) {
|
||||
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// A `waybetter://` link reached the app: validate it, queue it for the shell and bring the window
|
||||
/// forward. Nothing is downloaded or installed until the shell's screen asks for it.
|
||||
fn received(app: &AppHandle, raw: &str) {
|
||||
let Some(store) = app.try_state::<Store>() else { return };
|
||||
emit_shell(app, EVENT_LINK, store.receive_link(raw));
|
||||
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// macOS hands a double-clicked file over as an open-URL event with a `file://` URL.
|
||||
let event = match url::Url::parse(raw).ok().filter(|url| url.scheme() == "file") {
|
||||
Some(url) => match url.to_file_path() {
|
||||
Ok(path) => store.receive_file(&path),
|
||||
Err(()) => store.receive_file(Path::new("")),
|
||||
},
|
||||
None => store.receive_link(raw),
|
||||
};
|
||||
emit_shell(app, EVENT_LINK, event);
|
||||
bring_forward(app);
|
||||
}
|
||||
|
||||
fn store(app: &AppHandle) -> Result<State<'_, Store>, String> {
|
||||
|
|
@ -83,6 +129,15 @@ pub(crate) async fn install_stage_file(webview: Webview, app: AppHandle, name: S
|
|||
store(&app)?.stage_file(&name, text)
|
||||
}
|
||||
|
||||
/// A file the OS handed to the app (a double-click or "Open with"), asked for by the random id the
|
||||
/// `install-link` event carried: native code reads it and stages it exactly like a dropped file,
|
||||
/// for the same install screen. The shell never supplies a path; an unknown id is refused.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_stage_opened(webview: Webview, app: AppHandle, id: String) -> Result<InstallPreview, String> {
|
||||
shell_only(&webview)?;
|
||||
store(&app)?.stage_opened(&id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_cancel(webview: Webview, app: AppHandle, token: String) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
|
|
@ -189,6 +244,7 @@ pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
|
|||
install_pending,
|
||||
install_stage_link,
|
||||
install_stage_file,
|
||||
install_stage_opened,
|
||||
install_cancel,
|
||||
install_commit,
|
||||
plugin_remove,
|
||||
|
|
@ -203,3 +259,30 @@ pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
|
|||
strings_apply
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|arg| (*arg).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_name_files_never_flags_or_links() {
|
||||
let dir = std::env::temp_dir().join(format!("wb-opened-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let theme = dir.join("nord.json");
|
||||
std::fs::write(&theme, "{}").unwrap();
|
||||
let absolute = theme.to_string_lossy().into_owned();
|
||||
let found = file_args(&args(&["wayBetter.exe", "--diag", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json", "file:///x.json", "missing.json", &absolute]), "");
|
||||
assert_eq!(found, [theme.clone()], "the program, flags, links and missing files are skipped");
|
||||
// A second launch's relative name is resolved against its own working directory.
|
||||
let found = file_args(&args(&["wayBetter.exe", "nord.json"]), &dir.to_string_lossy());
|
||||
assert_eq!(found, [dir.join("nord.json")]);
|
||||
assert!(file_args(&args(&["wayBetter.exe", &dir.to_string_lossy()]), "").is_empty(), "a folder is not a file");
|
||||
let many: Vec<String> = std::iter::once("wayBetter.exe".to_owned()).chain((0..20).map(|_| absolute.clone())).collect();
|
||||
assert_eq!(file_args(&many, "").len(), MAX_OPENED_ARGS);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue