From 1b843df6bda29e80f241062f005cbe2fcde0b0b2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 22:10:41 -0300 Subject: [PATCH 01/15] feat(app): the clipboard is watched, kept and pasted, with the panel as a sidecar --- .github/workflows/ci.yml | 7 + .github/workflows/rules.yml | 7 + Cargo.lock | 137 +++++++++++++ Cargo.toml | 2 + app/src-tauri/Cargo.toml | 2 + app/src-tauri/capabilities/default.json | 16 +- app/src-tauri/src/keys.rs | 33 ++++ app/src-tauri/src/lib.rs | 24 ++- app/src-tauri/src/note.rs | 43 ++++ app/src-tauri/src/panel.rs | 105 ++++++++++ app/src-tauri/src/tray.rs | 1 + app/src-tauri/tauri.conf.json | 5 +- crates/cp-panel/Cargo.toml | 3 + crates/cp-panel/src/app.rs | 165 ++++++++++++++-- crates/cp-panel/src/engine.rs | 248 ++++++++++++++++++++++++ crates/cp-panel/src/main.rs | 15 +- crates/cp-win-sys/src/frontmost.rs | 40 +++- scripts/sidecar.py | 40 ++++ 18 files changed, 862 insertions(+), 31 deletions(-) create mode 100644 app/src-tauri/src/keys.rs create mode 100644 app/src-tauri/src/note.rs create mode 100644 app/src-tauri/src/panel.rs create mode 100644 crates/cp-panel/src/engine.rs create mode 100644 scripts/sidecar.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4051a4b..ee4e914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,13 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@nextest + - name: The panel travels inside the app, so it has to be there first + env: + PY: ${{ runner.os == 'Windows' && 'python' || 'python3' }} + shell: bash + run: | + "$PY" scripts/sidecar.py --debug + - run: cargo clippy --workspace --all-targets - run: cargo nextest run --workspace --no-tests=pass - if: runner.os == 'Windows' diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index c024fc1..4ea8aca 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -162,6 +162,13 @@ jobs: shared-key: coverage save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov + - name: The panel travels inside the app, so it has to be there first + env: + PY: ${{ runner.os == 'Windows' && 'python' || 'python3' }} + shell: bash + run: | + "$PY" scripts/sidecar.py --debug + - run: cargo llvm-cov --no-report --workspace - run: cargo llvm-cov --no-report run -p cp-mac --example probe - run: cargo llvm-cov report --fail-under-lines 90 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' diff --git a/Cargo.lock b/Cargo.lock index ec5be46..4efd2ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,6 +946,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "countme" version = "3.0.1" @@ -987,7 +993,9 @@ dependencies = [ "sys-locale", "tauri", "tauri-build", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tauri-plugin-shell", "tauri-plugin-single-instance", "winreg", ] @@ -1028,6 +1036,7 @@ dependencies = [ "raw-window-handle", "slint", "slint-build", + "tempfile", ] [[package]] @@ -1559,6 +1568,21 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -2208,6 +2232,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "once_cell", + "serde", + "thiserror 2.0.20", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "glow" version = "0.18.0" @@ -3759,6 +3801,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -5735,6 +5804,17 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.61.2", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5747,6 +5827,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6443,6 +6544,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.5" @@ -6465,6 +6581,27 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8548af174c5516e4f71f142acea4d02e00316296ea9aafa58798851481003e3c" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.5" diff --git a/Cargo.toml b/Cargo.toml index c490e4d..fcabe57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-build = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" +tauri-plugin-shell = "2" +tauri-plugin-global-shortcut = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "gif", "bmp", "webp"] } proptest = "1" tempfile = "3" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 84c207b..5613cca 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -28,6 +28,8 @@ sys-locale.workspace = true tauri.workspace = true tauri-plugin-opener.workspace = true tauri-plugin-single-instance.workspace = true +tauri-plugin-shell.workspace = true +tauri-plugin-global-shortcut.workspace = true [target."cfg(windows)".dependencies] cp-win-sys.workspace = true diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 0d94ffb..74580b5 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -27,6 +27,20 @@ } ] }, - "opener:allow-reveal-item-in-dir" + "opener:allow-reveal-item-in-dir", + { + "identifier": "shell:allow-spawn", + "allow": [ + { + "name": "binaries/cp-panel", + "sidecar": true, + "args": [ + "--serve" + ] + } + ] + }, + "shell:allow-stdin-write", + "shell:allow-kill" ] } diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs new file mode 100644 index 0000000..c0f8d84 --- /dev/null +++ b/app/src-tauri/src/keys.rs @@ -0,0 +1,33 @@ +use tauri::{AppHandle, Runtime}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +pub fn bind(app: &AppHandle, said: &str) -> Result<(), String> { + let wanted: Shortcut = said + .parse() + .map_err(|_| format!("«{said}» no es una combinación que el sistema entienda"))?; + let keys = app.global_shortcut(); + let _ = keys.unregister_all(); + let handle = app.clone(); + keys.on_shortcut(wanted, move |_app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + crate::panel::show(&handle); + } + }) + .map_err(|why| format!("el sistema no cedió «{said}»: {why}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_the_settings_file_ships_with_is_a_combination_the_system_knows() { + assert!(cp_config::SHORTCUT.parse::().is_ok()); + } + + #[test] + fn a_combination_nobody_could_press_is_refused_before_the_system_sees_it() { + assert!("".parse::().is_err()); + assert!("Ctrl+".parse::().is_err()); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index dbd0899..474c4fd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +mod keys; +mod note; +mod panel; mod settings; mod tray; mod waking; @@ -8,6 +11,8 @@ pub fn run() { tray::surface(app); })) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .invoke_handler(tauri::generate_handler![ settings::settings, settings::keep, @@ -23,18 +28,25 @@ pub fn run() { if tray::raise(app.handle(), spanish).is_none() { tray::surface(app.handle()); } + panel::raise(app.handle()); + let wanted = kept + .as_ref() + .map_or(cp_config::SHORTCUT, |one| one.shortcut.as_str()); + match keys::bind(app.handle(), wanted) { + Ok(()) => note::note(&format!("el atajo «{wanted}» responde")), + Err(why) => note::note(&format!("sin atajo del panel: {why}")), + } Ok(()) }) .build(tauri::generate_context!()) .expect("CopyPaste no pudo arrancar"); - app.run(|_app, event| { - if let tauri::RunEvent::ExitRequested { + app.run(|app, event| match event { + tauri::RunEvent::ExitRequested { code: None, api, .. - } = event - { - api.prevent_exit(); - } + } => api.prevent_exit(), + tauri::RunEvent::Exit => panel::quit(app), + _ => {} }); } diff --git a/app/src-tauri/src/note.rs b/app/src-tauri/src/note.rs new file mode 100644 index 0000000..895957a --- /dev/null +++ b/app/src-tauri/src/note.rs @@ -0,0 +1,43 @@ +use std::io::Write; +use std::path::PathBuf; + +pub fn where_to() -> PathBuf { + std::env::temp_dir().join("cp-gui.log") +} + +pub fn note(what: &str) { + let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(where_to()) + else { + return; + }; + let _ = writeln!(file, "{} {what}", clock()); +} + +fn clock() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|it| it.as_secs()) + .unwrap_or(0); + let day = now % 86_400; + format!("{:02}:{:02}:{:02}", day / 3600, (day % 3600) / 60, day % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_log_sits_among_the_other_temporary_files() { + assert_eq!(where_to().parent(), Some(std::env::temp_dir().as_path())); + } + + #[test] + fn the_clock_reads_as_hours_minutes_and_seconds() { + let said = clock(); + assert_eq!(said.len(), 8); + assert!(said.chars().filter(|one| *one == ':').count() == 2); + } +} diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs new file mode 100644 index 0000000..6d048e5 --- /dev/null +++ b/app/src-tauri/src/panel.rs @@ -0,0 +1,105 @@ +use crate::note::note; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, Runtime}; +use tauri_plugin_shell::ShellExt; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; + +#[derive(Default)] +pub struct Sidecar(Mutex>); + +pub fn raise(app: &AppHandle) { + app.manage(Sidecar::default()); + match light(app) { + Ok(()) => note("el panel queda esperando en segundo plano"), + Err(why) => note(&format!("el panel no arrancó: {why}")), + } +} + +pub fn show(app: &AppHandle) { + allow(app); + if say(app, "show").is_err() && light(app).is_ok() { + allow(app); + let _ = say(app, "show"); + } +} + +fn allow(app: &AppHandle) { + #[cfg(target_os = "windows")] + if let Some(state) = app.try_state::() + && let Ok(held) = state.0.lock() + && let Some(child) = held.as_ref() + { + cp_win_sys::frontmost::let_it_come_forward(child.pid()); + } + #[cfg(not(target_os = "windows"))] + let _ = app; +} + +pub fn hide(app: &AppHandle) { + let _ = say(app, "hide"); +} + +pub fn quit(app: &AppHandle) { + let _ = say(app, "quit"); + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + && let Some(child) = held.take() + { + let _ = child.kill(); + } +} + +fn light(app: &AppHandle) -> Result<(), tauri_plugin_shell::Error> { + let (mut heard, child) = app.shell().sidecar("cp-panel")?.args(["--serve"]).spawn()?; + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + && let Some(old) = held.replace(child) + { + let _ = old.kill(); + } + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = heard.recv().await { + if let CommandEvent::Terminated(_) = event { + forget(&handle); + break; + } + } + }); + Ok(()) +} + +fn say(app: &AppHandle, what: &str) -> Result<(), Said> { + let state = app.try_state::().ok_or(Said::Gone)?; + let mut held = state.0.lock().map_err(|_| Said::Gone)?; + let child = held.as_mut().ok_or(Said::Gone)?; + if child.write(format!("{what}\n").as_bytes()).is_err() { + held.take(); + return Err(Said::Gone); + } + Ok(()) +} + +fn forget(app: &AppHandle) { + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + { + held.take(); + } +} + +#[derive(Debug)] +pub enum Said { + Gone, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sidecar_starts_with_nobody_on_the_other_end() { + let sidecar = Sidecar::default(); + assert!(sidecar.0.lock().expect("sin envenenar").is_none()); + } +} diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs index b684a28..d4fa509 100644 --- a/app/src-tauri/src/tray.rs +++ b/app/src-tauri/src/tray.rs @@ -79,6 +79,7 @@ pub fn reword(app: &AppHandle, spanish: bool) { } pub fn surface(app: &AppHandle) { + crate::panel::hide(app); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 090f23a..429cb74 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -48,6 +48,9 @@ }, "macOS": { "minimumSystemVersion": "13.3" - } + }, + "externalBin": [ + "binaries/cp-panel" + ] } } diff --git a/crates/cp-panel/Cargo.toml b/crates/cp-panel/Cargo.toml index 84a1acb..8d20c7b 100644 --- a/crates/cp-panel/Cargo.toml +++ b/crates/cp-panel/Cargo.toml @@ -17,6 +17,9 @@ raw-window-handle.workspace = true cp-win-sys.workspace = true cp-win.workspace = true +[dev-dependencies] +tempfile.workspace = true + [build-dependencies] slint-build.workspace = true diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 38e1cbe..e3bc48f 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -9,7 +9,7 @@ use cp_store::{Clock, Filter, Store}; use slint::{ComponentHandle, Model, ModelRc}; use std::cell::RefCell; use std::rc::Rc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicIsize, AtomicU64, Ordering}; use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; @@ -31,6 +31,8 @@ pub struct App { struct State { store: Rc, + engine: Option, + ahead: Arc, query: String, tags: Vec, pinned: bool, @@ -76,6 +78,8 @@ impl App { let counter = spawn_counter(options.db.clone(), panel.as_weak(), generation.clone()); let state = Rc::new(RefCell::new(State { store: Rc::new(store), + engine: None, + ahead: Arc::new(AtomicIsize::new(0)), query: String::new(), tags: Vec::new(), pinned: false, @@ -104,19 +108,45 @@ impl App { } pub fn run(&self, panel: &Panel) -> Result<(), slint::PlatformError> { - panel.show()?; - self.dress(panel); - appear(panel); - panel.invoke_focus_search(); + let serving = self.state.borrow().options.serve; + if !serving { + panel.show()?; + self.dress(panel); + appear(panel); + panel.invoke_focus_search(); + } + self.keep_watch(panel.as_weak()); if let Some(dir) = self.state.borrow().options.signals.clone() { watch_signals(panel.as_weak(), dir); } + if serving { + let state = self.state.borrow(); + listen( + panel.as_weak(), + state.ahead.clone(), + state.options.backdrop.clone(), + ); + } if self.state.borrow().options.measure { crate::measure::run(self); } slint::run_event_loop_until_quit() } + fn keep_watch(&self, ui: slint::Weak) { + let db = self.state.borrow().options.db.clone(); + match crate::engine::Engine::start(&db, move |_| { + let _ = ui.upgrade_in_event_loop(|panel| { + if panel.window().is_visible() { + panel.invoke_reopened(); + } + }); + }) { + Ok(engine) => self.state.borrow_mut().engine = Some(engine), + Err(why) => note(&format!("nadie vigila el portapapeles: {why}")), + } + } + pub fn ui(&self) -> slint::Weak { self.ui.clone() } @@ -276,7 +306,7 @@ impl App { let handed = hand_over(&store, i64::from(id)); if let Some(ui) = ui.upgrade() { if handed { - vanish(&ui); + deliver(&ui, &state); } else { complain(&ui); } @@ -439,7 +469,7 @@ impl App { if let Some(ui) = ui.upgrade() { ui.set_sheet_open(false); if done { - vanish(&ui); + deliver(&ui, &state); } else { complain(&ui); } @@ -461,21 +491,26 @@ impl App { } fn dress(&self, panel: &Panel) { - #[cfg(target_os = "windows")] + dress(panel, &self.state.borrow().options.backdrop); + } +} + +fn dress(panel: &Panel, wanted: &str) { + #[cfg(target_os = "windows")] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let handle = panel.window().window_handle(); + if let Ok(raw) = HasWindowHandle::window_handle(&handle) + && let RawWindowHandle::Win32(win32) = raw.as_raw() { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - let handle = panel.window().window_handle(); - if let Ok(raw) = HasWindowHandle::window_handle(&handle) - && let RawWindowHandle::Win32(win32) = raw.as_raw() - { - let wanted = self.state.borrow().options.backdrop.clone(); - let backdrop = cp_win_sys::backdrop::Backdrop::from_name(&wanted) - .unwrap_or(cp_win_sys::backdrop::Backdrop::Mica); - cp_win_sys::backdrop::apply(win32.hwnd.get(), backdrop, true); - } + let backdrop = cp_win_sys::backdrop::Backdrop::from_name(wanted) + .unwrap_or(cp_win_sys::backdrop::Backdrop::Mica); + cp_win_sys::backdrop::apply(win32.hwnd.get(), backdrop, true); } - #[cfg(not(target_os = "windows"))] - let _ = panel; + } + #[cfg(not(target_os = "windows"))] + { + let _ = (panel, wanted); } } @@ -909,6 +944,96 @@ pub fn now_ms() -> i64 { .unwrap_or(0) } +fn deliver(ui: &Panel, state: &Rc>) { + let ahead = { + let state = state.borrow(); + if let Some(engine) = state.engine.as_ref() { + engine.ours(); + } + state.ahead.swap(0, Ordering::Relaxed) + }; + #[cfg(target_os = "windows")] + if let Some(target) = cp_win_sys::frontmost::target_at(ahead) { + let weak = ui.as_weak(); + let sent = cp_win::paste::paste_into(&target, move || { + if let Some(ui) = weak.upgrade() { + ui.set_sheet_open(false); + let _ = ui.hide(); + } + }); + if let cp_win::paste::Outcome::Degraded(why) = sent { + note(&format!("queda en el portapapeles, sin pegar: {why:?}")); + } + return; + } + let _ = ahead; + vanish(ui); +} + +fn forward(panel: &Panel) { + #[cfg(target_os = "windows")] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let handle = panel.window().window_handle(); + if let Ok(raw) = HasWindowHandle::window_handle(&handle) + && let RawWindowHandle::Win32(win32) = raw.as_raw() + && let Some(target) = cp_win_sys::frontmost::target_at(win32.hwnd.get()) + { + cp_win_sys::frontmost::bring_forward(target.window); + } + } + #[cfg(not(target_os = "windows"))] + let _ = panel; +} + +fn ahead_now() -> isize { + #[cfg(target_os = "windows")] + { + cp_win_sys::frontmost::ahead() + } + #[cfg(not(target_os = "windows"))] + { + 0 + } +} + +fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { + std::thread::spawn(move || { + let mut said = String::new(); + loop { + said.clear(); + match std::io::stdin().read_line(&mut said) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + match said.trim() { + "show" => { + ahead.store(ahead_now(), Ordering::Relaxed); + let dressed = backdrop.clone(); + let _ = ui.upgrade_in_event_loop(move |panel| { + if panel.show().is_err() { + return; + } + dress(&panel, &dressed); + forward(&panel); + panel.invoke_reopened(); + appear(&panel); + panel.invoke_focus_search(); + }); + } + "hide" => { + let _ = ui.upgrade_in_event_loop(|panel| vanish(&panel)); + } + "quit" => break, + other => note(&format!("no entiendo la orden «{other}»")), + } + } + let _ = ui.upgrade_in_event_loop(|_| { + let _ = slint::quit_event_loop(); + }); + }); +} + fn watch_signals(ui: slint::Weak, dir: std::path::PathBuf) { std::thread::spawn(move || { loop { diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs new file mode 100644 index 0000000..eaf5f49 --- /dev/null +++ b/crates/cp-panel/src/engine.rs @@ -0,0 +1,248 @@ +use std::path::Path; + +#[cfg(target_os = "windows")] +use crate::note::note; +#[cfg(target_os = "windows")] +use cp_core::capture::Captured; +#[cfg(target_os = "windows")] +use cp_core::item::Item; +#[cfg(target_os = "windows")] +use cp_core::kind::Kind; +#[cfg(target_os = "windows")] +use cp_store::Store; + +pub struct Engine { + #[cfg(target_os = "windows")] + watching: cp_win::watching::Watching, +} + +#[cfg(target_os = "windows")] +impl Engine { + pub fn start(db: &Path, fresh: impl Fn(i64) + Send + 'static) -> Result { + let store = Store::open(db)?; + let watching = cp_win::watching::Watching::start(move || { + if let Some(id) = kept(&store) { + fresh(id); + } + }); + Ok(Self { watching }) + } + + pub fn ours(&self) { + self.watching.ours(); + } +} + +#[cfg(not(target_os = "windows"))] +impl Engine { + pub fn start( + _db: &Path, + _fresh: impl Fn(i64) + Send + 'static, + ) -> Result { + Ok(Self {}) + } + + pub fn ours(&self) {} +} + +#[cfg(target_os = "windows")] +fn kept(store: &Store) -> Option { + let item = match cp_win::capture::capture_insisting( + cp_win::capture::PATIENCE, + cp_core::watch::RETRY, + ) { + Captured::Kept(item) => item, + Captured::Refused(why) => { + note(&format!("copia descartada: {why:?}")); + return None; + } + Captured::TooSlow => { + note("el portapapeles siguió ocupado tras insistir"); + return None; + } + Captured::Nothing | Captured::Superseded => return None, + }; + keep(store, &item, crate::app::now_ms()) +} + +#[cfg(target_os = "windows")] +fn keep(store: &Store, item: &Item, at: i64) -> Option { + match store.find_by_hash(item) { + Ok(Some(id)) => { + if let Err(why) = store.reactivate(id, at) { + note(&format!("la repetida {id} no subió: {why}")); + } + return Some(id); + } + Ok(None) => {} + Err(why) => note(&format!("no se pudo buscar si ya estaba: {why}")), + } + let id = match store.insert_item(&name_for(at, item), item, &preview_of(item), at) { + Ok(id) => id, + Err(why) => { + note(&format!("lo copiado no se pudo guardar: {why}")); + return None; + } + }; + for job in jobs_for(item) { + if let Err(why) = store.enqueue(id, job) { + note(&format!("{id} se quedó sin encolar {job}: {why}")); + } + } + Some(id) +} + +#[cfg(target_os = "windows")] +fn preview_of(item: &Item) -> String { + let content = cp_win::content::content_of(item, None); + if let Some(text) = content.text { + return text.into_owned(); + } + if !content.paths.is_empty() { + return content.paths.join("\n"); + } + String::new() +} + +#[cfg(target_os = "windows")] +fn name_for(at: i64, item: &Item) -> String { + format!("{at:x}-{:016x}", item.fingerprint()) +} + +#[cfg(target_os = "windows")] +fn jobs_for(item: &Item) -> &'static [&'static str] { + match item.kind { + Some(Kind::Image) => &["thumb", "ocr"], + Some(Kind::File) | Some(Kind::Folder) => &["thumb"], + _ => &[], + } +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use cp_core::item::{Format, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; + + fn text(what: &str) -> Item { + Item { + kind: Some(Kind::Text), + formats: vec![Format { + id: SYNTHETIC_TEXT.into(), + payload: Payload::Inline(what.as_bytes().to_vec()), + }], + } + } + + fn image() -> Item { + Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(vec![0x89, b'P', b'N', b'G']), + }], + } + } + + fn files(paths: &[&str]) -> Item { + Item { + kind: Some(Kind::File), + formats: vec![Format { + id: "CF_HDROP".into(), + payload: Payload::Inline(cp_win::drop::drop_of(paths)), + }], + } + } + + #[test] + fn the_preview_of_text_is_the_text_itself() { + assert_eq!(preview_of(&text("hola mundo")), "hola mundo"); + } + + #[test] + fn an_image_has_no_preview_until_the_reading_gives_it_one() { + assert_eq!(preview_of(&image()), ""); + } + + #[test] + fn files_preview_as_their_paths_one_per_line() { + let said = preview_of(&files(&["C:\\uno.txt", "C:\\dos.txt"])); + assert_eq!(said, "C:\\uno.txt\nC:\\dos.txt"); + } + + #[test] + fn the_same_content_copied_twice_gets_the_same_tail_and_a_different_head() { + let one = name_for(1_000, &text("igual")); + let other = name_for(2_000, &text("igual")); + assert_ne!(one, other); + assert_eq!( + one.split_once('-').map(|it| it.1), + other.split_once('-').map(|it| it.1) + ); + } + + #[test] + fn only_what_can_be_enriched_is_queued() { + assert_eq!(jobs_for(&image()), ["thumb", "ocr"]); + assert_eq!(jobs_for(&files(&["C:\\uno.txt"])), ["thumb"]); + assert!(jobs_for(&text("nada que hacer")).is_empty()); + } + + fn somewhere() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().expect("carpeta"); + let store = Store::open(&dir.path().join("history.db")).expect("abrir"); + (dir, store) + } + + #[test] + fn what_is_copied_lands_with_its_preview_and_its_kind() { + let (_dir, store) = somewhere(); + let id = keep(&store, &text("lo primero"), 1_000).expect("guardado"); + let kept = store.item(id).expect("leer").expect("sigue ahí"); + assert_eq!(kept.kind, Some(Kind::Text)); + assert_eq!(store.count().expect("contar"), 1); + } + + #[test] + fn the_same_thing_copied_twice_is_one_row_that_rises() { + let (_dir, store) = somewhere(); + let first = keep(&store, &text("igual"), 1_000).expect("guardado"); + let again = keep(&store, &text("igual"), 5_000).expect("reconocido"); + assert_eq!(first, again); + assert_eq!(store.count().expect("contar"), 1); + } + + #[test] + fn two_different_copies_are_two_rows() { + let (_dir, store) = somewhere(); + keep(&store, &text("una"), 1_000).expect("guardado"); + keep(&store, &text("otra"), 2_000).expect("guardado"); + assert_eq!(store.count().expect("contar"), 2); + } + + #[test] + fn an_image_leaves_its_reading_and_its_thumbnail_pending() { + let (_dir, store) = somewhere(); + let id = keep(&store, &image(), 1_000).expect("guardada"); + let mut waiting = store.take_pending("ocr", 2_000, 10).expect("cola"); + waiting.extend(store.take_pending("thumb", 2_000, 10).expect("cola")); + assert_eq!(waiting, [id, id]); + } + + #[test] + fn plain_text_asks_nobody_for_anything() { + let (_dir, store) = somewhere(); + keep(&store, &text("sin adornos"), 1_000).expect("guardado"); + assert!( + store + .take_pending("thumb", 2_000, 10) + .expect("cola") + .is_empty() + ); + assert!( + store + .take_pending("ocr", 2_000, 10) + .expect("cola") + .is_empty() + ); + } +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index f370da2..00d812f 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -1,5 +1,6 @@ mod age; mod app; +mod engine; mod measure; mod model; mod note; @@ -49,6 +50,7 @@ fn main() { pub struct Options { pub db: PathBuf, pub measure: bool, + pub serve: bool, pub signals: Option, pub backdrop: String, pub flat: bool, @@ -58,6 +60,7 @@ impl Options { fn from_args() -> Self { let mut db = None; let mut measure = false; + let mut serve = false; let mut flat = false; let mut backdrop = "none".to_owned(); let mut args = std::env::args().skip(1); @@ -65,17 +68,27 @@ impl Options { match arg.as_str() { "--db" => db = args.next().map(PathBuf::from), "--measure" => measure = true, + "--serve" => serve = true, "--flat" => flat = true, "--backdrop" => backdrop = args.next().unwrap_or_default(), other => eprintln!("argumento ignorado: {other}"), } } Self { - db: db.unwrap_or_else(|| std::env::temp_dir().join("cp-seed").join("history.db")), + db: db.unwrap_or_else(where_it_lives), measure, + serve, signals: std::env::var_os("CP_PANEL_SIGNALS").map(PathBuf::from), backdrop, flat, } } } + +fn where_it_lives() -> PathBuf { + #[cfg(target_os = "windows")] + if let Some(path) = cp_win_sys::paths::database() { + return path; + } + std::env::temp_dir().join("cp-seed").join("history.db") +} diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index bcd339c..1fc2715 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -7,8 +7,8 @@ use windows::Win32::System::Threading::{ }; use windows::Win32::UI::Input::KeyboardAndMouse::{GetFocus, SetFocus}; use windows::Win32::UI::WindowsAndMessaging::{ - GUITHREADINFO, GetForegroundWindow, GetGUIThreadInfo, IsWindow, SMTO_ABORTIFHUNG, SMTO_BLOCK, - SendMessageTimeoutW, SetForegroundWindow, WM_NULL, + AllowSetForegroundWindow, GUITHREADINFO, GetForegroundWindow, GetGUIThreadInfo, IsWindow, + SMTO_ABORTIFHUNG, SMTO_BLOCK, SendMessageTimeoutW, SetForegroundWindow, WM_NULL, }; use crate::source::process_of; @@ -37,6 +37,22 @@ pub fn capture_target() -> Option { }) } +pub fn let_it_come_forward(pid: u32) -> bool { + unsafe { AllowSetForegroundWindow(pid) }.is_ok() +} + +pub fn ahead() -> isize { + foreground().map_or(0, |window| window.0 as isize) +} + +pub fn target_at(handle: isize) -> Option { + if handle == 0 { + return None; + } + let window = HWND(handle as *mut std::ffi::c_void); + is_alive(window).then(|| target_for(window)) +} + pub fn target_for(window: HWND) -> Target { let thread = unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(window, None) }; @@ -192,6 +208,26 @@ mod tests { assert_eq!(integrity_of(0), None); } + #[test] + fn nothing_ahead_is_no_target() { + assert_eq!(target_at(0), None); + } + + #[test] + fn a_handle_that_is_no_longer_a_window_is_no_target() { + assert_eq!(target_at(isize::MAX), None); + } + + #[test] + fn what_is_ahead_can_be_picked_up_again_by_its_handle() { + let handle = ahead(); + if handle == 0 { + return; + } + let target = target_at(handle).expect("la ventana de delante sigue viva"); + assert_eq!(target.window.0 as isize, handle); + } + #[test] fn we_are_never_out_of_our_own_reach() { let Some(window) = foreground() else { diff --git a/scripts/sidecar.py b/scripts/sidecar.py new file mode 100644 index 0000000..e74248c --- /dev/null +++ b/scripts/sidecar.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import pathlib +import shutil +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WHERE = ROOT / "app" / "src-tauri" / "binaries" + + +def triple() -> str: + said = subprocess.run(["rustc", "-vV"], capture_output=True, text=True, check=True) + for line in said.stdout.splitlines(): + if line.startswith("host: "): + return line.removeprefix("host: ").strip() + raise SystemExit("rustc no dijo para qué máquina compila") + + +def main() -> int: + release = "--debug" not in sys.argv + build = ["cargo", "build", "-p", "cp-panel"] + if release: + build.append("--release") + subprocess.run(build, cwd=ROOT, check=True) + + tail = ".exe" if sys.platform == "win32" else "" + made = ROOT / "target" / ("release" if release else "debug") / f"cp-panel{tail}" + if not made.exists(): + raise SystemExit(f"no se encontró {made}") + + WHERE.mkdir(parents=True, exist_ok=True) + named = f"cp-panel-{triple()}{tail}" + for landed in (WHERE / named, made.parent / named): + shutil.copy2(made, landed) + print(f"{landed.relative_to(ROOT)} listo desde {made.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 19f84c3e97b34d6a235ebbdd1b3730e149488960 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 22:37:27 -0300 Subject: [PATCH 02/15] fix(app): what four lenses found, from a paste that never pasted --- .cargo/mutants.toml | 2 + .github/workflows/rules.yml | 8 +- app/package.json | 1 + app/src-tauri/capabilities/default.json | 16 +--- app/src-tauri/src/lib.rs | 1 + crates/cp-panel/src/app.rs | 102 +++++++++++++++++------- crates/cp-panel/src/engine.rs | 12 +-- crates/cp-panel/src/main.rs | 8 +- crates/cp-panel/ui/panel.slint | 2 + crates/cp-win-sys/src/frontmost.rs | 18 ++++- 10 files changed, 116 insertions(+), 54 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index ac9ef2c..4de12b5 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -9,6 +9,8 @@ exclude_re = [ "replace < with <= in rgb_of_hsl", "replace < with <= in migrate", "replace Rows::fetch_ahead with \\(\\)", + "replace Engine::ours with", + "replace kept -> Option with", ] exclude_globs = [ diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 4ea8aca..89d3a1f 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -162,12 +162,12 @@ jobs: shared-key: coverage save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov - - name: The panel travels inside the app, so it has to be there first - env: - PY: ${{ runner.os == 'Windows' && 'python' || 'python3' }} + - name: The app only needs the sidecar to exist here, nobody runs it shell: bash run: | - "$PY" scripts/sidecar.py --debug + mkdir -p app/src-tauri/binaries + triple=$(rustc -vV | sed -n 's/^host: //p') + : > "app/src-tauri/binaries/cp-panel-$triple" - run: cargo llvm-cov --no-report --workspace - run: cargo llvm-cov --no-report run -p cp-mac --example probe diff --git a/app/package.json b/app/package.json index 4d3eb95..b6b03f9 100644 --- a/app/package.json +++ b/app/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "sidecar": "python ../scripts/sidecar.py", "preview": "vite preview", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 74580b5..0d94ffb 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -27,20 +27,6 @@ } ] }, - "opener:allow-reveal-item-in-dir", - { - "identifier": "shell:allow-spawn", - "allow": [ - { - "name": "binaries/cp-panel", - "sidecar": true, - "args": [ - "--serve" - ] - } - ] - }, - "shell:allow-stdin-write", - "shell:allow-kill" + "opener:allow-reveal-item-in-dir" ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 474c4fd..6dae619 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ pub fn run() { if tray::raise(app.handle(), spanish).is_none() { tray::surface(app.handle()); } + #[cfg(target_os = "windows")] panel::raise(app.handle()); let wanted = kept .as_ref() diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index e3bc48f..13ddb6b 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -31,7 +31,7 @@ pub struct App { struct State { store: Rc, - engine: Option, + engine: Option>, ahead: Arc, query: String, tags: Vec, @@ -115,7 +115,9 @@ impl App { appear(panel); panel.invoke_focus_search(); } - self.keep_watch(panel.as_weak()); + if !self.state.borrow().options.measure { + self.keep_watch(panel.as_weak()); + } if let Some(dir) = self.state.borrow().options.signals.clone() { watch_signals(panel.as_weak(), dir); } @@ -137,12 +139,12 @@ impl App { let db = self.state.borrow().options.db.clone(); match crate::engine::Engine::start(&db, move |_| { let _ = ui.upgrade_in_event_loop(|panel| { - if panel.window().is_visible() { - panel.invoke_reopened(); + if panel.window().is_visible() && !panel.get_sheet_open() { + panel.invoke_arrived(); } }); }) { - Ok(engine) => self.state.borrow_mut().engine = Some(engine), + Ok(engine) => self.state.borrow_mut().engine = Some(Rc::new(engine)), Err(why) => note(&format!("nadie vigila el portapapeles: {why}")), } } @@ -302,8 +304,11 @@ impl App { let ui = self.ui.clone(); let state = self.state.clone(); panel.on_paste(move |id| { - let store = state.borrow().store.clone(); - let handed = hand_over(&store, i64::from(id)); + let (store, engine) = { + let state = state.borrow(); + (state.store.clone(), state.engine.clone()) + }; + let handed = hand_over(&store, engine.as_deref(), i64::from(id)); if let Some(ui) = ui.upgrade() { if handed { deliver(&ui, &state); @@ -425,6 +430,28 @@ impl App { }); let ui = self.ui.clone(); let state = self.state.clone(); + panel.on_fresh_start(move || { + { + let mut state = state.borrow_mut(); + state.query.clear(); + state.tags.clear(); + state.pinned = false; + } + if let Some(ui) = ui.upgrade() { + ui.set_query(Default::default()); + ui.set_sheet_open(false); + refresh(&ui, &state); + } + }); + let ui = self.ui.clone(); + let state = self.state.clone(); + panel.on_arrived(move || { + if let Some(ui) = ui.upgrade() { + keeping_place(&ui, &state); + } + }); + let ui = self.ui.clone(); + let state = self.state.clone(); panel.on_ask_keys(move || { let Some(ui) = ui.upgrade() else { return; @@ -446,9 +473,12 @@ impl App { let asking = state.borrow().asking; match asking { Asking::Forms(id) => { - let store = state.borrow().store.clone(); - if paste_as(&store, id, key.as_str()) { - vanish(&ui); + let (store, engine) = { + let state = state.borrow(); + (state.store.clone(), state.engine.clone()) + }; + if paste_as(&store, engine.as_deref(), id, key.as_str()) { + deliver(&ui, &state); } else { complain(&ui); } @@ -464,8 +494,11 @@ impl App { let ui = self.ui.clone(); let state = self.state.clone(); panel.on_paste_as(move |id, key| { - let store = state.borrow().store.clone(); - let done = paste_as(&store, i64::from(id), key.as_str()); + let (store, engine) = { + let state = state.borrow(); + (state.store.clone(), state.engine.clone()) + }; + let done = paste_as(&store, engine.as_deref(), i64::from(id), key.as_str()); if let Some(ui) = ui.upgrade() { ui.set_sheet_open(false); if done { @@ -697,7 +730,7 @@ fn keys_of(filter: &Filter) -> Vec { .collect() } -fn hand_over(store: &Store, id: i64) -> bool { +fn hand_over(store: &Store, engine: Option<&crate::engine::Engine>, id: i64) -> bool { let item = match store.item(id) { Ok(Some(item)) => item, Ok(None) => { @@ -718,6 +751,8 @@ fn hand_over(store: &Store, id: i64) -> bool { let how = cp_win::restore::to_clipboard(&clipboard, &item); let written = matches!(how, cp_win::restore::Restored::Written { .. }); if written { + mark(engine); + drop(clipboard); if let Err(why) = store.record_paste(id, now_ms()) { note(&format!("pegado {id} sin anotar: {why}")); } @@ -898,9 +933,9 @@ fn forms_of(_store: &Store, _id: i64) -> Vec { } #[cfg(target_os = "windows")] -fn paste_as(store: &Store, id: i64, key: &str) -> bool { +fn paste_as(store: &Store, engine: Option<&crate::engine::Engine>, id: i64, key: &str) -> bool { if key == AS_IS { - return hand_over(store, id); + return hand_over(store, engine, id); } let Some(form) = form_of(key) else { note(&format!("forma desconocida: {key}")); @@ -926,13 +961,17 @@ fn paste_as(store: &Store, id: i64, key: &str) -> bool { cp_win::restore::Restored::Written { .. } ); if written { - let _ = store.record_paste(id, now_ms()); + mark(engine); + drop(clipboard); + if let Err(why) = store.record_paste(id, now_ms()) { + note(&format!("pegado {id} sin anotar: {why}")); + } } written } #[cfg(not(target_os = "windows"))] -fn paste_as(_store: &Store, _id: i64, key: &str) -> bool { +fn paste_as(_store: &Store, _engine: Option<&crate::engine::Engine>, _id: i64, key: &str) -> bool { let _ = form_of(key); false } @@ -944,14 +983,17 @@ pub fn now_ms() -> i64 { .unwrap_or(0) } -fn deliver(ui: &Panel, state: &Rc>) { - let ahead = { - let state = state.borrow(); - if let Some(engine) = state.engine.as_ref() { - engine.ours(); - } - state.ahead.swap(0, Ordering::Relaxed) +fn mark(engine: Option<&crate::engine::Engine>) { + let Some(engine) = engine else { + return; }; + if !engine.ours() { + note("no se pudo marcar como nuestra la escritura del portapapeles"); + } +} + +fn deliver(ui: &Panel, state: &Rc>) { + let ahead = state.borrow().ahead.swap(0, Ordering::Relaxed); #[cfg(target_os = "windows")] if let Some(target) = cp_win_sys::frontmost::target_at(ahead) { let weak = ui.as_weak(); @@ -997,12 +1039,17 @@ fn ahead_now() -> isize { } } +const ORDER_UP_TO: u64 = 64; + fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { std::thread::spawn(move || { + use std::io::{BufRead, Read}; + let input = std::io::stdin(); + let mut reader = std::io::BufReader::new(input.lock()); let mut said = String::new(); loop { said.clear(); - match std::io::stdin().read_line(&mut said) { + match (&mut reader).take(ORDER_UP_TO).read_line(&mut said) { Ok(0) | Err(_) => break, Ok(_) => {} } @@ -1016,16 +1063,17 @@ fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { } dress(&panel, &dressed); forward(&panel); - panel.invoke_reopened(); + panel.invoke_fresh_start(); appear(&panel); panel.invoke_focus_search(); }); } "hide" => { + ahead.store(0, Ordering::Relaxed); let _ = ui.upgrade_in_event_loop(|panel| vanish(&panel)); } "quit" => break, - other => note(&format!("no entiendo la orden «{other}»")), + _ => note("llegó una orden que no entiendo"), } } let _ = ui.upgrade_in_event_loop(|_| { diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index eaf5f49..2fc886e 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -28,8 +28,8 @@ impl Engine { Ok(Self { watching }) } - pub fn ours(&self) { - self.watching.ours(); + pub fn ours(&self) -> bool { + self.watching.ours() } } @@ -42,7 +42,9 @@ impl Engine { Ok(Self {}) } - pub fn ours(&self) {} + pub fn ours(&self) -> bool { + true + } } #[cfg(target_os = "windows")] @@ -52,8 +54,8 @@ fn kept(store: &Store) -> Option { cp_core::watch::RETRY, ) { Captured::Kept(item) => item, - Captured::Refused(why) => { - note(&format!("copia descartada: {why:?}")); + Captured::Refused(_) => { + note("una copia se descartó por lo que la aplicación de origen pidió"); return None; } Captured::TooSlow => { diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 00d812f..18b2dda 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -75,7 +75,7 @@ impl Options { } } Self { - db: db.unwrap_or_else(where_it_lives), + db: db.unwrap_or_else(|| if measure { seeded() } else { where_it_lives() }), measure, serve, signals: std::env::var_os("CP_PANEL_SIGNALS").map(PathBuf::from), @@ -85,10 +85,14 @@ impl Options { } } +fn seeded() -> PathBuf { + std::env::temp_dir().join("cp-seed").join("history.db") +} + fn where_it_lives() -> PathBuf { #[cfg(target_os = "windows")] if let Some(path) = cp_win_sys::paths::database() { return path; } - std::env::temp_dir().join("cp-seed").join("history.db") + seeded() } diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 9dc7fed..4190021 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -54,6 +54,8 @@ export component Panel inherits Window { callback ask-kinds(); callback ask-keys(); callback reopened(); + callback fresh-start(); + callback arrived(); callback sheet-chosen(string); callback paste-as(int, string); callback cycle(int); diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 1fc2715..558d470 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -42,7 +42,13 @@ pub fn let_it_come_forward(pid: u32) -> bool { } pub fn ahead() -> isize { - foreground().map_or(0, |window| window.0 as isize) + let Some(window) = foreground() else { + return 0; + }; + if process_of(window) == Some(std::process::id()) { + return 0; + } + window.0 as isize } pub fn target_at(handle: isize) -> Option { @@ -228,6 +234,16 @@ mod tests { assert_eq!(target.window.0 as isize, handle); } + #[test] + fn a_window_of_our_own_is_never_what_is_ahead() { + let handle = ahead(); + if handle == 0 { + return; + } + let window = target_at(handle).expect("viva").window; + assert_ne!(process_of(window), Some(std::process::id())); + } + #[test] fn we_are_never_out_of_our_own_reach() { let Some(window) = foreground() else { From 8cc7991c9e15d99854b37251d51bba1ede091772 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 02:26:43 -0300 Subject: [PATCH 03/15] feat(app): where each copy came from, what failed, and where the logs live --- app/src-tauri/src/keys.rs | 52 ++++++++++++++++++++++++++++-- app/src-tauri/src/lib.rs | 6 ++-- app/src-tauri/src/note.rs | 26 ++++++++++++--- app/src/core.ts | 14 ++++++++ app/src/index.css | 5 +++ app/src/locales.ts | 4 +++ app/src/tests/setup.ts | 3 ++ app/src/tests/window.test.tsx | 18 +++++++++++ app/src/ui/General.tsx | 9 ++++-- crates/cp-panel/src/app.rs | 18 ++++++++--- crates/cp-panel/src/engine.rs | 47 +++++++++++++++++++++------ crates/cp-panel/src/note.rs | 25 +++++++++++--- crates/cp-win-sys/src/frontmost.rs | 6 +++- crates/cp-win-sys/src/source.rs | 17 ++++++++++ 14 files changed, 218 insertions(+), 32 deletions(-) diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs index c0f8d84..fa54ab4 100644 --- a/app/src-tauri/src/keys.rs +++ b/app/src-tauri/src/keys.rs @@ -1,19 +1,61 @@ -use tauri::{AppHandle, Runtime}; +use crate::note::note; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; +#[derive(Default)] +pub struct Bound(Mutex>); + +#[derive(serde::Serialize)] +pub struct Keys { + wanted: String, + bound: bool, +} + +pub fn raise(app: &AppHandle, said: &str) { + app.manage(Bound::default()); + match bind(app, said) { + Ok(()) => note(&format!("el atajo «{said}» responde")), + Err(why) => note(&format!("sin atajo del panel: {why}")), + } +} + pub fn bind(app: &AppHandle, said: &str) -> Result<(), String> { let wanted: Shortcut = said .parse() .map_err(|_| format!("«{said}» no es una combinación que el sistema entienda"))?; let keys = app.global_shortcut(); let _ = keys.unregister_all(); + remember(app, None); let handle = app.clone(); keys.on_shortcut(wanted, move |_app, _shortcut, event| { if event.state() == ShortcutState::Pressed { crate::panel::show(&handle); } }) - .map_err(|why| format!("el sistema no cedió «{said}»: {why}")) + .map_err(|why| format!("el sistema no cedió «{said}»: {why}"))?; + remember(app, Some(said)); + Ok(()) +} + +fn remember(app: &AppHandle, said: Option<&str>) { + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + { + *held = said.map(str::to_owned); + } +} + +#[tauri::command] +pub fn keys(app: tauri::AppHandle) -> Keys { + let wanted = crate::settings::settings() + .map(|one| one.shortcut) + .unwrap_or_else(|_| cp_config::SHORTCUT.to_owned()); + let bound = app + .try_state::() + .and_then(|state| state.0.lock().ok().map(|held| held.is_some())) + .unwrap_or(false); + Keys { wanted, bound } } #[cfg(test)] @@ -30,4 +72,10 @@ mod tests { assert!("".parse::().is_err()); assert!("Ctrl+".parse::().is_err()); } + + #[test] + fn nothing_is_bound_until_the_system_says_yes() { + let bound = Bound::default(); + assert!(bound.0.lock().expect("sin envenenar").is_none()); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 6dae619..7e9dd8d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ pub fn run() { settings::former, waking::waking, waking::wake, + keys::keys, relabel ]) .setup(|app| { @@ -33,10 +34,7 @@ pub fn run() { let wanted = kept .as_ref() .map_or(cp_config::SHORTCUT, |one| one.shortcut.as_str()); - match keys::bind(app.handle(), wanted) { - Ok(()) => note::note(&format!("el atajo «{wanted}» responde")), - Err(why) => note::note(&format!("sin atajo del panel: {why}")), - } + keys::raise(app.handle(), wanted); Ok(()) }) .build(tauri::generate_context!()) diff --git a/app/src-tauri/src/note.rs b/app/src-tauri/src/note.rs index 895957a..5ba3b56 100644 --- a/app/src-tauri/src/note.rs +++ b/app/src-tauri/src/note.rs @@ -1,15 +1,26 @@ use std::io::Write; use std::path::PathBuf; +const UP_TO: u64 = 1024 * 1024; + pub fn where_to() -> PathBuf { - std::env::temp_dir().join("cp-gui.log") + crate::settings::folder() + .map_or_else(std::env::temp_dir, |dir| dir.join("logs")) + .join("cp-gui.log") } pub fn note(what: &str) { + let path = where_to(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if std::fs::metadata(&path).map_or(0, |it| it.len()) > UP_TO { + let _ = std::fs::write(&path, b""); + } let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) - .open(where_to()) + .open(&path) else { return; }; @@ -30,8 +41,15 @@ mod tests { use super::*; #[test] - fn the_log_sits_among_the_other_temporary_files() { - assert_eq!(where_to().parent(), Some(std::env::temp_dir().as_path())); + fn the_log_sits_where_the_privacy_note_says_it_does() { + let path = where_to(); + assert_eq!( + path.file_name().and_then(|it| it.to_str()), + Some("cp-gui.log") + ); + if let Some(dir) = crate::settings::folder() { + assert!(path.starts_with(dir)); + } } #[test] diff --git a/app/src/core.ts b/app/src/core.ts index 8711cfb..cde1f7e 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -83,6 +83,20 @@ export function whereItLives() { return invoke("where_it_lives"); } +export type Keys = { wanted: string; bound: boolean }; + +export function useKeys() { + const [keys, setKeys] = useState(null); + + useEffect(() => { + invoke("keys") + .then(setKeys) + .catch(() => setKeys(null)); + }, []); + + return keys; +} + export type Waking = { offered: boolean; wakes: boolean; theirs: boolean }; export function useWaking() { diff --git a/app/src/index.css b/app/src/index.css index a207843..3d4982a 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -750,3 +750,8 @@ button:disabled { transition: none; } } + +.keys.taken { + opacity: 0.55; + text-decoration: line-through; +} diff --git a/app/src/locales.ts b/app/src/locales.ts index 4e9e2ad..880c666 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -25,6 +25,8 @@ const ES = { keys: "Atajo del panel", keysWhy: "Presiónalo en cualquier parte: lo que elijas se pega donde estabas escribiendo", keysChange: "Cambiar", + keysTaken: + "Otro programa ya usa esa combinación, así que el panel no se abre con ella. Elige otra cuando puedas cambiarla", hides: "Ocultar al hacer clic fuera", hidesWhy: "El panel se va solo en cuanto haces clic en otra ventana", @@ -129,6 +131,8 @@ const EN: Record = { keys: "Panel shortcut", keysWhy: "Press it anywhere: what you pick is pasted where you were typing", keysChange: "Change", + keysTaken: + "Another program already uses that combination, so the panel will not open with it. Pick another one when you can change it", hides: "Hide when you click elsewhere", hidesWhy: "The panel goes away on its own as soon as you click another window", diff --git a/app/src/tests/setup.ts b/app/src/tests/setup.ts index 8743a08..ac167d1 100644 --- a/app/src/tests/setup.ts +++ b/app/src/tests/setup.ts @@ -29,6 +29,9 @@ vi.mock("@tauri-apps/api/core", () => ({ return Promise.resolve(config); } if (what === "relabel") return Promise.resolve(null); + if (what === "keys") { + return Promise.resolve({ wanted: "Ctrl+Alt+V", bound: true }); + } if (what === "waking") { return Promise.resolve({ offered: true, wakes: false, theirs: false }); } diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index b889980..bcc4226 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -86,6 +86,24 @@ describe("la ventana", () => { ); }); + it("dice que el atajo no responde cuando otro programa lo tiene tomado", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + const real = vi.mocked(invoke).getMockImplementation(); + vi.mocked(invoke).mockImplementation(((what: string, args?: never) => + what === "keys" + ? Promise.resolve({ wanted: "Ctrl+Alt+V", bound: false }) + : real?.(what, args)) as never); + render(); + expect(await screen.findByText(/Otro programa ya usa esa combinación/)).toBeDefined(); + vi.mocked(invoke).mockImplementation(real as never); + }); + + it("calla sobre el atajo cuando el sistema sí lo cedió", async () => { + render(); + expect(await screen.findByText("Ctrl + Alt + V")).toBeDefined(); + expect(screen.queryByText(/Otro programa ya usa/)).toBeNull(); + }); + it("no afirma que está actualizada cuando nadie lo ha comprobado", async () => { const who = userEvent.setup(); render(); diff --git a/app/src/ui/General.tsx b/app/src/ui/General.tsx index caf66fc..42ad81f 100644 --- a/app/src/ui/General.tsx +++ b/app/src/ui/General.tsx @@ -1,4 +1,4 @@ -import { type Kept, type Look, useWaking } from "../core"; +import { type Kept, type Look, useKeys, useWaking } from "../core"; import { t } from "../locales"; import { Band, Knob, Line } from "./Bits"; @@ -10,6 +10,7 @@ export default function General({ change: (what: Partial) => void; }) { const { waking, trouble, ask } = useWaking(); + const keys = useKeys(); return ( <> @@ -66,8 +67,10 @@ export default function General({ )} - - {kept.shortcut.replaceAll("+", " + ")} + + + {kept.shortcut.replaceAll("+", " + ")} + diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 13ddb6b..6aba480 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -313,7 +313,7 @@ impl App { if handed { deliver(&ui, &state); } else { - complain(&ui); + complain(&ui, BUSY); } } }); @@ -480,7 +480,7 @@ impl App { if paste_as(&store, engine.as_deref(), id, key.as_str()) { deliver(&ui, &state); } else { - complain(&ui); + complain(&ui, BUSY); } } Asking::Kinds => { @@ -504,7 +504,7 @@ impl App { if done { deliver(&ui, &state); } else { - complain(&ui); + complain(&ui, BUSY); } } }); @@ -867,8 +867,11 @@ fn vanish(ui: &Panel) { }); } -fn complain(ui: &Panel) { - ui.set_count_text("no se pudo pegar: el portapapeles está ocupado".into()); +const BUSY: &str = "no se pudo pegar: el portapapeles está ocupado"; +const NOT_THERE: &str = "está copiado, pero no se pudo pegar ahí"; + +fn complain(ui: &Panel, said: &str) { + ui.set_count_text(said.into()); let weak = ui.as_weak(); slint::Timer::single_shot(Duration::from_millis(2_200), move || { if let Some(ui) = weak.upgrade() { @@ -1005,6 +1008,11 @@ fn deliver(ui: &Panel, state: &Rc>) { }); if let cp_win::paste::Outcome::Degraded(why) = sent { note(&format!("queda en el portapapeles, sin pegar: {why:?}")); + if ui.show().is_ok() { + forward(ui); + appear(ui); + complain(ui, NOT_THERE); + } } return; } diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 2fc886e..081c932 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -64,11 +64,12 @@ fn kept(store: &Store) -> Option { } Captured::Nothing | Captured::Superseded => return None, }; - keep(store, &item, crate::app::now_ms()) + let from = cp_win_sys::source::in_front(); + keep(store, &item, crate::app::now_ms(), from.as_deref()) } #[cfg(target_os = "windows")] -fn keep(store: &Store, item: &Item, at: i64) -> Option { +fn keep(store: &Store, item: &Item, at: i64, from: Option<&str>) -> Option { match store.find_by_hash(item) { Ok(Some(id)) => { if let Err(why) = store.reactivate(id, at) { @@ -86,6 +87,11 @@ fn keep(store: &Store, item: &Item, at: i64) -> Option { return None; } }; + if let Some(from) = from + && let Err(why) = store.set_source(id, from, at) + { + note(&format!("{id} se quedó sin saber de dónde vino: {why}")); + } for job in jobs_for(item) { if let Err(why) = store.enqueue(id, job) { note(&format!("{id} se quedó sin encolar {job}: {why}")); @@ -195,10 +201,21 @@ mod tests { (dir, store) } + #[test] + fn without_a_window_in_front_the_card_simply_has_no_app() { + let (_dir, store) = somewhere(); + let id = keep(&store, &text("de ninguna parte"), 1_000, None).expect("guardado"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listar"); + let mine = page.rows.iter().find(|one| one.id == id).expect("está"); + assert_eq!(mine.app, None); + } + #[test] fn what_is_copied_lands_with_its_preview_and_its_kind() { let (_dir, store) = somewhere(); - let id = keep(&store, &text("lo primero"), 1_000).expect("guardado"); + let id = keep(&store, &text("lo primero"), 1_000, None).expect("guardado"); let kept = store.item(id).expect("leer").expect("sigue ahí"); assert_eq!(kept.kind, Some(Kind::Text)); assert_eq!(store.count().expect("contar"), 1); @@ -207,8 +224,8 @@ mod tests { #[test] fn the_same_thing_copied_twice_is_one_row_that_rises() { let (_dir, store) = somewhere(); - let first = keep(&store, &text("igual"), 1_000).expect("guardado"); - let again = keep(&store, &text("igual"), 5_000).expect("reconocido"); + let first = keep(&store, &text("igual"), 1_000, None).expect("guardado"); + let again = keep(&store, &text("igual"), 5_000, None).expect("reconocido"); assert_eq!(first, again); assert_eq!(store.count().expect("contar"), 1); } @@ -216,24 +233,36 @@ mod tests { #[test] fn two_different_copies_are_two_rows() { let (_dir, store) = somewhere(); - keep(&store, &text("una"), 1_000).expect("guardado"); - keep(&store, &text("otra"), 2_000).expect("guardado"); + keep(&store, &text("una"), 1_000, None).expect("guardado"); + keep(&store, &text("otra"), 2_000, None).expect("guardado"); assert_eq!(store.count().expect("contar"), 2); } #[test] fn an_image_leaves_its_reading_and_its_thumbnail_pending() { let (_dir, store) = somewhere(); - let id = keep(&store, &image(), 1_000).expect("guardada"); + let id = keep(&store, &image(), 1_000, None).expect("guardada"); let mut waiting = store.take_pending("ocr", 2_000, 10).expect("cola"); waiting.extend(store.take_pending("thumb", 2_000, 10).expect("cola")); assert_eq!(waiting, [id, id]); } + #[test] + fn what_was_copied_remembers_the_app_it_came_from() { + let (_dir, store) = somewhere(); + let id = + keep(&store, &text("desde el navegador"), 1_000, Some("chrome")).expect("guardado"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listar"); + let mine = page.rows.iter().find(|one| one.id == id).expect("está"); + assert_eq!(mine.app.as_deref(), Some("chrome")); + } + #[test] fn plain_text_asks_nobody_for_anything() { let (_dir, store) = somewhere(); - keep(&store, &text("sin adornos"), 1_000).expect("guardado"); + keep(&store, &text("sin adornos"), 1_000, None).expect("guardado"); assert!( store .take_pending("thumb", 2_000, 10) diff --git a/crates/cp-panel/src/note.rs b/crates/cp-panel/src/note.rs index 6376f9b..10c90e4 100644 --- a/crates/cp-panel/src/note.rs +++ b/crates/cp-panel/src/note.rs @@ -1,16 +1,33 @@ use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; +const UP_TO: u64 = 1024 * 1024; + pub fn where_to() -> std::path::PathBuf { - std::env::temp_dir().join("cp-panel.log") + folder().join("cp-panel.log") +} + +fn folder() -> std::path::PathBuf { + #[cfg(target_os = "windows")] + if let Some(dir) = cp_win_sys::paths::data_dir() { + return dir.join("logs"); + } + std::env::temp_dir() } pub fn note(what: &str) { let clock = clock_now(); + let path = where_to(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if std::fs::metadata(&path).map_or(0, |it| it.len()) > UP_TO { + let _ = std::fs::write(&path, b""); + } if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) - .open(where_to()) + .open(&path) { let _ = writeln!(file, "{clock} {what}"); } @@ -53,9 +70,9 @@ mod tests { } #[test] - fn the_log_sits_next_to_the_other_temporary_files() { + fn the_log_sits_where_the_privacy_note_says_it_does() { let where_it_is = where_to(); - assert!(where_it_is.starts_with(std::env::temp_dir())); + assert!(where_it_is.starts_with(folder())); assert_eq!( where_it_is.file_name().and_then(|name| name.to_str()), Some("cp-panel.log") diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 558d470..72f3c7f 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -41,11 +41,15 @@ pub fn let_it_come_forward(pid: u32) -> bool { unsafe { AllowSetForegroundWindow(pid) }.is_ok() } +pub fn process_of_is_ours(window: HWND) -> bool { + process_of(window) == Some(std::process::id()) +} + pub fn ahead() -> isize { let Some(window) = foreground() else { return 0; }; - if process_of(window) == Some(std::process::id()) { + if process_of_is_ours(window) { return 0; } window.0 as isize diff --git a/crates/cp-win-sys/src/source.rs b/crates/cp-win-sys/src/source.rs index 808dfc7..36e6e45 100644 --- a/crates/cp-win-sys/src/source.rs +++ b/crates/cp-win-sys/src/source.rs @@ -31,6 +31,15 @@ pub fn name_of(pid: u32) -> Option { Some(stem_of(&path)) } +pub fn in_front() -> Option { + let window = crate::frontmost::foreground()?; + if crate::frontmost::process_of_is_ours(window) { + return None; + } + let named = name_of(process_of(window)?)?; + (!named.is_empty()).then_some(named) +} + fn stem_of(path: &str) -> String { let file = path.rsplit(['\\', '/']).next().unwrap_or(path); file.rsplit_once('.') @@ -52,6 +61,14 @@ mod tests { assert_eq!(stem_of("WINWORD.EXE"), "WINWORD"); } + #[test] + fn what_is_in_front_is_never_ourselves() { + if let Some(named) = in_front() { + assert!(!named.is_empty()); + assert_ne!(Some(named), name_of(std::process::id())); + } + } + #[test] fn a_name_without_a_folder_or_an_extension_still_works() { assert_eq!(stem_of("notepad"), "notepad"); From a8d216cb46591828f7ea21752aef3217a0faec0d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 02:57:23 -0300 Subject: [PATCH 04/15] fix(app): the tools link to their own pages, and no link can outlive its permission --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 11 +- RELEASING.md | 4 + app/package.json | 1 + app/src-tauri/capabilities/default.json | 3 + app/src/tests/links.test.ts | 46 ++++++ app/src/ui/About.tsx | 6 +- crates/cp-panel/Cargo.toml | 1 + crates/cp-panel/src/engine.rs | 210 +++++++++++++++++++++++- crates/cp-win-sys/src/paths.rs | 5 + 11 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 app/src/tests/links.test.ts diff --git a/Cargo.lock b/Cargo.lock index 4efd2ac..4feb562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1033,6 +1033,7 @@ dependencies = [ "cp-store", "cp-win", "cp-win-sys", + "image", "raw-window-handle", "slint", "slint-build", diff --git a/Cargo.toml b/Cargo.toml index fcabe57..b9ddd53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ version = "3.0.0" edition = "2024" rust-version = "1.98" license = "GPL-3.0-only" +homepage = "https://rgdevment.com/copypaste/" repository = "https://github.com/rgdevment/CopyPaste" publish = false diff --git a/README.md b/README.md index 5ff7204..c3d85e4 100644 --- a/README.md +++ b/README.md @@ -684,20 +684,25 @@ CopyPaste follows your system theme automatically — no configuration needed. ## Other Tools by the Same Author -I build free, open source tools focused on privacy and productivity. If you like CopyPaste, you might also find this useful: +I build free, open source tools focused on privacy and productivity. If you like CopyPaste, you might also find these useful:

- + LinkUnbound

-### [LinkUnbound](https://github.com/rgdevment/LinkUnbound) +### [LinkUnbound](https://rgdevment.com/linkunbound/) A free, open source browser picker for Windows and Mac. Every link you click gets intercepted — domain rules open the assigned browser instantly, or a small picker appears near your cursor to let you choose. Resolves Microsoft SafeLinks and redirect wrappers before matching rules. No ads. No telemetry. No accounts. Everything local. +### [Tisty](https://rgdevment.com/tisty/) + +Notes, documents and tasks that stay on your machine, in plain files you can read +without it. Same rules as everything else here: no accounts, no telemetry, no cloud. + --- ## License and Spirit diff --git a/RELEASING.md b/RELEASING.md index f103a9b..9b2bc04 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,6 +65,10 @@ The tap repository already exists and the `homebrew` job rewrites the cask itself, so there is nothing to write by hand. Leave the frozen Linux formulae alone: the job must not sweep them. +The cask's `homepage` is `https://rgdevment.com/copypaste/`, the product page — +not the repository. The same holds anywhere a store or a package manager asks +for a website: the repository is where the code lives, not where a user is sent. + ## Cutting a release Create and push an **annotated, signed** tag on `main`. The tag body is the diff --git a/app/package.json b/app/package.json index b6b03f9..eb3c8dc 100644 --- a/app/package.json +++ b/app/package.json @@ -2,6 +2,7 @@ "name": "copypaste-window", "private": true, "version": "3.0.0", + "homepage": "https://rgdevment.com/copypaste/", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 0d94ffb..2e0ccf4 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -13,6 +13,9 @@ { "identifier": "opener:allow-open-url", "allow": [ + { + "url": "https://rgdevment.com/*" + }, { "url": "https://github.com/*" }, diff --git a/app/src/tests/links.test.ts b/app/src/tests/links.test.ts new file mode 100644 index 0000000..e9f5492 --- /dev/null +++ b/app/src/tests/links.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { LINKS } from "../ui/About"; + +type Allowed = { url: string }; +type Permission = string | { identifier: string; allow?: Allowed[] }; + +const TOOLS = ["https://rgdevment.com/tisty/", "https://rgdevment.com/linkunbound/"]; + +function allowed(): string[] { + const said = readFileSync("src-tauri/capabilities/default.json", "utf8"); + const permissions: Permission[] = JSON.parse(said).permissions; + const opener = permissions.find( + (one) => typeof one !== "string" && one.identifier === "opener:allow-open-url", + ); + if (typeof opener === "string" || !opener?.allow) { + throw new Error("la ventana no tiene permiso para abrir enlaces"); + } + return opener.allow.map((one) => one.url); +} + +function covers(pattern: string, url: string): boolean { + const star = pattern.indexOf("*"); + if (star < 0) { + return pattern === url; + } + return url.startsWith(pattern.slice(0, star)) && url.endsWith(pattern.slice(star + 1)); +} + +describe("los enlaces del acerca de", () => { + it("todos están permitidos, así que ninguno nace muerto", () => { + const patterns = allowed(); + for (const url of [...LINKS, ...TOOLS]) { + expect( + patterns.some((one) => covers(one, url)), + `${url} no lo permite ningún patrón`, + ).toBe(true); + } + }); + + it("las otras herramientas llevan a su propia página, no al repositorio", () => { + for (const url of TOOLS) { + expect(url.startsWith("https://rgdevment.com/")).toBe(true); + } + }); +}); diff --git a/app/src/ui/About.tsx b/app/src/ui/About.tsx index 9150e8b..931b8d4 100644 --- a/app/src/ui/About.tsx +++ b/app/src/ui/About.tsx @@ -18,16 +18,18 @@ const TOOLS = [ name: "Tisty", hue: "#7c6cf0", says: "toolTisty", - at: "https://github.com/rgdevment/Tisty", + at: "https://rgdevment.com/tisty/", }, { name: "LinkUnbound", hue: "#3fbfa6", says: "toolLinkUnbound", - at: "https://github.com/rgdevment/LinkUnbound", + at: "https://rgdevment.com/linkunbound/", }, ]; +export const LINKS = [STARS, SPONSOR, COFFEE, ALTERNATIVE, RATING, PRIVACY, NOTICES]; + export default function About() { const [trouble, setTrouble] = useState(null); const [version, setVersion] = useState(null); diff --git a/crates/cp-panel/Cargo.toml b/crates/cp-panel/Cargo.toml index 8d20c7b..42fa067 100644 --- a/crates/cp-panel/Cargo.toml +++ b/crates/cp-panel/Cargo.toml @@ -19,6 +19,7 @@ cp-win.workspace = true [dev-dependencies] tempfile.workspace = true +image.workspace = true [build-dependencies] slint-build.workspace = true diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 081c932..f245da8 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -14,6 +14,10 @@ use cp_store::Store; pub struct Engine { #[cfg(target_os = "windows")] watching: cp_win::watching::Watching, + #[cfg(target_os = "windows")] + stop: std::sync::Arc, + #[cfg(target_os = "windows")] + errands: Option>, } #[cfg(target_os = "windows")] @@ -25,7 +29,13 @@ impl Engine { fresh(id); } }); - Ok(Self { watching }) + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let errands = errands(db, stop.clone()); + Ok(Self { + watching, + stop, + errands, + }) } pub fn ours(&self) -> bool { @@ -47,6 +57,140 @@ impl Engine { } } +#[cfg(target_os = "windows")] +impl Drop for Engine { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Relaxed); + if let Some(thread) = self.errands.take() { + let _ = thread.join(); + } + } +} + +#[cfg(target_os = "windows")] +const SIDE: i32 = cp_core::thumbnail::MAX_SIDE as i32; +#[cfg(target_os = "windows")] +const NAP: std::time::Duration = std::time::Duration::from_millis(400); +#[cfg(target_os = "windows")] +const LATER: i64 = 60_000; + +#[cfg(target_os = "windows")] +fn errands( + db: &Path, + stop: std::sync::Arc, +) -> Option> { + let store = match Store::open(db) { + Ok(store) => store, + Err(why) => { + note(&format!("nadie enriquece lo copiado: {why}")); + return None; + } + }; + let thumbs = cp_win_sys::paths::thumbs_dir()?; + Some(std::thread::spawn(move || { + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + if !errand(&store, &thumbs) { + std::thread::sleep(NAP); + } + } + })) +} + +#[cfg(target_os = "windows")] +fn errand(store: &Store, thumbs: &Path) -> bool { + let at = crate::app::now_ms(); + if let Some(id) = first_waiting(store, "thumb", at) { + thumbed(store, id, at, thumbs); + return true; + } + if let Some(id) = first_waiting(store, "ocr", at) { + read_out(store, id, at); + return true; + } + false +} + +#[cfg(target_os = "windows")] +fn first_waiting(store: &Store, job: &str, at: i64) -> Option { + match store.take_pending(job, at, 1) { + Ok(waiting) => waiting.into_iter().next(), + Err(why) => { + note(&format!("no se pudo mirar la cola de {job}: {why}")); + None + } + } +} + +#[cfg(target_os = "windows")] +fn thumbed(store: &Store, id: i64, at: i64, thumbs: &Path) { + let Some(png) = store.item(id).ok().flatten().as_ref().and_then(thumb_of) else { + give_up(store, id, "thumb", "no se pudo hacer la miniatura", at); + return; + }; + let Some(landed) = written(thumbs, id, &png) else { + give_up(store, id, "thumb", "la miniatura no se pudo guardar", at); + return; + }; + if let Err(why) = store.set_thumb(id, Some(&landed), at) { + note(&format!("{id} con miniatura sin anotar: {why}")); + } + done(store, id, "thumb"); +} + +#[cfg(target_os = "windows")] +fn read_out(store: &Store, id: i64, at: i64) { + if !cp_win_sys::ocr::is_available() { + done(store, id, "ocr"); + return; + } + let found = store + .item(id) + .ok() + .flatten() + .as_ref() + .and_then(|item| cp_win::content::content_of(item, None).image.map(Vec::from)) + .and_then(|image| cp_win_sys::ocr::text_in(&image)); + if let Some(text) = found + && let Err(why) = store.set_ocr_text(id, &text, at) + { + note(&format!("{id} leída sin anotar: {why}")); + } + done(store, id, "ocr"); +} + +#[cfg(target_os = "windows")] +fn thumb_of(item: &Item) -> Option> { + let content = cp_win::content::content_of(item, None); + if let Some(image) = content.image { + return cp_core::thumbnail::of_image(image, cp_core::thumbnail::MAX_SIDE); + } + let first = content.paths.first()?; + let dib = cp_win_sys::thumbnail::dib_of_file(std::path::Path::new(first), SIDE)?; + cp_core::dib::to_png(&dib) +} + +#[cfg(target_os = "windows")] +fn written(dir: &Path, id: i64, png: &[u8]) -> Option { + std::fs::create_dir_all(dir).ok()?; + let landed = dir.join(format!("{id}.png")); + std::fs::write(&landed, png).ok()?; + Some(landed.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "windows")] +fn done(store: &Store, id: i64, job: &str) { + if let Err(why) = store.work_done(id, job) { + note(&format!("{id} sigue en la cola de {job}: {why}")); + } +} + +#[cfg(target_os = "windows")] +fn give_up(store: &Store, id: i64, job: &str, why: &str, at: i64) { + if let Err(trouble) = store.work_failed(id, job, why, at + LATER) { + note(&format!("{id} sin anotar el fallo de {job}: {trouble}")); + } +} + #[cfg(target_os = "windows")] fn kept(store: &Store) -> Option { let item = match cp_win::capture::capture_insisting( @@ -151,6 +295,21 @@ mod tests { } } + fn drawn(side: u32) -> Item { + let square = image::RgbaImage::from_pixel(side, side, image::Rgba([12, 200, 140, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(square) + .write_to(&mut out, image::ImageFormat::Png) + .expect("dibujar"); + Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(out.into_inner()), + }], + } + } + fn files(paths: &[&str]) -> Item { Item { kind: Some(Kind::File), @@ -259,6 +418,55 @@ mod tests { assert_eq!(mine.app.as_deref(), Some("chrome")); } + #[test] + fn a_picture_ends_up_with_a_thumbnail_it_can_show() { + let (dir, store) = somewhere(); + let thumbs = dir.path().join("thumbs"); + let id = keep(&store, &drawn(600), 1_000, None).expect("guardada"); + assert!(errand(&store, &thumbs), "había trabajo que hacer"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listar"); + let mine = page.rows.iter().find(|one| one.id == id).expect("está"); + let made = mine.thumb_path.as_deref().expect("tiene miniatura"); + assert!(std::path::Path::new(made).exists(), "{made} no se escribió"); + let side = + cp_core::thumbnail::size_of(&std::fs::read(made).expect("leer")).expect("tamaño"); + assert!(side.width <= cp_core::thumbnail::MAX_SIDE); + assert!( + store + .take_pending("thumb", 2_000, 10) + .expect("cola") + .is_empty() + ); + } + + #[test] + fn nothing_waiting_means_nothing_to_do() { + let (dir, store) = somewhere(); + assert!(!errand(&store, &dir.path().join("thumbs"))); + } + + #[test] + fn something_that_cannot_be_drawn_waits_instead_of_spinning() { + let (dir, store) = somewhere(); + let thumbs = dir.path().join("thumbs"); + let id = keep(&store, &image(), 1_000, None).expect("guardada"); + assert!(errand(&store, &thumbs), "lo intentó"); + let now = crate::app::now_ms(); + assert!( + store + .take_pending("thumb", now, 10) + .expect("cola") + .is_empty(), + "no se reintenta de inmediato" + ); + let later = store + .take_pending("thumb", now + LATER + 1_000, 10) + .expect("cola"); + assert_eq!(later, [id], "vuelve a tocarle el turno más tarde"); + } + #[test] fn plain_text_asks_nobody_for_anything() { let (_dir, store) = somewhere(); diff --git a/crates/cp-win-sys/src/paths.rs b/crates/cp-win-sys/src/paths.rs index c4cfd65..42ccace 100644 --- a/crates/cp-win-sys/src/paths.rs +++ b/crates/cp-win-sys/src/paths.rs @@ -13,6 +13,10 @@ pub fn legacy_database() -> Option { Some(data_dir()?.join("clipboard.db")) } +pub fn thumbs_dir() -> Option { + Some(data_dir()?.join("thumbs")) +} + pub fn blobs_dir() -> Option { Some(data_dir()?.join("blobs")) } @@ -32,6 +36,7 @@ mod tests { database().expect("base"), legacy_database().expect("base vieja"), blobs_dir().expect("blobs"), + thumbs_dir().expect("miniaturas"), ] { assert!(path.starts_with(&root), "{path:?} se salió de {root:?}"); } From a9abea8c28c68c9c963bdabe87fc5814ee24702f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 15:30:39 -0300 Subject: [PATCH 05/15] feat(app): retention, quota, emptying and the shortcut stop being decoration --- Cargo.lock | 1 + app/src-tauri/src/lib.rs | 6 ++ app/src-tauri/src/panel.rs | 4 ++ app/src-tauri/src/settings.rs | 11 +++- app/src/core.ts | 34 +++++++++++ app/src/locales.ts | 12 +++- app/src/tests/keys.test.ts | 31 ++++++++++ app/src/tests/setup.ts | 1 + app/src/tests/window.test.tsx | 25 ++++++++ app/src/ui/General.tsx | 34 +++++++++-- app/src/ui/History.tsx | 31 ++++++++-- crates/cp-panel/Cargo.toml | 1 + crates/cp-panel/src/app.rs | 60 +++++++++++++++++++ crates/cp-panel/src/engine.rs | 104 +++++++++++++++++++++++++++++++++ crates/cp-panel/ui/panel.slint | 1 + 15 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 app/src/tests/keys.test.ts diff --git a/Cargo.lock b/Cargo.lock index 4feb562..9676025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1029,6 +1029,7 @@ dependencies = [ name = "cp-panel" version = "3.0.0" dependencies = [ + "cp-config", "cp-core", "cp-store", "cp-win", diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 7e9dd8d..2b5e18f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub fn run() { waking::waking, waking::wake, keys::keys, + empty, relabel ]) .setup(|app| { @@ -49,6 +50,11 @@ pub fn run() { }); } +#[tauri::command] +fn empty(app: tauri::AppHandle) -> Result<(), String> { + panel::empty(&app) +} + #[tauri::command] fn relabel(app: tauri::AppHandle, locale: Option) { tray::reword(&app, tray::spanish(locale.as_deref())); diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs index 6d048e5..7ec5d50 100644 --- a/app/src-tauri/src/panel.rs +++ b/app/src-tauri/src/panel.rs @@ -39,6 +39,10 @@ pub fn hide(app: &AppHandle) { let _ = say(app, "hide"); } +pub fn empty(app: &AppHandle) -> Result<(), String> { + say(app, "empty").map_err(|_| "el panel no está escuchando".to_owned()) +} + pub fn quit(app: &AppHandle) { let _ = say(app, "quit"); if let Some(state) = app.try_state::() diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs index 50c7fcd..c3ccdda 100644 --- a/app/src-tauri/src/settings.rs +++ b/app/src-tauri/src/settings.rs @@ -27,11 +27,18 @@ pub fn settings() -> Result { } #[tauri::command] -pub fn keep(config: Config) -> Result { +pub fn keep(app: tauri::AppHandle, config: Config) -> Result { let dir = folder().ok_or_else(nowhere)?; let path = cp_config::at(&dir); + let before = cp_config::read(&path).ok().map(|one| one.shortcut); cp_config::write(&path, &config).map_err(|why| why.to_string())?; - cp_config::read(&path).map_err(|why| why.to_string()) + let landed = cp_config::read(&path).map_err(|why| why.to_string())?; + if before.as_deref() != Some(landed.shortcut.as_str()) + && let Err(why) = crate::keys::bind(&app, &landed.shortcut) + { + crate::note::note(&format!("el atajo nuevo no se pudo tomar: {why}")); + } + Ok(landed) } #[tauri::command] diff --git a/app/src/core.ts b/app/src/core.ts index cde1f7e..42d210e 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -83,6 +83,40 @@ export function whereItLives() { return invoke("where_it_lives"); } +export function combination(press: { + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + metaKey: boolean; + code: string; +}): string | null { + const held: string[] = []; + if (press.ctrlKey) held.push("Ctrl"); + if (press.altKey) held.push("Alt"); + if (press.shiftKey) held.push("Shift"); + if (press.metaKey) held.push("Cmd"); + const code = press.code; + let key: string | null = null; + if (/^Key[A-Z]$/.test(code)) { + key = code.slice(3); + } else if (/^Digit[0-9]$/.test(code)) { + key = code.slice(5); + } else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) { + key = code; + } else if (code === "Space") { + key = "Space"; + } + if (key === null || held.length === 0) { + return null; + } + held.push(key); + return held.join("+"); +} + +export function empty(): Promise { + return invoke("empty"); +} + export type Keys = { wanted: string; bound: boolean }; export function useKeys() { diff --git a/app/src/locales.ts b/app/src/locales.ts index 880c666..7690ce7 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -25,6 +25,8 @@ const ES = { keys: "Atajo del panel", keysWhy: "Presiónalo en cualquier parte: lo que elijas se pega donde estabas escribiendo", keysChange: "Cambiar", + keysAsk: "Presiona la combinación que quieres", + keysStop: "Dejarlo como está", keysTaken: "Otro programa ya usa esa combinación, así que el panel no se abre con ella. Elige otra cuando puedas cambiarla", hides: "Ocultar al hacer clic fuera", @@ -43,8 +45,10 @@ const ES = { whereUnknown: "No se pudo averiguar", whereOpen: "Abrir carpeta", empty: "Vaciar el historial", - emptyWhy: "Borra todo lo copiado, incluso lo anclado. No se puede deshacer", + emptyWhy: "Borra todo lo copiado y conserva lo que hayas anclado. No se puede deshacer", emptyDo: "Vaciar", + emptySure: "¿Seguro?", + emptyGone: "Listo, el historial quedó vacío", bandCopies: "Exportar e importar", out: "Exportar", @@ -131,6 +135,8 @@ const EN: Record = { keys: "Panel shortcut", keysWhy: "Press it anywhere: what you pick is pasted where you were typing", keysChange: "Change", + keysAsk: "Press the combination you want", + keysStop: "Leave it as it is", keysTaken: "Another program already uses that combination, so the panel will not open with it. Pick another one when you can change it", hides: "Hide when you click elsewhere", @@ -149,8 +155,10 @@ const EN: Record = { whereUnknown: "Could not be found", whereOpen: "Open folder", empty: "Empty the history", - emptyWhy: "Deletes everything you copied, pinned items included. It cannot be undone", + emptyWhy: "Deletes everything you copied and keeps whatever you pinned. It cannot be undone", emptyDo: "Empty", + emptySure: "Sure?", + emptyGone: "Done, the history is empty", bandCopies: "Export and import", out: "Export", diff --git a/app/src/tests/keys.test.ts b/app/src/tests/keys.test.ts new file mode 100644 index 0000000..e981f55 --- /dev/null +++ b/app/src/tests/keys.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { combination } from "../core"; + +function press(code: string, held: Partial>) { + return { + code, + ctrlKey: held.ctrl === true, + altKey: held.alt === true, + shiftKey: held.shift === true, + metaKey: held.meta === true, + }; +} + +describe("la combinación que el usuario presiona", () => { + it("se escribe como el sistema la espera", () => { + expect(combination(press("KeyV", { ctrl: true, alt: true }))).toBe("Ctrl+Alt+V"); + expect(combination(press("F9", { ctrl: true, alt: true }))).toBe("Ctrl+Alt+F9"); + expect(combination(press("Digit1", { ctrl: true, shift: true }))).toBe("Ctrl+Shift+1"); + expect(combination(press("Space", { meta: true, alt: true }))).toBe("Alt+Cmd+Space"); + }); + + it("no acepta una tecla suelta, que secuestraría el teclado entero", () => { + expect(combination(press("KeyV", {}))).toBeNull(); + expect(combination(press("F9", {}))).toBeNull(); + }); + + it("no acepta modificadores sin una tecla de verdad", () => { + expect(combination(press("ControlLeft", { ctrl: true }))).toBeNull(); + expect(combination(press("AltLeft", { ctrl: true, alt: true }))).toBeNull(); + }); +}); diff --git a/app/src/tests/setup.ts b/app/src/tests/setup.ts index ac167d1..f3f6c1c 100644 --- a/app/src/tests/setup.ts +++ b/app/src/tests/setup.ts @@ -29,6 +29,7 @@ vi.mock("@tauri-apps/api/core", () => ({ return Promise.resolve(config); } if (what === "relabel") return Promise.resolve(null); + if (what === "empty") return Promise.resolve(null); if (what === "keys") { return Promise.resolve({ wanted: "Ctrl+Alt+V", bound: true }); } diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index bcc4226..062d092 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -104,6 +104,31 @@ describe("la ventana", () => { expect(screen.queryByText(/Otro programa ya usa/)).toBeNull(); }); + it("cambiar el atajo guarda la combinación que se presiona", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Cambiar" })); + const stop = await screen.findByRole("button", { name: "Dejarlo como está" }); + stop.focus(); + await userEvent.keyboard("{Control>}{Alt>}[F9]{/Alt}{/Control}"); + const kept = vi + .mocked(invoke) + .mock.calls.filter(([what]) => what === "keep") + .pop(); + expect((kept?.[1] as { config: { shortcut: string } }).config.shortcut).toBe("Ctrl+Alt+F9"); + }); + + it("vaciar el historial pide confirmación antes de hacerlo", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Historial" })); + const wipe = await screen.findByRole("button", { name: "Vaciar" }); + await userEvent.click(wipe); + expect(vi.mocked(invoke).mock.calls.some(([what]) => what === "empty")).toBe(false); + await userEvent.click(await screen.findByRole("button", { name: "¿Seguro?" })); + expect(vi.mocked(invoke).mock.calls.some(([what]) => what === "empty")).toBe(true); + }); + it("no afirma que está actualizada cuando nadie lo ha comprobado", async () => { const who = userEvent.setup(); render(); diff --git a/app/src/ui/General.tsx b/app/src/ui/General.tsx index 42ad81f..ab0c281 100644 --- a/app/src/ui/General.tsx +++ b/app/src/ui/General.tsx @@ -1,4 +1,5 @@ -import { type Kept, type Look, useKeys, useWaking } from "../core"; +import { useState } from "react"; +import { combination, type Kept, type Look, useKeys, useWaking } from "../core"; import { t } from "../locales"; import { Band, Knob, Line } from "./Bits"; @@ -11,6 +12,7 @@ export default function General({ }) { const { waking, trouble, ask } = useWaking(); const keys = useKeys(); + const [asking, setAsking] = useState(false); return ( <> @@ -67,12 +69,34 @@ export default function General({ )} - - + + {kept.shortcut.replaceAll("+", " + ")} - diff --git a/app/src/ui/History.tsx b/app/src/ui/History.tsx index 833c8eb..3a2d3c1 100644 --- a/app/src/ui/History.tsx +++ b/app/src/ui/History.tsx @@ -1,6 +1,6 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { useEffect, useState } from "react"; -import { type Kept, whereItLives } from "../core"; +import { empty, type Kept, whereItLives } from "../core"; import { fill, t } from "../locales"; import { Band, Line } from "./Bits"; @@ -14,6 +14,8 @@ export default function History({ change: (what: Partial) => void; }) { const [where, setWhere] = useState(null); + const [sure, setSure] = useState(false); + const [said, setSaid] = useState(null); const days = kept["keeps-days"]; const DAYS = KEPT.includes(days) || days === 0 ? KEPT : [...KEPT, days].sort((a, b) => a - b); @@ -23,6 +25,14 @@ export default function History({ .catch(() => setWhere(null)); }, []); + useEffect(() => { + if (!sure) { + return; + } + const forgets = setTimeout(() => setSure(false), 4_000); + return () => clearTimeout(forgets); + }, [sure]); + return ( <>

{t("railHistory")}

@@ -72,9 +82,22 @@ export default function History({
- - diff --git a/crates/cp-panel/Cargo.toml b/crates/cp-panel/Cargo.toml index 42fa067..3af202f 100644 --- a/crates/cp-panel/Cargo.toml +++ b/crates/cp-panel/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true publish = false [dependencies] +cp-config.workspace = true cp-core.workspace = true cp-store.workspace = true slint.workspace = true diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 6aba480..bc69873 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -19,6 +19,7 @@ const OUT: Duration = Duration::from_millis(130); const NEXT_FRAME: Duration = Duration::from_millis(16); const SETTLES: Duration = Duration::from_millis(70); const HOVERS: Duration = Duration::from_millis(55); +const LOOKS: Duration = Duration::from_millis(250); const SETTLES_SHEET: Duration = Duration::from_millis(260); const AFTER_ROLLING: Duration = Duration::from_millis(220); const JUST_ROLLED: Duration = Duration::from_millis(260); @@ -41,6 +42,7 @@ struct State { metrics: Metrics, asking: Asking, typing: slint::Timer, + leaving: slint::Timer, pointing: slint::Timer, arming: slint::Timer, rolled: Instant, @@ -88,6 +90,7 @@ impl App { metrics, asking: Asking::Kinds, typing: slint::Timer::default(), + leaving: slint::Timer::default(), pointing: slint::Timer::default(), arming: slint::Timer::default(), rolled: Instant::now() - JUST_ROLLED, @@ -441,6 +444,19 @@ impl App { ui.set_query(Default::default()); ui.set_sheet_open(false); refresh(&ui, &state); + watch_leaving(&ui, &state); + } + }); + let ui = self.ui.clone(); + let state = self.state.clone(); + panel.on_emptied(move || { + let store = state.borrow().store.clone(); + match store.clear_all_unpinned(now_ms()) { + Ok(gone) => note(&format!("se vaciaron {gone} elementos sin anclar")), + Err(why) => note(&format!("no se pudo vaciar: {why}")), + } + if let Some(ui) = ui.upgrade() { + refresh(&ui, &state); } }); let ui = self.ui.clone(); @@ -1036,6 +1052,47 @@ fn forward(panel: &Panel) { let _ = panel; } +fn hides_when_left() -> bool { + #[cfg(target_os = "windows")] + { + cp_win_sys::paths::data_dir() + .and_then(|dir| cp_config::read(&cp_config::at(&dir)).ok()) + .is_none_or(|kept| kept.hides_when_left) + } + #[cfg(not(target_os = "windows"))] + { + true + } +} + +fn watch_leaving(ui: &Panel, state: &Rc>) { + let held = state.borrow(); + held.leaving.stop(); + if !hides_when_left() { + return; + } + let weak = ui.as_weak(); + let mine = state.clone(); + let mut was_ours = false; + held.leaving + .start(slint::TimerMode::Repeated, LOOKS, move || { + let Some(ui) = weak.upgrade() else { + return; + }; + if !ui.window().is_visible() { + return; + } + if ahead_now() == 0 { + was_ours = true; + return; + } + if was_ours { + mine.borrow().leaving.stop(); + vanish(&ui); + } + }); +} + fn ahead_now() -> isize { #[cfg(target_os = "windows")] { @@ -1076,6 +1133,9 @@ fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { panel.invoke_focus_search(); }); } + "empty" => { + let _ = ui.upgrade_in_event_loop(|panel| panel.invoke_emptied()); + } "hide" => { ahead.store(0, Ordering::Relaxed); let _ = ui.upgrade_in_event_loop(|panel| vanish(&panel)); diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index f245da8..102afff 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -73,6 +73,10 @@ const SIDE: i32 = cp_core::thumbnail::MAX_SIDE as i32; const NAP: std::time::Duration = std::time::Duration::from_millis(400); #[cfg(target_os = "windows")] const LATER: i64 = 60_000; +#[cfg(target_os = "windows")] +const SWEEPS_EVERY: std::time::Duration = std::time::Duration::from_secs(3_600); +#[cfg(target_os = "windows")] +const A_DAY: i64 = 24 * 60 * 60 * 1_000; #[cfg(target_os = "windows")] fn errands( @@ -88,7 +92,12 @@ fn errands( }; let thumbs = cp_win_sys::paths::thumbs_dir()?; Some(std::thread::spawn(move || { + let mut swept = std::time::Instant::now() - SWEEPS_EVERY; while !stop.load(std::sync::atomic::Ordering::Relaxed) { + if swept.elapsed() >= SWEEPS_EVERY { + sweep(&store); + swept = std::time::Instant::now(); + } if !errand(&store, &thumbs) { std::thread::sleep(NAP); } @@ -96,6 +105,51 @@ fn errands( })) } +#[cfg(target_os = "windows")] +fn sweep(store: &Store) { + let Some(dir) = cp_win_sys::paths::data_dir() else { + return; + }; + let kept = match cp_config::read(&cp_config::at(&dir)) { + Ok(kept) => kept, + Err(why) => { + note(&format!("no se pudo leer qué conservar: {why}")); + return; + } + }; + let policy = policy_of(&kept); + if policy == cp_store::Policy::default() { + return; + } + match store.sweep(&policy, crate::app::now_ms()) { + Ok(swept) => { + if swept.expired + swept.over_bytes + swept.orphans > 0 { + note(&format!( + "se fueron {} por edad, {} por espacio y {} sueltos", + swept.expired, swept.over_bytes, swept.orphans + )); + } + } + Err(why) => note(&format!("no se pudo hacer sitio: {why}")), + } +} + +#[cfg(target_os = "windows")] +fn policy_of(kept: &cp_config::Config) -> cp_store::Policy { + cp_store::Policy { + keep_for: kept + .keeps_days + .filter(|days| *days > 0) + .map(|days| i64::from(days) * A_DAY), + keep_at_most: None, + bytes_at_most: kept + .images_quota_mb + .filter(|mb| *mb > 0) + .map(|mb| i64::from(mb) * 1024 * 1024), + broken_for: None, + } +} + #[cfg(target_os = "windows")] fn errand(store: &Store, thumbs: &Path) -> bool { let at = crate::app::now_ms(); @@ -441,6 +495,56 @@ mod tests { ); } + #[test] + fn what_the_settings_say_becomes_what_the_store_sweeps() { + let kept = cp_config::Config { + keeps_days: Some(30), + images_quota_mb: Some(512), + ..cp_config::Config::default() + }; + let policy = policy_of(&kept); + assert_eq!(policy.keep_for, Some(30 * A_DAY)); + assert_eq!(policy.bytes_at_most, Some(512 * 1024 * 1024)); + } + + #[test] + fn keeping_things_forever_and_without_a_limit_sweeps_nothing() { + let kept = cp_config::Config { + keeps_days: None, + images_quota_mb: None, + ..cp_config::Config::default() + }; + assert_eq!(policy_of(&kept), cp_store::Policy::default()); + let zeroed = cp_config::Config { + keeps_days: Some(0), + images_quota_mb: Some(0), + ..cp_config::Config::default() + }; + assert_eq!(policy_of(&zeroed), cp_store::Policy::default()); + } + + #[test] + fn what_is_older_than_the_setting_goes_and_what_is_pinned_stays() { + let (_dir, store) = somewhere(); + let now = 100 * A_DAY; + let old = keep(&store, &text("de hace mucho"), now - 40 * A_DAY, None).expect("guardado"); + let recent = keep(&store, &text("de ayer"), now - A_DAY, None).expect("guardado"); + let pinned = keep(&store, &text("anclado y viejo"), now - 40 * A_DAY, None).expect("g"); + store.set_pinned(pinned, true, now).expect("anclar"); + let kept = cp_config::Config { + keeps_days: Some(30), + ..cp_config::Config::default() + }; + store.sweep(&policy_of(&kept), now).expect("barrer"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listar"); + let left: Vec = page.rows.iter().map(|one| one.id).collect(); + assert!(!left.contains(&old), "lo viejo tenía que irse"); + assert!(left.contains(&recent), "lo reciente se queda"); + assert!(left.contains(&pinned), "lo anclado nunca caduca"); + } + #[test] fn nothing_waiting_means_nothing_to_do() { let (dir, store) = somewhere(); diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 4190021..1cc8388 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -55,6 +55,7 @@ export component Panel inherits Window { callback ask-keys(); callback reopened(); callback fresh-start(); + callback emptied(); callback arrived(); callback sheet-chosen(string); callback paste-as(int, string); From 435c5b49e784af823e24fa72db44bd122213f30e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 15:33:04 -0300 Subject: [PATCH 06/15] fix(app): the system theme follows the system, and nothing lies in silence --- app/src/index.css | 81 ++++++++++++++++++++++++----------- app/src/tests/theme.test.ts | 23 ++++++++++ app/src/tests/window.test.tsx | 30 +++++++++++++ app/src/ui/Backup.tsx | 3 ++ app/src/ui/History.tsx | 12 ++++-- 5 files changed, 119 insertions(+), 30 deletions(-) create mode 100644 app/src/tests/theme.test.ts diff --git a/app/src/index.css b/app/src/index.css index 3d4982a..d6a24cb 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1,30 +1,4 @@ :root { - --panel: #1a1d2b; - --rail: #171a27; - --card: #1e2132; - --field: #1f2234; - --edge: #2a2e44; - --hair: rgb(255 255 255 / 0.07); - --tile: #262a40; - --ink: #d6d7da; - --title: #bcbdc2; - --muted: #83858e; - --placeholder: #6d6f7b; - --accent: #a5b4fc; - --accent-dim: #7c86c9; - --accent-soft: rgb(165 180 252 / 0.12); - --selected: #282c46; - --selected-edge: #3a3f66; - --danger: #f28b82; - --danger-soft: rgb(242 139 130 / 0.12); - --good: #86efac; - --warn: #fcd34d; - --warn-soft: #46351c; - --ui: "Segoe UI", -apple-system, system-ui, sans-serif; - --mono: "Cascadia Mono", ui-monospace, Consolas, Menlo, monospace; -} - -:root[data-theme="light"] { --panel: #f7f8fb; --rail: #eef0f5; --card: #ffffff; @@ -46,6 +20,61 @@ --good: #2f8f4e; --warn: #9a6b00; --warn-soft: #fdf0d2; + color-scheme: light; + --ui: "Segoe UI", -apple-system, system-ui, sans-serif; + --mono: "Cascadia Mono", ui-monospace, Consolas, Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --panel: #1a1d2b; + --rail: #171a27; + --card: #1e2132; + --field: #1f2234; + --edge: #2a2e44; + --hair: rgb(255 255 255 / 0.07); + --tile: #262a40; + --ink: #d6d7da; + --title: #bcbdc2; + --muted: #83858e; + --placeholder: #6d6f7b; + --accent: #a5b4fc; + --accent-dim: #7c86c9; + --accent-soft: rgb(165 180 252 / 0.12); + --selected: #282c46; + --selected-edge: #3a3f66; + --danger: #f28b82; + --danger-soft: rgb(242 139 130 / 0.12); + --good: #86efac; + --warn: #fcd34d; + --warn-soft: #46351c; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --panel: #1a1d2b; + --rail: #171a27; + --card: #1e2132; + --field: #1f2234; + --edge: #2a2e44; + --hair: rgb(255 255 255 / 0.07); + --tile: #262a40; + --ink: #d6d7da; + --title: #bcbdc2; + --muted: #83858e; + --placeholder: #6d6f7b; + --accent: #a5b4fc; + --accent-dim: #7c86c9; + --accent-soft: rgb(165 180 252 / 0.12); + --selected: #282c46; + --selected-edge: #3a3f66; + --danger: #f28b82; + --danger-soft: rgb(242 139 130 / 0.12); + --good: #86efac; + --warn: #fcd34d; + --warn-soft: #46351c; } * { diff --git a/app/src/tests/theme.test.ts b/app/src/tests/theme.test.ts new file mode 100644 index 0000000..7e97c24 --- /dev/null +++ b/app/src/tests/theme.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const sheet = readFileSync("src/index.css", "utf8"); + +describe("los tres temas", () => { + it("el claro es la base, así que sin elección no se cae al oscuro", () => { + const root = sheet.slice(sheet.indexOf(":root {"), sheet.indexOf("}")); + expect(root).toContain("--panel: #f7f8fb"); + expect(root).toContain("color-scheme: light"); + }); + + it("el del sistema sigue al sistema", () => { + expect(sheet).toContain("@media (prefers-color-scheme: dark)"); + expect(sheet).toContain(':root:not([data-theme="light"])'); + }); + + it("elegir uno gana sobre lo que diga el sistema, en los dos sentidos", () => { + expect(sheet).toContain(':root[data-theme="dark"]'); + const guarded = sheet.indexOf(':root:not([data-theme="light"])'); + expect(guarded).toBeGreaterThan(-1); + }); +}); diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index 062d092..ec489f1 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -104,6 +104,36 @@ describe("la ventana", () => { expect(screen.queryByText(/Otro programa ya usa/)).toBeNull(); }); + it("un tope de imágenes que no está en la lista se muestra tal cual", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + const real = vi.mocked(invoke).getMockImplementation(); + vi.mocked(invoke).mockImplementation(((what: string, args?: never) => + what === "settings" + ? Promise.resolve({ + locale: "es", + theme: "system", + shortcut: "Ctrl+Alt+V", + "hides-when-left": true, + "keeps-days": 30, + "images-quota-mb": 700, + }) + : real?.(what, args)) as never); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Historial" })); + const picked = (await screen.findByLabelText("Espacio para imágenes")) as HTMLSelectElement; + expect(picked.value).toBe("700"); + expect(screen.getByRole("option", { name: "700 MB" })).toBeDefined(); + vi.mocked(invoke).mockImplementation(real as never); + }); + + it("los botones de copia de seguridad dicen que todavía no están", async () => { + render(); + await userEvent.click(await screen.findByRole("button", { name: "Copia de seguridad" })); + const exporta = await screen.findByRole("button", { name: "Exportar…" }); + expect(exporta).toBeDisabled(); + expect(screen.getAllByText("Todavía no").length).toBeGreaterThan(0); + }); + it("cambiar el atajo guarda la combinación que se presiona", async () => { const { invoke } = await import("@tauri-apps/api/core"); render(); diff --git a/app/src/ui/Backup.tsx b/app/src/ui/Backup.tsx index 8baa903..a26510c 100644 --- a/app/src/ui/Backup.tsx +++ b/app/src/ui/Backup.tsx @@ -31,12 +31,14 @@ export default function Backup() { + {t("soon")}
+ {t("soon")} @@ -53,6 +55,7 @@ export default function Backup() { + {t("soon")}
) : ( diff --git a/app/src/ui/History.tsx b/app/src/ui/History.tsx index 3a2d3c1..ab1e7ee 100644 --- a/app/src/ui/History.tsx +++ b/app/src/ui/History.tsx @@ -5,6 +5,7 @@ import { fill, t } from "../locales"; import { Band, Line } from "./Bits"; const KEPT = [7, 30, 90]; +const QUOTA = [0, 256, 512, 1024]; export default function History({ kept, @@ -18,6 +19,8 @@ export default function History({ const [said, setSaid] = useState(null); const days = kept["keeps-days"]; const DAYS = KEPT.includes(days) || days === 0 ? KEPT : [...KEPT, days].sort((a, b) => a - b); + const mb = kept["images-quota-mb"]; + const QUOTAS = QUOTA.includes(mb) ? QUOTA : [...QUOTA, mb].sort((a, b) => a - b); useEffect(() => { whereItLives() @@ -60,10 +63,11 @@ export default function History({ value={String(kept["images-quota-mb"])} onChange={(event) => change({ "images-quota-mb": Number(event.target.value) })} > - - - - + {QUOTAS.map((one) => ( + + ))} From 28f2aec3f583f56440538c6525a4ec56095df66f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 15:38:08 -0300 Subject: [PATCH 07/15] feat(panel): the panel wears the theme you chose, and the system's when you let it --- Cargo.lock | 1 + crates/cp-panel/src/app.rs | 47 +++++++++++++++++++++++++++++++++- crates/cp-panel/ui/theme.slint | 39 ++++++++++++++-------------- crates/cp-win-sys/Cargo.toml | 1 + crates/cp-win-sys/src/lib.rs | 1 + crates/cp-win-sys/src/theme.rs | 29 +++++++++++++++++++++ 6 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 crates/cp-win-sys/src/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 9676025..d56bb72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1072,6 +1072,7 @@ dependencies = [ "image", "windows 0.62.2", "windows-future 0.3.2", + "winreg", ] [[package]] diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index bc69873..38b99b6 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -106,6 +106,7 @@ impl App { panel.global::().set_shadow_blur(0.0); } app.wire(&panel); + dress_theme(&panel); refresh(&panel, &app.state); Ok((panel, app)) } @@ -443,6 +444,7 @@ impl App { if let Some(ui) = ui.upgrade() { ui.set_query(Default::default()); ui.set_sheet_open(false); + dress_theme(&ui); refresh(&ui, &state); watch_leaving(&ui, &state); } @@ -554,7 +556,7 @@ fn dress(panel: &Panel, wanted: &str) { { let backdrop = cp_win_sys::backdrop::Backdrop::from_name(wanted) .unwrap_or(cp_win_sys::backdrop::Backdrop::Mica); - cp_win_sys::backdrop::apply(win32.hwnd.get(), backdrop, true); + cp_win_sys::backdrop::apply(win32.hwnd.get(), backdrop, !wants_light()); } } #[cfg(not(target_os = "windows"))] @@ -1052,6 +1054,32 @@ fn forward(panel: &Panel) { let _ = panel; } +fn light_for(asked: cp_config::Theme, the_system_is_light: bool) -> bool { + match asked { + cp_config::Theme::Light => true, + cp_config::Theme::Dark => false, + cp_config::Theme::System => the_system_is_light, + } +} + +fn wants_light() -> bool { + #[cfg(target_os = "windows")] + { + let asked = cp_win_sys::paths::data_dir() + .and_then(|dir| cp_config::read(&cp_config::at(&dir)).ok()) + .map_or(cp_config::Theme::System, |kept| kept.theme); + light_for(asked, cp_win_sys::theme::wants_light().unwrap_or(false)) + } + #[cfg(not(target_os = "windows"))] + { + light_for(cp_config::Theme::System, false) + } +} + +fn dress_theme(ui: &Panel) { + ui.global::().set_light(wants_light()); +} + fn hides_when_left() -> bool { #[cfg(target_os = "windows")] { @@ -1173,3 +1201,20 @@ fn watch_signals(ui: slint::Weak, dir: std::path::PathBuf) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn choosing_a_theme_wins_over_what_the_system_wants() { + assert!(light_for(cp_config::Theme::Light, false)); + assert!(!light_for(cp_config::Theme::Dark, true)); + } + + #[test] + fn leaving_it_to_the_system_follows_the_system_both_ways() { + assert!(light_for(cp_config::Theme::System, true)); + assert!(!light_for(cp_config::Theme::System, false)); + } +} diff --git a/crates/cp-panel/ui/theme.slint b/crates/cp-panel/ui/theme.slint index a9e35eb..93783f3 100644 --- a/crates/cp-panel/ui/theme.slint +++ b/crates/cp-panel/ui/theme.slint @@ -1,23 +1,24 @@ export global Theme { - in-out property panel: #1A1D2B; - out property panel-border: #262A3C; - out property field: #1F2234; - out property field-border: #2A2E44; - out property card: #1E2132; - out property card-hover: #252940; - out property tile: #262A40; - out property title: #BCBDC2; - out property body: #D6D7DA; - out property mono: #C3C5CC; - out property muted: #83858E; - out property placeholder: #6D6F7B; - out property accent: #A5B4FC; - out property accent-dim: #7C86C9; - out property selected: #282C46; - out property selected-edge: #3A3F66; - out property footer: #1B1E2C; - out property danger: #F28B82; - out property shadow: #00000099; + in-out property light: false; + in-out property panel: light ? #F7F8FB : #1A1D2B; + out property panel-border: light ? #DCDEE8 : #262A3C; + out property field: light ? #FFFFFF : #1F2234; + out property field-border: light ? #D0D3E0 : #2A2E44; + out property card: light ? #FFFFFF : #1E2132; + out property card-hover: light ? #F0F1F7 : #252940; + out property tile: light ? #EEF0F6 : #262A40; + out property title: light ? #3A3D4A : #BCBDC2; + out property body: light ? #21242E : #D6D7DA; + out property mono: light ? #3A3D4A : #C3C5CC; + out property muted: light ? #6B6D7A : #83858E; + out property placeholder: light ? #8A8C99 : #6D6F7B; + out property accent: light ? #4F46E5 : #A5B4FC; + out property accent-dim: light ? #6B63EA : #7C86C9; + out property selected: light ? #E9E9FB : #282C46; + out property selected-edge: light ? #C7C5F2 : #3A3F66; + out property footer: light ? #EEF0F5 : #1B1E2C; + out property danger: light ? #C0392B : #F28B82; + out property shadow: light ? #0000002E : #00000099; in-out property shadow-blur: 22px; out property radius: 16px; out property margin: 18px; diff --git a/crates/cp-win-sys/Cargo.toml b/crates/cp-win-sys/Cargo.toml index f69f8f6..9ec1f15 100644 --- a/crates/cp-win-sys/Cargo.toml +++ b/crates/cp-win-sys/Cargo.toml @@ -10,6 +10,7 @@ publish = false [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true windows-future.workspace = true +winreg = "0.55" [target.'cfg(target_os = "windows")'.dev-dependencies] image.workspace = true diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index 9e1d749..3127a8f 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -14,6 +14,7 @@ pub mod paths; pub mod permissions; pub mod reading; pub mod source; +pub mod theme; pub mod thumbnail; pub mod window; pub mod writing; diff --git a/crates/cp-win-sys/src/theme.rs b/crates/cp-win-sys/src/theme.rs new file mode 100644 index 0000000..e1fb6d7 --- /dev/null +++ b/crates/cp-win-sys/src/theme.rs @@ -0,0 +1,29 @@ +use winreg::RegKey; +use winreg::enums::HKEY_CURRENT_USER; + +const WHERE_IT_LIVES: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; +const WHAT_IT_SAYS: &str = "AppsUseLightTheme"; + +pub fn wants_light() -> Option { + let said: u32 = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(WHERE_IT_LIVES) + .ok()? + .get_value(WHAT_IT_SAYS) + .ok()?; + Some(said == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_always_has_an_opinion_about_its_own_theme() { + assert!(wants_light().is_some()); + } + + #[test] + fn asking_twice_gives_the_same_answer() { + assert_eq!(wants_light(), wants_light()); + } +} From b879cbef39a4e09c5229629e3d9e63f4cb56fcb3 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 15:46:47 -0300 Subject: [PATCH 08/15] fix(ci): what macOS and the linter caught, and this machine could not --- app/src/tests/window.test.tsx | 3 ++- crates/cp-panel/src/app.rs | 4 +++- crates/cp-panel/src/engine.rs | 4 ---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index ec489f1..e62f8a5 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -145,7 +145,8 @@ describe("la ventana", () => { .mocked(invoke) .mock.calls.filter(([what]) => what === "keep") .pop(); - expect((kept?.[1] as { config: { shortcut: string } }).config.shortcut).toBe("Ctrl+Alt+F9"); + const said = kept?.[1] as { config: { shortcut: string } } | undefined; + expect(said?.config.shortcut).toBe("Ctrl+Alt+F9"); }); it("vaciar el historial pide confirmación antes de hacerlo", async () => { diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 38b99b6..9636fb4 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -781,7 +781,7 @@ fn hand_over(store: &Store, engine: Option<&crate::engine::Engine>, id: i64) -> } #[cfg(not(target_os = "windows"))] { - let _ = item; + let _ = (item, engine); false } } @@ -886,6 +886,7 @@ fn vanish(ui: &Panel) { } const BUSY: &str = "no se pudo pegar: el portapapeles está ocupado"; +#[cfg(target_os = "windows")] const NOT_THERE: &str = "está copiado, pero no se pudo pegar ahí"; fn complain(ui: &Panel, said: &str) { @@ -1004,6 +1005,7 @@ pub fn now_ms() -> i64 { .unwrap_or(0) } +#[cfg(target_os = "windows")] fn mark(engine: Option<&crate::engine::Engine>) { let Some(engine) = engine else { return; diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 102afff..4f5bf54 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -51,10 +51,6 @@ impl Engine { ) -> Result { Ok(Self {}) } - - pub fn ours(&self) -> bool { - true - } } #[cfg(target_os = "windows")] From 69b7b4b196b5c9c52f6a0ec84c0661142bd1aec0 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 24 Sep 2026 17:52:02 -0300 Subject: [PATCH 09/15] fix(ci): the shell keeps its entry point on both platforms --- app/src-tauri/src/lib.rs | 1 - app/src-tauri/src/panel.rs | 14 +++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 2b5e18f..38d042a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -30,7 +30,6 @@ pub fn run() { if tray::raise(app.handle(), spanish).is_none() { tray::surface(app.handle()); } - #[cfg(target_os = "windows")] panel::raise(app.handle()); let wanted = kept .as_ref() diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs index 7ec5d50..3c6775f 100644 --- a/app/src-tauri/src/panel.rs +++ b/app/src-tauri/src/panel.rs @@ -1,4 +1,3 @@ -use crate::note::note; use std::sync::Mutex; use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_shell::ShellExt; @@ -8,11 +7,16 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent}; pub struct Sidecar(Mutex>); pub fn raise(app: &AppHandle) { - app.manage(Sidecar::default()); - match light(app) { - Ok(()) => note("el panel queda esperando en segundo plano"), - Err(why) => note(&format!("el panel no arrancó: {why}")), + #[cfg(target_os = "windows")] + { + app.manage(Sidecar::default()); + match light(app) { + Ok(()) => crate::note::note("el panel queda esperando en segundo plano"), + Err(why) => crate::note::note(&format!("el panel no arrancó: {why}")), + } } + #[cfg(not(target_os = "windows"))] + let _ = app; } pub fn show(app: &AppHandle) { From 47a8f3a418b5b8eb5fd6b496caafe2f95f2a6c1c Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 09:29:14 -0300 Subject: [PATCH 10/15] fix(app): three ways the history could vanish, and a panel you could not reach --- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/src/lib.rs | 1 + app/src-tauri/src/panel.rs | 6 ++-- app/src-tauri/src/settings.rs | 13 +++++++ app/src-tauri/src/tray.rs | 14 ++++---- app/src/locales.ts | 8 ++--- app/src/tests/window.test.tsx | 2 +- crates/cp-panel/src/app.rs | 5 ++- crates/cp-panel/src/engine.rs | 68 ++++++++++++++++++++++++----------- crates/cp-store/src/store.rs | 45 +++++++++++++++++++++++ 10 files changed, 128 insertions(+), 36 deletions(-) diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 5613cca..5f979d8 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -15,7 +15,7 @@ all = { level = "deny", priority = -1 } [lib] name = "cp_gui_lib" -crate-type = ["staticlib", "cdylib", "rlib"] +crate-type = ["rlib"] [build-dependencies] tauri-build.workspace = true diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 38d042a..419a105 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub fn run() { relabel ]) .setup(|app| { + settings::settle(); let kept = settings::settings().ok(); let spanish = tray::spanish(kept.as_ref().and_then(|one| one.locale.as_deref())); if tray::raise(app.handle(), spanish).is_none() { diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs index 3c6775f..e1dfcdd 100644 --- a/app/src-tauri/src/panel.rs +++ b/app/src-tauri/src/panel.rs @@ -59,6 +59,7 @@ pub fn quit(app: &AppHandle) { fn light(app: &AppHandle) -> Result<(), tauri_plugin_shell::Error> { let (mut heard, child) = app.shell().sidecar("cp-panel")?.args(["--serve"]).spawn()?; + let whose = child.pid(); if let Some(state) = app.try_state::() && let Ok(mut held) = state.0.lock() && let Some(old) = held.replace(child) @@ -69,7 +70,7 @@ fn light(app: &AppHandle) -> Result<(), tauri_plugin_shell::Error tauri::async_runtime::spawn(async move { while let Some(event) = heard.recv().await { if let CommandEvent::Terminated(_) = event { - forget(&handle); + forget(&handle, whose); break; } } @@ -88,9 +89,10 @@ fn say(app: &AppHandle, what: &str) -> Result<(), Said> { Ok(()) } -fn forget(app: &AppHandle) { +fn forget(app: &AppHandle, whose: u32) { if let Some(state) = app.try_state::() && let Ok(mut held) = state.0.lock() + && held.as_ref().is_some_and(|child| child.pid() == whose) { held.take(); } diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs index c3ccdda..2619131 100644 --- a/app/src-tauri/src/settings.rs +++ b/app/src-tauri/src/settings.rs @@ -20,6 +20,19 @@ fn nowhere() -> String { "no se encontró la carpeta donde CopyPaste guarda lo suyo".to_owned() } +pub fn settle() { + let Some(dir) = folder() else { + return; + }; + let path = cp_config::at(&dir); + if path.exists() { + return; + } + if let Err(why) = cp_config::write(&path, &Config::default()) { + crate::note::note(&format!("los ajustes no se pudieron estrenar: {why}")); + } +} + #[tauri::command] pub fn settings() -> Result { let dir = folder().ok_or_else(nowhere)?; diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs index d4fa509..96215ff 100644 --- a/app/src-tauri/src/tray.rs +++ b/app/src-tauri/src/tray.rs @@ -14,11 +14,11 @@ pub fn spanish(locale: Option<&str>) -> bool { !asked.to_lowercase().starts_with("en") } -fn worded(spanish: bool) -> [&'static str; 2] { +fn worded(spanish: bool) -> [&'static str; 3] { if spanish { - ["Ajustes…", "Salir de CopyPaste"] + ["Mostrar el panel", "Ajustes…", "Salir de CopyPaste"] } else { - ["Settings…", "Quit CopyPaste"] + ["Show the panel", "Settings…", "Quit CopyPaste"] } } @@ -28,10 +28,11 @@ const BAR: &[u8] = include_bytes!("../icons/tray/macos@2x.png"); const BAR: &[u8] = include_bytes!("../icons/tray/windows-32.png"); pub fn raise(app: &AppHandle, spanish: bool) -> Option<()> { - let [asks, leaves] = worded(spanish); + let [shows, asks, leaves] = worded(spanish); + let panel = MenuItem::with_id(app, "panel", shows, true, None::<&str>).ok()?; let settings = MenuItem::with_id(app, "settings", asks, true, None::<&str>).ok()?; let quit = MenuItem::with_id(app, "quit", leaves, true, None::<&str>).ok()?; - let menu = Menu::with_items(app, &[&settings, &quit]).ok()?; + let menu = Menu::with_items(app, &[&panel, &settings, &quit]).ok()?; let tray = TrayIconBuilder::with_id("copypaste") .icon(Image::from_bytes(BAR).ok()?) @@ -39,6 +40,7 @@ pub fn raise(app: &AppHandle, spanish: bool) -> Option<()> { .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { + "panel" => crate::panel::show(app), "settings" => surface(app), "quit" => app.exit(0), _ => {} @@ -61,7 +63,7 @@ pub fn raise(app: &AppHandle, spanish: bool) -> Option<()> { #[cfg(not(target_os = "macos"))] let _ = &tray; - app.manage(Said(Mutex::new(vec![settings, quit]))); + app.manage(Said(Mutex::new(vec![panel, settings, quit]))); Some(()) } diff --git a/app/src/locales.ts b/app/src/locales.ts index 7690ce7..4baa775 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -37,8 +37,8 @@ const ES = { keepsWhy: "Lo más antiguo se borra solo. Lo anclado nunca caduca", keepsDays: "{one} días", keepsForever: "Siempre", - quota: "Espacio para imágenes", - quotaWhy: "Al llegar al tope se borran las imágenes más antiguas; el texto no se toca", + quota: "Espacio del historial", + quotaWhy: "Al llegar al tope se borra lo más antiguo, sea lo que sea; lo anclado se queda", quotaNone: "Sin límite", bandWhere: "Tus datos", where: "Carpeta de datos", @@ -147,8 +147,8 @@ const EN: Record = { keepsWhy: "Older items are deleted automatically. Pinned items never expire", keepsDays: "{one} days", keepsForever: "Forever", - quota: "Space for images", - quotaWhy: "When the limit is reached, the oldest images are deleted; text is never touched", + quota: "History size", + quotaWhy: "When the limit is reached the oldest goes, whatever it is; pinned items stay", quotaNone: "No limit", bandWhere: "Your data", where: "Data folder", diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index e62f8a5..24992ad 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -120,7 +120,7 @@ describe("la ventana", () => { : real?.(what, args)) as never); render(); await userEvent.click(await screen.findByRole("button", { name: "Historial" })); - const picked = (await screen.findByLabelText("Espacio para imágenes")) as HTMLSelectElement; + const picked = (await screen.findByLabelText("Espacio del historial")) as HTMLSelectElement; expect(picked.value).toBe("700"); expect(screen.getByRole("option", { name: "700 MB" })).toBeDefined(); vi.mocked(invoke).mockImplementation(real as never); diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 9636fb4..0c1bb29 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -1150,7 +1150,10 @@ fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { } match said.trim() { "show" => { - ahead.store(ahead_now(), Ordering::Relaxed); + let in_front = ahead_now(); + if in_front != 0 { + ahead.store(in_front, Ordering::Relaxed); + } let dressed = backdrop.clone(); let _ = ui.upgrade_in_event_loop(move |panel| { if panel.show().is_err() { diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 4f5bf54..9ef5598 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -86,7 +86,10 @@ fn errands( return None; } }; - let thumbs = cp_win_sys::paths::thumbs_dir()?; + let Some(thumbs) = cp_win_sys::paths::thumbs_dir() else { + note("no hay dónde dejar las miniaturas"); + return None; + }; Some(std::thread::spawn(move || { let mut swept = std::time::Instant::now() - SWEEPS_EVERY; while !stop.load(std::sync::atomic::Ordering::Relaxed) { @@ -106,7 +109,11 @@ fn sweep(store: &Store) { let Some(dir) = cp_win_sys::paths::data_dir() else { return; }; - let kept = match cp_config::read(&cp_config::at(&dir)) { + let path = cp_config::at(&dir); + if !path.exists() { + return; + } + let kept = match cp_config::read(&path) { Ok(kept) => kept, Err(why) => { note(&format!("no se pudo leer qué conservar: {why}")); @@ -193,13 +200,26 @@ fn read_out(store: &Store, id: i64, at: i64) { done(store, id, "ocr"); return; } - let found = store - .item(id) - .ok() - .flatten() - .as_ref() - .and_then(|item| cp_win::content::content_of(item, None).image.map(Vec::from)) - .and_then(|image| cp_win_sys::ocr::text_in(&image)); + let item = match store.item(id) { + Ok(Some(item)) => item, + Ok(None) => { + done(store, id, "ocr"); + return; + } + Err(why) => { + give_up( + store, + id, + "ocr", + &format!("no se pudo leer para leerla: {why}"), + at, + ); + return; + } + }; + let found = cp_win::content::content_of(&item, None) + .image + .and_then(cp_win_sys::ocr::text_in); if let Some(text) = found && let Err(why) = store.set_ocr_text(id, &text, at) { @@ -236,8 +256,10 @@ fn done(store: &Store, id: i64, job: &str) { #[cfg(target_os = "windows")] fn give_up(store: &Store, id: i64, job: &str, why: &str, at: i64) { - if let Err(trouble) = store.work_failed(id, job, why, at + LATER) { - note(&format!("{id} sin anotar el fallo de {job}: {trouble}")); + match store.work_failed(id, job, why, at + LATER) { + Ok(true) => {} + Ok(false) => note(&format!("{id} se queda sin {job} para siempre: {why}")), + Err(trouble) => note(&format!("{id} sin anotar el fallo de {job}: {trouble}")), } } @@ -307,8 +329,13 @@ fn preview_of(item: &Item) -> String { } #[cfg(target_os = "windows")] -fn name_for(at: i64, item: &Item) -> String { - format!("{at:x}-{:016x}", item.fingerprint()) +fn name_for(at: i64, _item: &Item) -> String { + static TURN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let turn = TURN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let since = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |it| it.subsec_nanos()); + format!("{at:x}-{since:08x}{turn:08x}") } #[cfg(target_os = "windows")] @@ -387,14 +414,13 @@ mod tests { } #[test] - fn the_same_content_copied_twice_gets_the_same_tail_and_a_different_head() { - let one = name_for(1_000, &text("igual")); - let other = name_for(2_000, &text("igual")); - assert_ne!(one, other); - assert_eq!( - one.split_once('-').map(|it| it.1), - other.split_once('-').map(|it| it.1) - ); + fn the_name_never_gives_away_what_was_copied() { + let secret = text("una contraseña"); + let one = name_for(1_000, &secret); + let other = name_for(1_000, &secret); + assert_ne!(one, other, "dos copias iguales no comparten nombre"); + let fingerprint = format!("{:016x}", secret.fingerprint()); + assert!(!one.contains(&fingerprint), "el nombre delata el contenido"); } #[test] diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 158c700..5364102 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -519,6 +519,7 @@ impl Store { } fn erase(&self, id: i64, at: i64) -> Result<()> { + self.drop_thumb(id)?; self.db.execute( "UPDATE items SET deleted_at = ?2, updated_at = ?2, @@ -531,6 +532,20 @@ impl Store { self.release(id) } + fn drop_thumb(&self, id: i64) -> Result<()> { + let path: Option = self + .db + .query_row("SELECT thumb_path FROM items WHERE id = ?1", [id], |row| { + row.get(0) + }) + .optional()? + .flatten(); + if let Some(path) = path { + let _ = std::fs::remove_file(path); + } + Ok(()) + } + fn release(&self, id: i64) -> Result<()> { if let Some(blobs) = &self.blobs { for digest in self.blobs_of(id)? { @@ -1828,6 +1843,36 @@ mod tests { ); } + #[test] + fn deleting_an_item_takes_its_thumbnail_off_the_disk() { + let (dir, store) = on_disk(); + let made = dir.path().join("una.png"); + std::fs::write(&made, b"no es un png, pero pesa").expect("escribir"); + let id = store + .insert_item("uuid-con-miniatura", &sample_item(), "algo", 1) + .expect("insert"); + store + .set_thumb(id, Some(&made.to_string_lossy()), 2) + .expect("miniatura"); + store.mark_deleted(id, 3).expect("borra"); + assert!(!made.exists(), "la miniatura sobrevivió al borrado"); + } + + #[test] + fn emptying_the_history_takes_every_thumbnail_with_it() { + let (dir, store) = on_disk(); + let made = dir.path().join("otra.png"); + std::fs::write(&made, b"tampoco es un png").expect("escribir"); + let id = store + .insert_item("uuid-vaciado", &sample_item(), "algo", 1) + .expect("insert"); + store + .set_thumb(id, Some(&made.to_string_lossy()), 2) + .expect("miniatura"); + store.clear_all_unpinned(3).expect("vacía"); + assert!(!made.exists(), "vaciar dejó la miniatura en el disco"); + } + #[test] fn a_deleted_item_leaves_no_content_behind() { let store = Store::in_memory().expect("esquema"); From 465dbeac54c3fee5773242a35e080889052e8ef1 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 09:37:10 -0300 Subject: [PATCH 11/15] feat(app): the panel can answer back, so a broken history is not a silent one --- app/src-tauri/src/lib.rs | 6 +++++ app/src-tauri/src/panel.rs | 42 ++++++++++++++++++++++++++++++++--- app/src/App.tsx | 6 +++-- app/src/core.ts | 17 ++++++++++++++ app/src/locales.ts | 4 ++++ app/src/tests/setup.ts | 1 + app/src/tests/window.test.tsx | 19 ++++++++++++++++ crates/cp-panel/src/app.rs | 9 +++++--- crates/cp-panel/src/main.rs | 6 ++--- crates/cp-panel/src/note.rs | 13 +++++++++++ 10 files changed, 111 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 419a105..0f04207 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { waking::wake, keys::keys, empty, + trouble, relabel ]) .setup(|app| { @@ -55,6 +56,11 @@ fn empty(app: tauri::AppHandle) -> Result<(), String> { panel::empty(&app) } +#[tauri::command] +fn trouble(app: tauri::AppHandle) -> Option { + panel::trouble(&app) +} + #[tauri::command] fn relabel(app: tauri::AppHandle, locale: Option) { tray::reword(&app, tray::spanish(locale.as_deref())); diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs index e1dfcdd..0969cec 100644 --- a/app/src-tauri/src/panel.rs +++ b/app/src-tauri/src/panel.rs @@ -6,10 +6,39 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent}; #[derive(Default)] pub struct Sidecar(Mutex>); +#[derive(Default)] +pub struct Trouble(Mutex>); + +pub fn trouble(app: &AppHandle) -> Option { + app.try_state::() + .and_then(|state| state.0.lock().ok().and_then(|held| held.clone())) +} + +fn heard_from_panel(app: &AppHandle, said: &str) { + let Some(what) = said.strip_prefix("trouble ") else { + return; + }; + crate::note::note(&format!("el panel avisa: {what}")); + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + { + *held = Some(what.to_owned()); + } +} + +fn all_is_well(app: &AppHandle) { + if let Some(state) = app.try_state::() + && let Ok(mut held) = state.0.lock() + { + held.take(); + } +} + pub fn raise(app: &AppHandle) { #[cfg(target_os = "windows")] { app.manage(Sidecar::default()); + app.manage(Trouble::default()); match light(app) { Ok(()) => crate::note::note("el panel queda esperando en segundo plano"), Err(why) => crate::note::note(&format!("el panel no arrancó: {why}")), @@ -66,12 +95,19 @@ fn light(app: &AppHandle) -> Result<(), tauri_plugin_shell::Error { let _ = old.kill(); } + all_is_well(app); let handle = app.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = heard.recv().await { - if let CommandEvent::Terminated(_) = event { - forget(&handle, whose); - break; + match event { + CommandEvent::Stdout(line) => { + heard_from_panel(&handle, String::from_utf8_lossy(&line).trim()); + } + CommandEvent::Terminated(_) => { + forget(&handle, whose); + break; + } + _ => {} } } }); diff --git a/app/src/App.tsx b/app/src/App.tsx index ad55458..4364f76 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { useKept } from "./core"; -import { t } from "./locales"; +import { useKept, useTrouble } from "./core"; +import { fill, t } from "./locales"; import About from "./ui/About"; import Backup from "./ui/Backup"; import Chrome from "./ui/Chrome"; @@ -19,6 +19,7 @@ type Where = (typeof WHERE)[number]["key"] | "about"; export default function App() { const [where, setWhere] = useState("general"); const { kept, trouble, change, look } = useKept(); + const panelTrouble = useTrouble(); return ( <> @@ -47,6 +48,7 @@ export default function App() {
+ {panelTrouble &&

{fill("panelTrouble", panelTrouble)}

} {trouble && (

{trouble} diff --git a/app/src/core.ts b/app/src/core.ts index 42d210e..0fb4c21 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -113,6 +113,23 @@ export function combination(press: { return held.join("+"); } +export function useTrouble() { + const [said, setSaid] = useState(null); + + useEffect(() => { + const ask = () => { + invoke("trouble") + .then(setSaid) + .catch(() => setSaid(null)); + }; + ask(); + const again = setInterval(ask, 5_000); + return () => clearInterval(again); + }, []); + + return said; +} + export function empty(): Promise { return invoke("empty"); } diff --git a/app/src/locales.ts b/app/src/locales.ts index 4baa775..c949e99 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -27,6 +27,8 @@ const ES = { keysChange: "Cambiar", keysAsk: "Presiona la combinación que quieres", keysStop: "Dejarlo como está", + panelTrouble: + "El panel no está funcionando: {one}. Lo copiado no se está guardando hasta que se resuelva", keysTaken: "Otro programa ya usa esa combinación, así que el panel no se abre con ella. Elige otra cuando puedas cambiarla", hides: "Ocultar al hacer clic fuera", @@ -137,6 +139,8 @@ const EN: Record = { keysChange: "Change", keysAsk: "Press the combination you want", keysStop: "Leave it as it is", + panelTrouble: + "The panel is not working: {one}. Nothing you copy is being kept until this is fixed", keysTaken: "Another program already uses that combination, so the panel will not open with it. Pick another one when you can change it", hides: "Hide when you click elsewhere", diff --git a/app/src/tests/setup.ts b/app/src/tests/setup.ts index f3f6c1c..58c7611 100644 --- a/app/src/tests/setup.ts +++ b/app/src/tests/setup.ts @@ -30,6 +30,7 @@ vi.mock("@tauri-apps/api/core", () => ({ } if (what === "relabel") return Promise.resolve(null); if (what === "empty") return Promise.resolve(null); + if (what === "trouble") return Promise.resolve(null); if (what === "keys") { return Promise.resolve({ wanted: "Ctrl+Alt+V", bound: true }); } diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx index 24992ad..e8d1488 100644 --- a/app/src/tests/window.test.tsx +++ b/app/src/tests/window.test.tsx @@ -86,6 +86,25 @@ describe("la ventana", () => { ); }); + it("avisa cuando el panel no está funcionando, en vez de callarlo", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + const real = vi.mocked(invoke).getMockImplementation(); + vi.mocked(invoke).mockImplementation(((what: string, args?: never) => + what === "trouble" + ? Promise.resolve("no se pudo abrir el historial: base dañada") + : real?.(what, args)) as never); + render(); + expect(await screen.findByText(/no se pudo abrir el historial/)).toBeDefined(); + expect(screen.getByText(/no se está guardando/)).toBeDefined(); + vi.mocked(invoke).mockImplementation(real as never); + }); + + it("no inventa problemas del panel cuando todo va bien", async () => { + render(); + await screen.findByRole("button", { name: "General" }); + expect(screen.queryByText(/no se está guardando/)).toBeNull(); + }); + it("dice que el atajo no responde cuando otro programa lo tiene tomado", async () => { const { invoke } = await import("@tauri-apps/api/core"); const real = vi.mocked(invoke).getMockImplementation(); diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 0c1bb29..43a91d6 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -149,7 +149,7 @@ impl App { }); }) { Ok(engine) => self.state.borrow_mut().engine = Some(Rc::new(engine)), - Err(why) => note(&format!("nadie vigila el portapapeles: {why}")), + Err(why) => crate::note::trouble(&format!("nadie vigila el portapapeles: {why}")), } } @@ -454,8 +454,11 @@ impl App { panel.on_emptied(move || { let store = state.borrow().store.clone(); match store.clear_all_unpinned(now_ms()) { - Ok(gone) => note(&format!("se vaciaron {gone} elementos sin anclar")), - Err(why) => note(&format!("no se pudo vaciar: {why}")), + Ok(gone) => { + note(&format!("se vaciaron {gone} elementos sin anclar")); + crate::note::tell(&format!("emptied {gone}")); + } + Err(why) => crate::note::trouble(&format!("no se pudo vaciar: {why}")), } if let Some(ui) = ui.upgrade() { refresh(&ui, &state); diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 18b2dda..893dfb9 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -27,16 +27,14 @@ fn main() { let store = match cp_store::Store::open(&options.db) { Ok(store) => store, Err(why) => { - note::note(&format!("no se pudo abrir {}: {why}", options.db.display())); - eprintln!("no se pudo abrir {}: {why}", options.db.display()); + note::trouble(&format!("no se pudo abrir el historial: {why}")); std::process::exit(1); } }; let (panel, app) = match app::App::start(store, options.clone()) { Ok(started) => started, Err(why) => { - note::note(&format!("el panel no arrancó: {why}")); - eprintln!("el panel no arrancó: {why}"); + note::trouble(&format!("el panel no se pudo dibujar: {why}")); std::process::exit(1); } }; diff --git a/crates/cp-panel/src/note.rs b/crates/cp-panel/src/note.rs index 10c90e4..3eacc52 100644 --- a/crates/cp-panel/src/note.rs +++ b/crates/cp-panel/src/note.rs @@ -33,6 +33,19 @@ pub fn note(what: &str) { } } +pub fn tell(what: &str) { + use std::io::Write; + let out = std::io::stdout(); + let mut out = out.lock(); + let _ = writeln!(out, "{what}"); + let _ = out.flush(); +} + +pub fn trouble(what: &str) { + note(what); + tell(&format!("trouble {what}")); +} + pub fn catch_panics() { let before = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { From a95d783963734432f0d5a344fa96d1ac4d07c546 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 10:00:01 -0300 Subject: [PATCH 12/15] feat(installer): binaries out of the data folder, and an offer to retire the 2.x --- app/src-tauri/installer.nsi | 993 ++++++++++++++++++++++++++++++++++ app/src-tauri/tauri.conf.json | 3 +- 2 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 app/src-tauri/installer.nsi diff --git a/app/src-tauri/installer.nsi b/app/src-tauri/installer.nsi new file mode 100644 index 0000000..b09e3e7 --- /dev/null +++ b/app/src-tauri/installer.nsi @@ -0,0 +1,993 @@ +Unicode true +ManifestDPIAware true +; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) +; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see +; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 +; https://github.com/tauri-apps/tauri/pull/10106 +ManifestDPIAwareness PerMonitorV2 + +!if "{{compression}}" == "none" + SetCompress off +!else + ; Set the compression algorithm. We default to LZMA. + SetCompressor /SOLID "{{compression}}" +!endif + +; Keep above !include to stay ahead of any plugin command +; see https://github.com/tauri-apps/tauri/pull/15422#discussion_r3289239624 +{{#if signed_plugins_path}} +!addplugindir "{{signed_plugins_path}}" +{{/if}} + +!include MUI2.nsh +!include FileFunc.nsh +!include x64.nsh +!include WordFunc.nsh +!include "utils.nsh" +!include "FileAssociation.nsh" +!include "Win\COM.nsh" +!include "Win\Propkey.nsh" +!include "Win\RestartManager.nsh" +!include "StrFunc.nsh" +${StrCase} +${StrLoc} + +{{#if installer_hooks}} +!include "{{installer_hooks}}" +{{/if}} + +!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + +!define MANUFACTURER "{{manufacturer}}" +!define PRODUCTNAME "{{product_name}}" +!define VERSION "{{version}}" +!define VERSIONWITHBUILD "{{version_with_build}}" +!define HOMEPAGE "{{homepage}}" +!define INSTALLMODE "{{install_mode}}" +!define LICENSE "{{license}}" +!define INSTALLERICON "{{installer_icon}}" +!define SIDEBARIMAGE "{{sidebar_image}}" +!define HEADERIMAGE "{{header_image}}" +!define UNINSTALLERICON "{{uninstaller_icon}}" +!define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}" +!define MAINBINARYNAME "{{main_binary_name}}" +!define MAINBINARYSRCPATH "{{main_binary_path}}" +!define BUNDLEID "{{bundle_id}}" +!define COPYRIGHT "{{copyright}}" +!define OUTFILE "{{out_file}}" +!define ARCH "{{arch}}" +!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" +!define ALLOWDOWNGRADES "{{allow_downgrades}}" +!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" +!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" +!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" +!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" +!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" +!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" +!define MANUKEY "Software\${MANUFACTURER}" +!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" +!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" +!define ESTIMATEDSIZE "{{estimated_size}}" +!define STARTMENUFOLDER "{{start_menu_folder}}" + +Var PassiveMode +Var UpdateMode +Var NoShortcutMode +Var WixMode +Var OldMainBinaryName + +Name "${PRODUCTNAME}" +BrandingText "${COPYRIGHT}" +OutFile "${OUTFILE}" + +; We don't actually use this value as default install path, +; it's just for nsis to append the product name folder in the directory selector +; https://nsis.sourceforge.io/Reference/InstallDir +!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" +InstallDir "${PLACEHOLDER_INSTALL_DIR}" + +VIProductVersion "${VERSIONWITHBUILD}" +VIAddVersionKey "ProductName" "${PRODUCTNAME}" +VIAddVersionKey "FileDescription" "${PRODUCTNAME}" +VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "ProductVersion" "${VERSION}" + +# additional plugins +!addplugindir "${ADDITIONALPLUGINSPATH}" + +; Uninstaller signing command +!if "${UNINSTALLERSIGNCOMMAND}" != "" + !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' +!endif + +; Handle install mode, `perUser`, `perMachine` or `both` +!if "${INSTALLMODE}" == "perMachine" + RequestExecutionLevel admin +!endif + +!if "${INSTALLMODE}" == "currentUser" + RequestExecutionLevel user +!endif + +!if "${INSTALLMODE}" == "both" + !define MULTIUSER_MUI + !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" + !define MULTIUSER_INSTALLMODE_COMMANDLINE + !if "${ARCH}" == "x64" + !define MULTIUSER_USE_PROGRAMFILES64 + !else if "${ARCH}" == "arm64" + !define MULTIUSER_USE_PROGRAMFILES64 + !endif + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" + !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME + !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation + !define MULTIUSER_EXECUTIONLEVEL Highest + !include MultiUser.nsh +!endif + +; Installer icon +!if "${INSTALLERICON}" != "" + !define MUI_ICON "${INSTALLERICON}" +!endif + +; Installer sidebar image +!if "${SIDEBARIMAGE}" != "" + !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" +!endif + +; Enable header images for installer and uninstaller pages when either image is configured. +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!else if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!endif + +; Installer header image +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" +!endif + +; Uninstaller header image +!if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_UNBITMAP "${UNINSTALLERHEADERIMAGE}" +!endif + +; Uninstaller icon +!if "${UNINSTALLERICON}" != "" + !define MUI_UNICON "${UNINSTALLERICON}" +!endif + +; Define registry key to store installer language +!define MUI_LANGDLL_REGISTRY_ROOT "HKCU" +!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" +!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" + +; Installer pages, must be ordered as they appear +; 1. Welcome Page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_WELCOME + +; 2. License Page (if defined) +!if "${LICENSE}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MUI_PAGE_LICENSE "${LICENSE}" +!endif + +; 3. Install mode (if it is set to `both`) +!if "${INSTALLMODE}" == "both" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MULTIUSER_PAGE_INSTALLMODE +!endif + +; 4. Custom page to ask user if he wants to reinstall/uninstall +; only if a previous installation was detected +Var ReinstallPageCheck +Page custom PageReinstall PageLeaveReinstall +Function PageReinstall + ; Uninstall previous WiX installation if exists. + ; + ; A WiX installer stores the installation info in registry + ; using a UUID and so we have to loop through all keys under + ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` + ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} + ; + ; This has a potential issue that there maybe another installation that matches + ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, + ; however, this should be fine since the user will have to confirm the uninstallation + ; and they can chose to abort it if doesn't make sense. + StrCpy $0 0 + wix_loop: + EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 + StrCmp $1 "" wix_loop_done ; Exit loop if there is no more keys to loop on + IntOp $0 $0 + 1 + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" + StrCmp "$R0$R1" "${PRODUCTNAME}${MANUFACTURER}" 0 wix_loop + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" + ${StrCase} $R1 $R0 "L" + ${StrLoc} $R0 $R1 "msiexec" ">" + StrCmp $R0 0 0 wix_loop_done + StrCpy $WixMode 1 + StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" + Goto compare_version + wix_loop_done: + + ; Check if there is an existing installation, if not, abort the reinstall page + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} "$R0$R1" == "" ${|} Abort ${|} + + ; Compare this installar version with the existing installation + ; and modify the messages presented to the user accordingly + compare_version: + StrCpy $R4 "$(older)" + ${If} $WixMode = 1 + ReadRegStr $R0 HKLM "$R6" "DisplayVersion" + ${Else} + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${EndIf} + ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} + + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; Reinstalling the same version + ${If} $R0 = 0 + StrCpy $R1 "$(alreadyInstalledLong)" + StrCpy $R2 "$(addOrReinstall)" + StrCpy $R3 "$(uninstallApp)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" + ; Upgrading + ${ElseIf} $R0 = 1 + StrCpy $R1 "$(olderOrUnknownVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + StrCpy $R3 "$(dontUninstall)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ; Downgrading + ${ElseIf} $R0 = -1 + StrCpy $R1 "$(newerVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + !if "${ALLOWDOWNGRADES}" == "true" + StrCpy $R3 "$(dontUninstall)" + !else + StrCpy $R3 "$(dontUninstallDowngrade)" + !endif + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ${Else} + Abort + ${EndIf} + + ; Skip showing the page if passive + ; + ; Note that we don't call this earlier at the beginning + ; of this function because we need to populate some variables + ; related to current installed version if detected and whether + ; we are downgrading or not. + ${If} $PassiveMode = 1 + Call PageLeaveReinstall + ${Else} + nsDialogs::Create 1018 + Pop $R4 + ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} + + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; Disable this radio button if downgrading and downgrades are disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ; Check the first radio button if this the first time + ; we enter this page or if the second button wasn't + ; selected the last time we were on this page + ${If} $ReinstallPageCheck <> 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 + nsDialogs::Show + ${EndIf} +FunctionEnd +Function PageReinstallUpdateSelection + ${NSD_GetState} $R2 $R1 + ${If} $R1 == ${BST_CHECKED} + StrCpy $ReinstallPageCheck 1 + ${Else} + StrCpy $ReinstallPageCheck 2 + ${EndIf} +FunctionEnd +Function PageLeaveReinstall + ${NSD_GetState} $R2 $R1 + + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version + ; $R1 holds the radio buttons state: + ; 1 => first choice was selected + ; 0 => second choice was selected + ${If} $R0 = 0 ; Same version, proceed + ${If} $R1 = 1 ; User chose to add/reinstall + Goto reinst_done + ${Else} ; User chose to uninstall + Goto reinst_uninstall + ${EndIf} + ${ElseIf} $R0 = 1 ; Upgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${ElseIf} $R0 = -1 ; Downgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${EndIf} + + reinst_uninstall: + HideWindow + ClearErrors + + ${If} $WixMode = 1 + ReadRegStr $R1 HKLM "$R6" "UninstallString" + ExecWait '$R1' $0 + ${Else} + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE + ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P + StrCpy $R1 "$R1 _?=$4" ; append uninstall directory + ExecWait '$R1' $0 + ${EndIf} + + BringToFront + + ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code + + ${If} $0 <> 0 + ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + ; User cancelled wix uninstaller? return to select un/reinstall page + ${If} $WixMode = 1 + ${AndIf} $0 = 1602 + Abort + ${EndIf} + + ; User cancelled NSIS uninstaller? return to select un/reinstall page + ${If} $0 = 1 + Abort + ${EndIf} + + ; Other errors? show generic error message and return to select un/reinstall page + MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" + Abort + ${EndIf} + reinst_done: +FunctionEnd + +; 5. Choose install directory page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_DIRECTORY + +; 6. Start menu shortcut page +Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder + +; 7. Installation page +!insertmacro MUI_PAGE_INSTFILES + +; 8. Finish page +; +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page as a button create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut +; Show run app after installation. +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_FINISH + +Function RunMainBinary + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" +FunctionEnd + +; Uninstaller Pages +; 1. Confirm uninstall page +Var DeleteAppDataCheckbox +Var DeleteAppDataCheckboxState +!define /ifndef WS_EX_LAYOUTRTL 0x00400000 +!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave +Function un.ConfirmLeave + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive +!insertmacro MUI_UNPAGE_CONFIRM + +; 2. Uninstalling Page +!insertmacro MUI_UNPAGE_INSTFILES + +;Languages +{{#each languages}} +!insertmacro MUI_LANGUAGE "{{this}}" +{{/each}} +!insertmacro MUI_RESERVEFILE_LANGDLL +{{#each language_files}} + !include "{{this}}" +{{/each}} + +Function .onInit + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} + + !if "${DISPLAYLANGUAGESELECTOR}" == "true" + !insertmacro MUI_LANGDLL_DISPLAY + !endif + + !insertmacro SetContext + + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" + ; Set default install location + !if "${INSTALLMODE}" == "perMachine" + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else if "${ARCH}" == "arm64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${PRODUCTNAME}" + !endif + + Call RestorePreviousInstallLocation + ${EndIf} + + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd + + +Section EarlyChecks + ; Abort silent installer if downgrades is disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${If} ${Silent} + ; If downgrading + ${If} $R0 = -1 + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 <> 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "$(silentDowngrades)" + ${EndIf} + Abort + ${EndIf} + ${EndIf} + !endif + +SectionEnd + +Section WebView2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" + ${Else} + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: + ${EndIf} + ${EndIf} + ${EndIf} + !endif + ${EndIf} +SectionEnd + +!macro NSIS_HOOK_PREINSTALL + ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}_is1" "UninstallString" + ${If} $R0 != "" + IfSilent former_stays + ${If} $LANGUAGE == ${LANG_SPANISH} + StrCpy $R1 "CopyPaste 2 sigue instalado. Si se queda, las dos versiones se pelearán por el mismo atajo.$\n$\n¿Quitarlo ahora? Tu historial no se toca." + ${Else} + StrCpy $R1 "CopyPaste 2 is still installed. If it stays, both versions will fight over the same shortcut.$\n$\nRemove it now? Your history is left untouched." + ${EndIf} + MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON1 "$R1" IDNO former_stays + ExecWait '$R0 /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' + former_stays: + ${EndIf} +!macroend + +Section Install + SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources_dirs}} + CreateDirectory "$INSTDIR\\{{this}}" + {{/each}} + {{#each resources}} + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{no-escape @key}}" + {{/each}} + + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + ; or when uninstalling + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + !endif + + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + Call CreateOrUpdateStartMenuShortcut + !insertmacro MUI_STARTMENU_WRITE_END + + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif + + ; Auto close this page for passive mode + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function .onInstSuccess + ; Check for `/R` flag only in silent and passive installers because + ; GUI installer has a toggle for the user to (re)start the app + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + ${GetOptions} $CMDLINE "/R" $R0 + ${IfNot} ${Errors} + ${GetOptions} $CMDLINE "/ARGS" $R0 + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + !insertmacro SetContext + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif + + !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd + +Section Uninstall + + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + {{#each resources_ancestors}} + RMDir /REBOOTOK "$INSTDIR\\{{this}}" + {{/each}} + RMDir "$INSTDIR" + + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + ${EndIf} + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + DeleteRegKey SHCTX "${UNINSTKEY}" + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + + SetShellVarContext current + RmDir /r "$LOCALAPPDATA\${PRODUCTNAME}" + RmDir /r "$LOCALAPPDATA\${BUNDLEID}" + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd + +Function Skip + Abort +FunctionEnd + +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd + +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" +FunctionEnd diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 429cb74..efffff9 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -43,7 +43,8 @@ "English", "Spanish" ], - "displayLanguageSelector": false + "displayLanguageSelector": false, + "template": "installer.nsi" } }, "macOS": { From e7e7734aa0a423059deca1f157cbec2146dd7b02 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 10:14:45 -0300 Subject: [PATCH 13/15] ci: the pieces the release will stand on, and a bundle that checks what it packs --- .github/actions/prepare/action.yml | 43 ++++++ .github/actions/sidecar/action.yml | 33 ++++ .github/workflows/bundle.yml | 129 ++++++++++++++++ .github/workflows/ci.yml | 7 +- app/package-lock.json | 233 +++++++++++++++++++++++++++++ app/package.json | 4 +- scripts/sidecar.py | 19 ++- 7 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 .github/actions/prepare/action.yml create mode 100644 .github/actions/sidecar/action.yml create mode 100644 .github/workflows/bundle.yml diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml new file mode 100644 index 0000000..e38613d --- /dev/null +++ b/.github/actions/prepare/action.yml @@ -0,0 +1,43 @@ +name: Prepare the build +description: Rust toolchain, cache, and the release version stamped into the manifest. + +inputs: + version: + description: Version to stamp into Cargo.toml. + required: true + targets: + description: Rust targets to install, comma separated. + required: true + cache-key: + description: Added to the cache key when one job id builds more than one target. + required: false + default: "" + +runs: + using: composite + steps: + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + targets: ${{ inputs.targets }} + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: ${{ inputs.cache-key }} + save-if: false + + - name: Stamp the version + shell: bash + run: | + version="${{ inputs.version }}" + + cargo fetch --locked --offline 2>/dev/null || cargo fetch --locked + + sed -i.bak -e "s/^version = .*/version = \"$version\"/" Cargo.toml + rm -f Cargo.toml.bak + + if ! grep -q "^version = \"$version\"\$" Cargo.toml; then + echo "::error::could not stamp $version into Cargo.toml" + exit 1 + fi + + cargo update --workspace --offline diff --git a/.github/actions/sidecar/action.yml b/.github/actions/sidecar/action.yml new file mode 100644 index 0000000..b5745e9 --- /dev/null +++ b/.github/actions/sidecar/action.yml @@ -0,0 +1,33 @@ +name: The panel the app carries +description: >- + `externalBin` in tauri.conf.json makes the binary a build-time requirement of + the app, so anything that compiles the workspace — a test, a clippy run — + needs it there first, not only a bundle. + +inputs: + profile: + description: debug or release. + required: false + default: debug + target: + description: Rust target the panel is built for, empty for the host. + required: false + default: "" + +runs: + using: composite + steps: + - shell: bash + env: + PY: ${{ runner.os == 'Windows' && 'python' || 'python3' }} + PROFILE: ${{ inputs.profile }} + TARGET: ${{ inputs.target }} + run: | + said="" + if [ "$PROFILE" = "debug" ]; then + said="--debug" + fi + if [ -n "$TARGET" ]; then + said="$said --target $TARGET" + fi + "$PY" scripts/sidecar.py $said diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml new file mode 100644 index 0000000..624e834 --- /dev/null +++ b/.github/workflows/bundle.yml @@ -0,0 +1,129 @@ +name: Bundle + +on: + push: + branches: [main] + paths: + - "app/src-tauri/installer.nsi" + - "app/src-tauri/tauri.conf.json" + - "app/src-tauri/Cargo.toml" + - "app/src-tauri/build.rs" + - "app/package.json" + - "scripts/sidecar.py" + - "Cargo.toml" + - ".github/actions/sidecar/action.yml" + - ".github/workflows/bundle.yml" + - ".github/workflows/release.yml" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + # `paths` on the trigger leaves a required check unreported for good; a skipped job counts as met. + changes: + name: what the branch touched + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + packaging: ${{ steps.look.outputs.packaging }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - id: look + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + if [ -z "$BASE" ]; then + echo "packaging=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + watched='^(app/src-tauri/(installer\.nsi|tauri\.conf\.json|Cargo\.toml|build\.rs)' + watched+='|app/package\.json|scripts/sidecar\.py|Cargo\.toml$' + watched+='|\.github/actions/sidecar/action\.yml$' + watched+='|\.github/workflows/(bundle|release)\.yml$)' + from=$(git merge-base "$BASE" HEAD) + if git diff --name-only --diff-filter=d "$from" HEAD | grep -qE "$watched"; then + echo "packaging=true" >> "$GITHUB_OUTPUT" + else + echo "packaging=false" >> "$GITHUB_OUTPUT" + fi + + nsis: + name: the installer builds + needs: changes + if: needs.changes.outputs.packaging == 'true' + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: bundle + save-if: ${{ github.ref == 'refs/heads/main' }} + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: app/package-lock.json + + - name: Frontend + working-directory: app + run: npm ci + + - uses: ./.github/actions/sidecar + with: + profile: release + + - name: Bundle + working-directory: app + shell: bash + run: npm run tauri build -- --bundles nsis --no-sign + + - name: What the installer was told to do + shell: bash + run: | + setup=$(ls target/release/bundle/nsis/*-setup.exe 2>/dev/null | head -1) || true + if [ -z "$setup" ]; then + echo "::error::the bundler produced no installer" + exit 1 + fi + + script=$(ls target/release/nsis/*/installer.nsi 2>/dev/null | head -1) || true + if [ -z "$script" ]; then + echo "::error::makensis ran but left no script behind to read" + exit 1 + fi + + status=0 + want() { + if ! grep -q "$1" "$script"; then + echo "::error::the generated installer does not $2" + status=1 + fi + } + want 'oname=cp-panel.exe' \ + "lay down the panel; the sidecar was not bundled" + want 'StrCpy .INSTDIR ".LOCALAPPDATA.Programs' \ + "keep the binaries out of the data folder" + want '_is1" "UninstallString"' \ + "look for CopyPaste 2 before copying anything" + want 'RmDir /r ".LOCALAPPDATA.${PRODUCTNAME}"' \ + "point its delete-my-data box at the folder the data is actually in" + exit $status + + - name: How big it came out + shell: bash + run: ls -lh target/release/bundle/nsis/*-setup.exe diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee4e914..cf4117b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,12 +36,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@nextest - - name: The panel travels inside the app, so it has to be there first - env: - PY: ${{ runner.os == 'Windows' && 'python' || 'python3' }} - shell: bash - run: | - "$PY" scripts/sidecar.py --debug + - uses: ./.github/actions/sidecar - run: cargo clippy --workspace --all-targets - run: cargo nextest run --workspace --no-tests=pass diff --git a/app/package-lock.json b/app/package-lock.json index b381b4a..4a7b1ad 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@tauri-apps/cli": "^2.11.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.7", @@ -1720,6 +1721,238 @@ "url": "https://opencollective.com/tauri" } }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.5.tgz", + "integrity": "sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.5", + "@tauri-apps/cli-darwin-x64": "2.11.5", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.5", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.5", + "@tauri-apps/cli-linux-arm64-musl": "2.11.5", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.5", + "@tauri-apps/cli-linux-x64-gnu": "2.11.5", + "@tauri-apps/cli-linux-x64-musl": "2.11.5", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.5", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.5", + "@tauri-apps/cli-win32-x64-msvc": "2.11.5" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.5.tgz", + "integrity": "sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.5.tgz", + "integrity": "sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.5.tgz", + "integrity": "sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.5.tgz", + "integrity": "sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.5.tgz", + "integrity": "sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.5.tgz", + "integrity": "sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.5.tgz", + "integrity": "sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.5.tgz", + "integrity": "sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.5.tgz", + "integrity": "sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.5.tgz", + "integrity": "sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.5.tgz", + "integrity": "sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz", diff --git a/app/package.json b/app/package.json index eb3c8dc..98e2193 100644 --- a/app/package.json +++ b/app/package.json @@ -12,7 +12,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "lint": "biome check src", - "format": "biome check src --write" + "format": "biome check src --write", + "tauri": "tauri" }, "dependencies": { "@tauri-apps/api": "^2", @@ -22,6 +23,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@tauri-apps/cli": "^2.11.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.7", diff --git a/scripts/sidecar.py b/scripts/sidecar.py index e74248c..66e0137 100644 --- a/scripts/sidecar.py +++ b/scripts/sidecar.py @@ -8,7 +8,9 @@ WHERE = ROOT / "app" / "src-tauri" / "binaries" -def triple() -> str: +def asked_for() -> str: + if "--target" in sys.argv: + return sys.argv[sys.argv.index("--target") + 1] said = subprocess.run(["rustc", "-vV"], capture_output=True, text=True, check=True) for line in said.stdout.splitlines(): if line.startswith("host: "): @@ -18,18 +20,25 @@ def triple() -> str: def main() -> int: release = "--debug" not in sys.argv - build = ["cargo", "build", "-p", "cp-panel"] + triple = asked_for() + crossing = "--target" in sys.argv + build = ["cargo", "build", "-p", "cp-panel", "--locked"] if release: build.append("--release") + if crossing: + build += ["--target", triple] subprocess.run(build, cwd=ROOT, check=True) - tail = ".exe" if sys.platform == "win32" else "" - made = ROOT / "target" / ("release" if release else "debug") / f"cp-panel{tail}" + tail = ".exe" if triple.endswith("-msvc") or triple.endswith("-gnu") else "" + where = ROOT / "target" + if crossing: + where = where / triple + made = where / ("release" if release else "debug") / f"cp-panel{tail}" if not made.exists(): raise SystemExit(f"no se encontró {made}") WHERE.mkdir(parents=True, exist_ok=True) - named = f"cp-panel-{triple()}{tail}" + named = f"cp-panel-{triple}{tail}" for landed in (WHERE / named, made.parent / named): shutil.copy2(made, landed) print(f"{landed.relative_to(ROOT)} listo desde {made.relative_to(ROOT)}") From 07e444a4f1531b22cc683c2d3166a93cfa67cac3 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 10:29:45 -0300 Subject: [PATCH 14/15] feat(app): the updater and the channel it will read --- Cargo.lock | 325 ++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + app/src-tauri/Cargo.toml | 1 + app/src-tauri/src/lib.rs | 1 + app/src-tauri/tauri.conf.json | 11 ++ 5 files changed, 339 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d56bb72..fe110fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -997,6 +1006,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-plugin-updater", "winreg", ] @@ -1303,6 +1313,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2106,6 +2127,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2593,6 +2625,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2611,9 +2658,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3742,6 +3791,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4417,6 +4472,18 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -4595,6 +4662,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -4640,6 +4713,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -5355,15 +5442,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -5402,6 +5494,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rowan" version = "0.17.0" @@ -5498,6 +5604,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5525,6 +5704,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -5607,6 +5795,29 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -6228,6 +6439,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" @@ -6324,6 +6541,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -6621,6 +6859,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -6937,6 +7208,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -7348,6 +7629,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" @@ -7798,6 +8085,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -8177,6 +8473,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -8973,6 +9280,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -9008,6 +9321,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.7" diff --git a/Cargo.toml b/Cargo.toml index b9ddd53..e952a4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ tauri-build = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" tauri-plugin-shell = "2" +tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "gif", "bmp", "webp"] } proptest = "1" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 5f979d8..a88db31 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri.workspace = true tauri-plugin-opener.workspace = true tauri-plugin-single-instance.workspace = true tauri-plugin-shell.workspace = true +tauri-plugin-updater.workspace = true tauri-plugin-global-shortcut.workspace = true [target."cfg(windows)".dependencies] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 0f04207..4e36655 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ pub fn run() { })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .invoke_handler(tauri::generate_handler![ settings::settings, diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index efffff9..b672aae 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -53,5 +53,16 @@ "externalBin": [ "binaries/cp-panel" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNCRjc2NTAyRDdEQ0JCRUYKUldUdnU5elhBbVgzTzI0MnhTV3k5RTA4aVdFb2oxUUtGaW5FY0pjUkJ2bkorZllNL0FpeW94b1IK", + "endpoints": [ + "https://raw.githubusercontent.com/rgdevment/CopyPaste/manifest/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } From fc9ae97eddbf58d5a5691d32929206f09acbe451 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 10:40:09 -0300 Subject: [PATCH 15/15] ci: the release and the feed, as LinkUnbound already proved them --- .github/workflows/feed.yml | 174 ++++ .github/workflows/release.yml | 1425 +++++++++++++++++++++++++++++++++ 2 files changed, 1599 insertions(+) create mode 100644 .github/workflows/feed.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml new file mode 100644 index 0000000..5e38134 --- /dev/null +++ b/.github/workflows/feed.yml @@ -0,0 +1,174 @@ +name: Feed + +# What a release checks the moment it publishes, checked again every day and again whenever a +# release is touched by hand. A feed breaks between releases — an asset deleted, one replaced with +# other bytes, a release retired, a branch force-pushed — and nothing else would notice until +# somebody wondered why their CopyPaste had not changed in a month. +on: + schedule: + - cron: "41 7 * * *" + release: + types: [unpublished, deleted] + workflow_dispatch: + +permissions: + contents: read + +jobs: + answers: + name: the feed answers what a copy would read + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Read it the way an installed copy does + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign + feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest" + + # The key every installed copy carries. Verifying against anything else would be + # verifying that the release signs itself, which it always does. + jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub + + # What GitHub calls the latest release is the one published most recently, which is not + # the highest version: a fix published for an older series after a newer one is out would + # be it. The feed follows the versions, so this has to as well. + # Only the series this feed answers for: 1.x asks GitHub directly and never reads it. + newest=$(gh release list --limit 100 --json tagName,isPrerelease,isDraft \ + -q '.[] | select(.isPrerelease == false and .isDraft == false) | .tagName' \ + | sed 's/^v//' | grep -E '^[3-9][0-9]*\.' | sort -V | tail -1 || true) + + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o manifest.json "$feed/release-manifest.json"; then + echo "::error::the feed did not answer. Every copy out there is looking at nothing, \ + and will go on looking at nothing until the branch is put back." + exit 1 + fi + if ! jq -e . manifest.json >/dev/null 2>&1; then + echo "::error::the feed answered with something that is not json" + cat manifest.json + exit 1 + fi + + said=$(jq -r '.latest // ""' manifest.json) + + if [ -z "$newest" ] && [ "$said" != "0.0.0" ]; then + echo "::error::the feed announces «$said» and no release of that series is published" + exit 1 + fi + + # The seed, and only while nothing it answers for has been published. Tied to the + # releases rather than to the string, because a release that went out and failed to + # write the feed leaves it saying 0.0.0 too — which is the one moment this has to + # speak. The 1.x releases are not on this feed at all: that series asks GitHub + # directly and never reads this branch. + seeded=0 + if [ "$said" = "0.0.0" ]; then + if [ -n "$newest" ]; then + echo "::error::the feed is still at its seed while $newest is published. The release \ + went out and the feed was never written, so nobody will be told about it." + exit 1 + fi + echo "::notice::the feed is still at its seed; the stable channel announces nothing yet" + seeded=1 + fi + + if [ "$seeded" = 0 ]; then + echo "the feed says $said; the highest release is $newest" + if [ "$said" != "$newest" ]; then + echo "::error::the feed says «$said» and the highest release is «$newest». Every copy \ + out there is being told about a version that is not the one being published." + exit 1 + fi + fi + + # A channel is more than a version: it carries the address and the signature, and those + # are what decide whether an update announced can actually be taken. Checking that the + # installer answers 200 is not enough — an asset can be deleted and put back with other + # bytes, which answers 200 and fails on every machine in the world. + takes() { + local what="$1" expected="$2" + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o channel.json "$feed/$what"; then + echo "::error::$what did not answer, so what is announced is an update nobody \ + can take" + return 1 + fi + local version + version=$(jq -r '.version // ""' channel.json) + if [ "$version" != "$expected" ]; then + echo "::error::$what offers «$version» and the feed announces «$expected»" + return 1 + fi + + local names + names=$(jq -r '.platforms | keys[]' channel.json 2>/dev/null || true) + if [ -z "$names" ]; then + echo "::error::$what names no platform at all, so it offers nothing to anybody" + return 1 + fi + + local os url + for os in $names; do + url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) + jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url"; then + echo "::error::the installer for $os in $what does not answer: $url" + return 1 + fi + if ! minisign -Vm one.bin -p feed.pub -x one.sig >/dev/null; then + echo "::error::the installer for $os in $what does not verify against the key \ + every installed copy carries. It answers, and it cannot be installed: $url" + return 1 + fi + echo " $os verifies" + done + } + + if [ "$seeded" = 0 ]; then + echo "the stable channel:" + takes latest.json "$said" + fi + + # The candidates' channel exists only between a candidate and the stable release that + # retires it, and that release deletes the evidence along with the file. Unwatched, + # anything that happens to a candidate is invisible for its whole life. + ahead=$(jq -r '.latestPrerelease // ""' manifest.json) + if [ -n "$ahead" ]; then + echo "the candidates' channel, announcing $ahead:" + takes candidate.json "$ahead" + elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \ + "$feed/candidate.json" 2>/dev/null; then + echo "::error::candidate.json is still being served and the feed announces no \ + candidate. A copy that remembers one would be sent to a version nobody is \ + publishing any more." + exit 1 + else + echo "no candidate is announced, and none is served" + fi + + # The branch is the feed and nothing else. Cut with `checkout -B` instead of `--orphan` it + # carries a copy of the whole source, and that copy would sit there ageing unnoticed. + - name: The branch holds only the feed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + held=$(gh api "repos/${{ github.repository }}/contents?ref=manifest" -q '.[].name' | sort) + allowed=$(printf '%s\n' candidate.json latest.json release-manifest.json | sort) + extra=$(comm -23 <(echo "$held") <(echo "$allowed")) + if [ -n "$extra" ]; then + echo "::error::the manifest branch carries files that are not the feed: \ + $(echo "$extra" | tr '\n' ' ')" + exit 1 + fi + echo "the branch holds only: $(echo "$held" | tr '\n' ' ')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a964307 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,1425 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to build. Publishes nothing." + required: false + default: "0.0.0-dispatch" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + BUILD_MACOS: "false" + +jobs: + gate: + name: Style and prose + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + - run: npm ci + - run: npm run lint:md + - name: Frontend style + working-directory: app + run: | + npm ci + npm run lint + + tested: + name: Tested when it landed + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + actions: read + env: + GH_REPO: ${{ github.repository }} + steps: + - name: This commit was tested when it landed + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in $(seq 1 40); do + if ! said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ + --limit 1 --json status,conclusion \ + --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' \ + 2>"$RUNNER_TEMP/gh.err"); then + echo "::error::the run history could not be read: $(cat "$RUNNER_TEMP/gh.err")" + exit 1 + fi + case "$said" in + "completed success") + echo "the commit this tag names passed CI on main" + exit 0 + ;; + "completed "*) + echo "::error::CI on ${GITHUB_SHA:0:8} ended ${said#completed }, so this tag is \ + not a release" + exit 1 + ;; + "") + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/40)" + sleep 30 + ;; + *) + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/40)" + sleep 30 + ;; + esac + done + echo "::error::twenty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + ran: a tag belongs on a commit that reached main and was tested there" + exit 1 + + rust: + name: fmt + clippy + runs-on: windows-latest + timeout-minutes: 25 + env: + RUSTFLAGS: -D warnings + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false + - uses: ./.github/actions/sidecar + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets + + version: + name: Resolve the version + needs: [gate] + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.resolve.outputs.version }} + prerelease: ${{ steps.resolve.outputs.prerelease }} + macos: ${{ steps.resolve.outputs.macos }} + env: + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD != '' }} + HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }} + HAS_APPLE_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + HAS_UPDATER_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD != '' }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_PFX_PASSWORD: ${{ secrets.PFX_PASSWORD != '' }} + steps: + - name: Signing secrets come in pairs + run: | + fail() { echo "::error::$1"; exit 1; } + + if [ "$HAS_PFX" = "true" ] && [ "$HAS_PFX_PASSWORD" != "true" ]; then + fail "PFX_BASE64 is set but PFX_PASSWORD is empty" + fi + if [ "$HAS_P12" = "true" ] && [ "$HAS_P12_PASSWORD" != "true" ]; then + fail "MACOS_CERTIFICATE_P12 is set but MACOS_CERTIFICATE_PASSWORD is empty" + fi + if [ "$HAS_APPLE_ID" = "true" ] && + { [ "$HAS_APPLE_APP_PASSWORD" != "true" ] || [ "$HAS_APPLE_TEAM_ID" != "true" ]; }; then + fail "APPLE_ID is set but APPLE_APP_PASSWORD or APPLE_TEAM_ID is empty" + fi + if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_IDENTITY" != "true" ]; then + fail "MACOS_CERTIFICATE_P12 is set but APPLE_SIGNING_IDENTITY is empty: the app would be bundled unsigned" + fi + if [ "$HAS_UPDATER_KEY" = "true" ] && [ "$HAS_UPDATER_PASSWORD" != "true" ]; then + echo "::notice::the updater key carries no password; that is fine if it was made without one" + fi + if [ "$HAS_UPDATER_KEY" != "true" ]; then + echo "::warning::no updater key; this release cannot be installed by an existing copy" + fi + + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + missing="" + want() { [ "$2" = "true" ] || missing="$missing $1"; } + want TAURI_SIGNING_PRIVATE_KEY "$HAS_UPDATER_KEY" + want PFX_BASE64 "$HAS_PFX" + want PFX_PASSWORD "$HAS_PFX_PASSWORD" + if [ "$BUILD_MACOS" = "true" ]; then + want MACOS_CERTIFICATE_P12 "$HAS_P12" + want MACOS_CERTIFICATE_PASSWORD "$HAS_P12_PASSWORD" + want APPLE_SIGNING_IDENTITY "$HAS_APPLE_IDENTITY" + want APPLE_ID "$HAS_APPLE_ID" + want APPLE_APP_PASSWORD "$HAS_APPLE_APP_PASSWORD" + want APPLE_TEAM_ID "$HAS_APPLE_TEAM_ID" + fi + [ -z "$missing" ] || fail "this tag would be released without:$missing" + fi + + echo "signing secrets are coherent" + + - id: resolve + env: + ASKED_FOR: ${{ inputs.version }} + run: | + if [[ "$GITHUB_REF" =~ ^refs/tags/v(.+)$ ]]; then + version="${BASH_REMATCH[1]}" + else + version="$ASKED_FOR" + fi + + strict='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + strict+='(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + strict+='(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if [[ ! "$version" =~ $strict ]]; then + echo "::error::«$version» is not a version semver reads (1.2.3 or 1.2.3-rc.1)" + exit 1 + fi + + if [[ "$version" =~ -rc[0-9]{2,} ]]; then + echo "::error::«$version» has to be written rc.${BASH_REMATCH[0]#-rc}: past rc9, \ + semver reads rc10 as older than rc2" + exit 1 + fi + + prerelease=false + if [[ "$version" == *-* ]]; then + prerelease=true + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "macos=${BUILD_MACOS}" >> "$GITHUB_OUTPUT" + echo "version $version (pre-release: $prerelease)" + + bundle-windows: + name: copypaste-windows-installer + needs: version + runs-on: windows-latest + timeout-minutes: 60 + outputs: + stage: ${{ steps.stage.outputs.sha }} + env: + TARGET: x86_64-pc-windows-msvc + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/prepare + with: + version: ${{ needs.version.outputs.version }} + targets: ${{ env.TARGET }} + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: app/package-lock.json + + - name: Frontend + working-directory: app + run: npm ci + + - uses: ./.github/actions/sidecar + with: + profile: release + + - name: Teach the bundler to sign what it packs + if: env.HAS_PFX == 'true' + shell: pwsh + env: + PFX_BASE64: ${{ secrets.PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} + run: | + $pfx = Join-Path $env:RUNNER_TEMP "bundler.pfx" + Set-Content -Path $pfx -Value ([Convert]::FromBase64String($env:PFX_BASE64)) -AsByteStream + try { + $said = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $cert = @(Import-PfxCertificate -FilePath $pfx ` + -CertStoreLocation Cert:\CurrentUser\My -Password $said)[0] + } finally { + Remove-Item $pfx -ErrorAction SilentlyContinue + } + + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + @{ bundle = @{ windows = @{ signCommand = @{ + cmd = $signtool.FullName + args = @("sign", "/sha1", $cert.Thumbprint, "/fd", "sha256", + "/tr", "http://timestamp.digicert.com", "/td", "sha256", "%1") + } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 + "CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Host "the bundler signs with $($cert.Thumbprint)" + + - name: Bundle + working-directory: app + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + sign="" + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi + packs="" + if [ -f sign.json ]; then packs="--config sign.json"; fi + npm run tauri build -- --bundles nsis $sign $packs + + - name: The installer carries both binaries + shell: bash + run: | + setup=$(ls target/release/bundle/nsis/*-setup.exe 2>/dev/null | head -1) || true + if [ -z "$setup" ]; then + echo "::error::the bundler produced no installer" + exit 1 + fi + script=$(ls target/release/nsis/*/installer.nsi | head -1) + if [ -z "$script" ] || ! grep -q 'oname=cp-panel.exe' "$script"; then + echo "::error::the generated installer script does not lay down the panel; the sidecar was not bundled" + exit 1 + fi + echo "the installer lays down the panel" + + - name: Set the binaries aside for the MSIX + shell: bash + run: | + mkdir -p stage + cp target/release/CopyPaste.exe stage/ + cp target/release/cp-panel.exe stage/ + + - name: Sign what the MSIX will carry + if: env.HAS_PFX == 'true' + shell: pwsh + run: | + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 ` + stage/CopyPaste.exe stage/cp-panel.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + + - name: What the installer will put on disk is signed + if: env.HAS_PFX == 'true' + shell: pwsh + run: | + $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | + Select-Object -First 1 + if (-not $setup) { Write-Error "no installer to open"; exit 1 } + + $seven = @( + "C:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $seven) { + $seven = (Get-Command 7z, 7za -ErrorAction SilentlyContinue | + Select-Object -First 1).Source + } + if (-not $seven) { Write-Error "no 7-Zip to open the installer with"; exit 1 } + + $peek = Join-Path $env:RUNNER_TEMP "peek" + & $seven x $setup.FullName "-o$peek" -y | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } + + $mine = Get-ChildItem -Path $peek -Recurse -File | + Where-Object { $_.Extension -in ".exe", ".dll" -and $_.FullName -notmatch [regex]::Escape('$PLUGINSDIR') } + if (-not $mine) { Write-Error "the installer holds nothing to run"; exit 1 } + + $status = 0 + foreach ($one in $mine) { + $where = $one.FullName.Substring($peek.Length).TrimStart("\\") + $sig = Get-AuthenticodeSignature $one.FullName + if (-not $sig.SignerCertificate) { + Write-Error "$where goes out unsigned" + $status = 1 + } elseif ($sig.SignerCertificate.Thumbprint -ne $env:CERT_THUMBPRINT) { + Write-Error "$where is signed by $($sig.SignerCertificate.Subject), which is not this build's certificate" + $status = 1 + } elseif (-not $sig.TimeStamperCertificate) { + Write-Error "$where is signed without a timestamp, so it dies with the certificate" + $status = 1 + } else { + Write-Host "$where <- $($sig.SignerCertificate.Subject)" + } + } + exit $status + + - name: What the MSIX job must find unchanged + id: stage + shell: bash + run: | + echo "sha=$(cat stage/CopyPaste.exe stage/cp-panel.exe | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v7 + with: + name: stage-windows + path: stage + retention-days: 5 + if-no-files-found: error + + - name: Sign the installer + if: env.HAS_PFX == 'true' + shell: pwsh + run: | + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | + Select-Object -First 1 + if (-not $setup) { Write-Error "no installer to sign"; exit 1 } + + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 $setup.FullName + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + + $sig = Get-AuthenticodeSignature $setup.FullName + if (-not $sig.TimeStamperCertificate) { + Write-Error "no timestamp: the signature would die with the certificate"; exit 1 + } + if ($sig.SignerCertificate.Thumbprint -ne $env:CERT_THUMBPRINT) { + Write-Error "the installer was signed by $($sig.SignerCertificate.Subject), not by this build's certificate"; exit 1 + } + Write-Host "status: $($sig.Status)" + + - name: Name it after the release + shell: bash + run: | + v="${{ needs.version.outputs.version }}" + mkdir -p dist + setup=$(ls target/release/bundle/nsis/*-setup.exe 2>/dev/null | head -1) || true + if [ -z "$setup" ]; then + echo "::error::the bundler produced no installer" + exit 1 + fi + cp "$setup" "dist/copypaste-installer-$v-windows-x86_64.exe" + ls -l dist + + - name: Sign it for the updater + if: env.HAS_UPDATER_KEY == 'true' + working-directory: app + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + v="${{ needs.version.outputs.version }}" + exe="../dist/copypaste-installer-$v-windows-x86_64.exe" + rm -f "$exe.sig" + npm run tauri -- signer sign "$exe" + test -s "$exe.sig" || { echo "::error::no updater signature was written"; exit 1; } + + - uses: actions/upload-artifact@v7 + with: + name: bundle-windows + path: dist/* + retention-days: 7 + if-no-files-found: error + + bundle-macos: + name: copypaste-macos-installer (${{ matrix.arch }}) + needs: version + if: needs.version.outputs.macos == 'true' + runs-on: macos-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: aarch64 + - target: x86_64-apple-darwin + arch: x86_64 + env: + TARGET: ${{ matrix.target }} + ARCH: ${{ matrix.arch }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/prepare + with: + version: ${{ needs.version.outputs.version }} + targets: ${{ matrix.target }} + cache-key: ${{ matrix.target }} + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: app/package-lock.json + + - name: Frontend + working-directory: app + run: npm ci + + - name: One deployment target for the panel and the app + run: | + floor=$(jq -r '.bundle.macOS.minimumSystemVersion' app/src-tauri/tauri.conf.json) + echo "MACOSX_DEPLOYMENT_TARGET=$floor" >> "$GITHUB_ENV" + + - uses: ./.github/actions/sidecar + with: + profile: release + target: ${{ matrix.target }} + + - name: Import signing certificate + if: env.HAS_P12 == 'true' + env: + MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$MACOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + rm -f "$CERT_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Build + working-directory: app + run: npm run tauri build -- --target "$TARGET" --no-bundle + + - name: Sign the settings binary + if: env.HAS_P12 == 'true' + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + bin="target/$TARGET/release/CopyPaste" + codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --strict --verbose "$bin" + + - name: Bundle + working-directory: app + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + sign="" + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi + npm run tauri bundle -- --target "$TARGET" --bundles app,dmg $sign + + - name: It carries the panel, on one chip + run: | + app="target/$TARGET/release/bundle/macos/CopyPaste.app" + plist="$app/Contents/Info.plist" + test -d "$app" || { echo "::error::the bundler produced no app"; exit 1; } + status=0 + want() { + if [ "$(plutil -extract "$1" raw -o - "$plist" 2>/dev/null)" != "$2" ]; then + echo "::error::$1 is «$(plutil -extract "$1" raw -o - "$plist" 2>/dev/null)», not «$2»" + status=1 + fi + } + want CFBundleExecutable CopyPaste + want CFBundleIdentifier com.rgdevment.copypaste + want CFBundleShortVersionString "${{ needs.version.outputs.version }}" + chip=x86_64; [ "$ARCH" = aarch64 ] && chip=arm64 + for one in cp-panel CopyPaste; do + bin="$app/Contents/MacOS/$one" + test -x "$bin" || { echo "::error::$one is not in the bundle"; status=1; continue; } + archs=$(lipo -archs "$bin") + if [ "$archs" != "$chip" ]; then + echo "::error::$one is «$archs», not $chip alone" + status=1 + fi + echo "$one: $archs" + done + exit $status + + - name: The app is really signed + if: env.HAS_P12 == 'true' + run: | + app="target/$TARGET/release/bundle/macos/CopyPaste.app" + codesign --verify --deep --strict --verbose=2 "$app" + if codesign -dv "$app" 2>&1 | grep -q "Signature=adhoc"; then + echo "::error::$app came out ad-hoc signed; notarisation would refuse it" + exit 1 + fi + + - name: Notarize + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + dmg=$(ls target/$TARGET/release/bundle/dmg/*.dmg | head -1) + xcrun notarytool submit "$dmg" \ + --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait --timeout 900 + xcrun stapler staple "$dmg" + + - name: Staple the app itself, and tar it again + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + out="target/$TARGET/release/bundle/macos" + app="$out/CopyPaste.app" + + ditto -c -k --keepParent "$app" "$RUNNER_TEMP/app.zip" + xcrun notarytool submit "$RUNNER_TEMP/app.zip" \ + --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait --timeout 900 + xcrun stapler staple "$app" + xcrun stapler validate "$app" + + rm -f "$out"/*.app.tar.gz "$out"/*.app.tar.gz.sig + COPYFILE_DISABLE=1 tar --no-xattrs -czf "$out/CopyPaste.app.tar.gz" -C "$out" CopyPaste.app + + rm -rf "$RUNNER_TEMP/rt" && mkdir -p "$RUNNER_TEMP/rt" + tar -xzf "$out/CopyPaste.app.tar.gz" -C "$RUNNER_TEMP/rt" + codesign --verify --deep --strict "$RUNNER_TEMP/rt/CopyPaste.app" + xcrun stapler validate "$RUNNER_TEMP/rt/CopyPaste.app" + + - name: Sign it for the updater + if: env.HAS_UPDATER_KEY == 'true' && env.HAS_APPLE_ID == 'true' + working-directory: app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + out="../target/$TARGET/release/bundle/macos" + npm run tauri -- signer sign "$out/CopyPaste.app.tar.gz" + test -s "$out/CopyPaste.app.tar.gz.sig" + + - name: Name it after the release + run: | + v="${{ needs.version.outputs.version }}" + mkdir -p dist + out="target/$TARGET/release/bundle/macos" + app="$out/CopyPaste.app" + if xcrun stapler validate "$app" >/dev/null 2>&1 \ + && [ -s "$out/CopyPaste.app.tar.gz.sig" ]; then + cp "$out/CopyPaste.app.tar.gz" "dist/copypaste-$v-macos-$ARCH.app.tar.gz" + cp "$out/CopyPaste.app.tar.gz.sig" "dist/copypaste-$v-macos-$ARCH.app.tar.gz.sig" + else + echo "::warning::no stapled, signed tarball; macOS $ARCH gets no self-update this release" + rm -f "$out/CopyPaste.app.tar.gz" "$out/CopyPaste.app.tar.gz.sig" + fi + dmg=$(ls target/$TARGET/release/bundle/dmg/*.dmg 2>/dev/null | head -1) || true + if [ -z "$dmg" ]; then + echo "::error::the bundler produced no disk image" + exit 1 + fi + cp "$dmg" "dist/copypaste-installer-$v-macos-$ARCH.dmg" + ls -l dist + + - uses: actions/upload-artifact@v7 + with: + name: bundle-macos-${{ matrix.arch }} + path: dist/* + retention-days: 7 + if-no-files-found: error + + - name: Cleanup keychain + if: always() && env.KEYCHAIN_PATH != '' + run: security delete-keychain "$KEYCHAIN_PATH" || true + + bundle-msix: + name: copypaste-windows-msix + needs: [version, bundle-windows] + if: needs.version.outputs.prerelease == 'false' + runs-on: windows-latest + timeout-minutes: 20 + outputs: + made: ${{ steps.gate.outputs.go }} + env: + MSIX_IDENTITY: ${{ vars.MSIX_IDENTITY }} + MSIX_PUBLISHER: ${{ vars.MSIX_PUBLISHER }} + MSIX_PUBLISHER_DISPLAY: ${{ vars.MSIX_PUBLISHER_DISPLAY }} + steps: + - name: Nothing to build without a reserved identity + id: gate + shell: bash + run: | + if [ -z "$MSIX_IDENTITY" ] || [ -z "$MSIX_PUBLISHER" ]; then + echo "::notice::MSIX_IDENTITY or MSIX_PUBLISHER not set; no MSIX this run" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v7 + if: steps.gate.outputs.go == 'yes' + + - uses: actions/download-artifact@v8 + if: steps.gate.outputs.go == 'yes' + with: + name: stage-windows + path: msix + + - name: Lay the package out + if: steps.gate.outputs.go == 'yes' + shell: bash + run: | + v="${{ needs.version.outputs.version }}" + export QUAD="${v%%-*}.0" + + test -s msix/CopyPaste.exe + test -s msix/cp-panel.exe + test "$(cat msix/CopyPaste.exe msix/cp-panel.exe | sha256sum | cut -d' ' -f1)" \ + = "${{ needs.bundle-windows.outputs.stage }}" + + mkdir -p msix/Assets + for logo in StoreLogo Square44x44Logo Square71x71Logo Square150x150Logo; do + cp "app/src-tauri/icons/$logo.png" "msix/Assets/$logo.png" + done + + python scripts/fill_manifest.py app/src-tauri/msix/AppxManifest.xml.in msix/AppxManifest.xml + + if grep -q "@[A-Z_]*@" msix/AppxManifest.xml; then + echo "::error::a placeholder survived the manifest" + exit 1 + fi + + - name: Pack + if: steps.gate.outputs.go == 'yes' + shell: pwsh + run: | + $sdk = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Directory | + Where-Object { + (Test-Path "$($_.FullName)\x64\makeappx.exe") -and (Test-Path "$($_.FullName)\x64\makepri.exe") + } | + Sort-Object Name -Descending | Select-Object -First 1 + if (-not $sdk) { Write-Host "::error::no Windows SDK with makeappx and makepri"; exit 1 } + + $v = "${{ needs.version.outputs.version }}" + New-Item -ItemType Directory -Force dist | Out-Null + + & "$($sdk.FullName)\x64\makepri.exe" createconfig /cf priconfig.xml /dq en-US_es-ES /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not write a config"; exit 1 } + & "$($sdk.FullName)\x64\makepri.exe" new /pr msix /cf priconfig.xml /of msix/resources.pri /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not index the package"; exit 1 } + + & "$($sdk.FullName)\x64\makeappx.exe" pack /d msix /p "dist/copypaste-$v-windows-x64.msix" /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makeappx refused the package"; exit 1 } + Get-ChildItem dist + + - name: It went in under the identity 1.x shipped with + if: steps.gate.outputs.go == 'yes' + shell: pwsh + run: | + $sdk = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Directory | + Where-Object { Test-Path "$($_.FullName)\x64\makeappx.exe" } | + Sort-Object Name -Descending | Select-Object -First 1 + $v = "${{ needs.version.outputs.version }}" + $out = "$env:RUNNER_TEMP\unpacked" + & "$($sdk.FullName)\x64\makeappx.exe" unpack /p "dist/copypaste-$v-windows-x64.msix" /d $out /o | Out-Null + + [xml]$m = Get-Content "$out\AppxManifest.xml" + $said = $m.Package.Identity.Name + $quad = ($v -split '-')[0] + '.0' + Write-Host "identity: $said version: $($m.Package.Identity.Version)" + + $was = "rgdevment.CopyPaste-BrowserPicker" + if ($said -ne $was) { + Write-Host "::error::the package went in as $said. 1.x shipped as $was, and under any other name nobody who has it installed is offered this."; exit 1 + } + if ($m.Package.Identity.Version -ne $quad) { + Write-Host "::error::the package says $($m.Package.Identity.Version) and the tag says $quad"; exit 1 + } + $exe = $m.Package.Applications.Application.Executable + if ($exe -ne "cp-panel.exe") { + Write-Host "::error::the package launches $exe, which is not what the shell runs for a link"; exit 1 + } + + - uses: actions/upload-artifact@v7 + if: steps.gate.outputs.go == 'yes' + with: + name: bundle-msix + path: dist/* + retention-days: 7 + if-no-files-found: error + + publish: + name: GitHub Release + needs: [rust, tested, version, bundle-windows, bundle-macos, bundle-msix] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.rust.result == 'success' && needs.tested.result == 'success' + && needs.version.result == 'success' + && needs.bundle-windows.result == 'success' + && (needs.bundle-macos.result == 'success' || needs.bundle-macos.result == 'skipped') + && (needs.bundle-msix.result == 'success' || needs.bundle-msix.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 60 + concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v8 + with: + pattern: bundle-* + path: dist + merge-multiple: true + + - name: Gather + run: | + rm -f dist/*.msix + ls -l dist + + - name: Everything that should be here, is + run: | + v="${{ needs.version.outputs.version }}" + status=0 + want="dist/copypaste-installer-$v-windows-x86_64.exe" + if [ "${BUILD_MACOS}" = "true" ]; then + for arch in aarch64 x86_64; do + want="$want dist/copypaste-$v-macos-$arch.app.tar.gz" + if [ ! -s "dist/copypaste-installer-$v-macos-$arch.dmg" ]; then + echo "::error::the macOS $arch disk image is missing" + status=1 + fi + done + fi + for one in $want; do + if [ ! -s "$one" ]; then + echo "::error::$one is missing or empty" + status=1 + fi + if [ ! -s "$one.sig" ]; then + echo "::error::$one.sig is missing; the release would offer an update nobody can take" + status=1 + fi + done + exit $status + + - name: The signature answers to the key this build ships + run: | + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign + v="${{ needs.version.outputs.version }}" + + jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub + for one in "dist/copypaste-installer-$v-windows-x86_64.exe" \ + "dist/copypaste-$v-macos-aarch64.app.tar.gz" \ + "dist/copypaste-$v-macos-x86_64.app.tar.gz"; do + [ -s "$one" ] || continue + base64 -d < "$one.sig" > one.minisig + if ! minisign -Vm "$one" -p feed.pub -x one.minisig; then + echo "::error::$one was signed with a key that is not the one in \ + tauri.conf.json. Every copy built from this tree carries that public key, so nothing \ + could ever verify this update. Nothing was published." + exit 1 + fi + done + rm -f feed.pub one.minisig + echo "the signatures and the shipped key are halves of the same pair" + + - name: Checksums + run: cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS + + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 + with: + subject-path: dist/copypaste-*,dist/SHA256SUMS + + - name: Notes from the tag + id: notes + run: | + body=$(git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' "${GITHUB_REF_NAME}" \ + | sed '/-----BEGIN SSH SIGNATURE-----/,$d' \ + | sed -e :a -e '/^\n*$/{$d;N;ba}') + edge="LU_EOF_$(openssl rand -hex 8)" + { + echo "body<<$edge" + echo "$body" + echo "$edge" + } >> "$GITHUB_OUTPUT" + + - name: The feed does not walk backwards + run: | + v="${{ needs.version.outputs.version }}" + if ! git fetch origin manifest --depth 1 2>/dev/null; then + if git ls-remote --exit-code --heads origin manifest >/dev/null 2>&1; then + echo "::error::the feed exists and could not be read, so it cannot be checked" + exit 1 + fi + echo "no feed yet" + exit 0 + fi + feed=$(git show FETCH_HEAD:release-manifest.json 2>/dev/null || echo '{}') + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + was=$(jq -r '.latestPrerelease // ""' <<< "$feed"); track="candidates" + else + was=$(jq -r '.latest // ""' <<< "$feed"); track="stable" + fi + if [ -n "$was" ] && [ "$was" != "$v" ] \ + && [ "$(printf '%s\n%s\n' "$was" "$v" | sort -V | tail -1)" != "$v" ]; then + echo "::error::the $track feed already says $was; publishing $v would hand everybody \ + a version they left behind" + exit 1 + fi + echo "the $track feed says «${was:-nothing}»; $v may follow" + + - name: Publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NOTES: ${{ steps.notes.outputs.body }} + PRERELEASE: ${{ needs.version.outputs.prerelease }} + run: | + tag="$GITHUB_REF_NAME" + printf '%s\n' "$NOTES" > "$RUNNER_TEMP/notes.md" + + tags=$(gh release list --limit 200 --json tagName --jq '.[].tagName') + if ! grep -qx "$tag" <<< "$tags"; then + gh release create "$tag" --draft --verify-tag --title "$tag" \ + --prerelease="$PRERELEASE" --generate-notes --notes-file "$RUNNER_TEMP/notes.md" + fi + + state=$(gh release view "$tag" --json isDraft,assets) + draft=$(jq -r .isDraft <<< "$state") + for f in dist/*; do + name=$(basename "$f") + have=$(jq -r --arg n "$name" '.assets[] | select(.name == $n) | .digest // ""' <<< "$state") + want="sha256:$(sha256sum "$f" | cut -d' ' -f1)" + if [ "$have" = "$want" ]; then + echo "$name is already up, same bytes" + continue + fi + if [ "$draft" = false ] && [ -n "$have" ]; then + echo "::error::$name is published with other bytes; this run would replace a live \ + file. Rerun only the failed jobs, or delete the release and let it be made again." + exit 1 + fi + secs=$((180 + $(stat -c %s "$f") / 50000)) + for attempt in 1 2 3; do + began=$(date +%s) + if timeout "$secs" gh release upload "$tag" "$f" --clobber; then + echo "$name is up in $(( $(date +%s) - began ))s" + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::$name never made it up; the release stays a draft" + exit 1 + fi + echo "::warning::$name stalled after ${secs}s (${attempt}/3)" + sleep $((attempt * 10)) + done + done + + have=$(gh release view "$tag" --json assets -q '.assets[].name' | sort) + want=$(ls dist | sort) + missing=$(comm -23 <(echo "$want") <(echo "$have")) + if [ -n "$missing" ]; then + echo "::error::the release is missing files this run built; it stays a draft" + echo "$missing" + exit 1 + fi + spare=$(comm -13 <(echo "$want") <(echo "$have")) + if [ -n "$spare" ]; then + echo "::warning::the release carries files this run did not build:" + echo "$spare" + fi + + latest=false; [ "$PRERELEASE" = "false" ] && latest=true + gh release edit "$tag" --draft=false --latest="$latest" + + - name: The feed + run: | + v="${{ needs.version.outputs.version }}" + base="https://github.com/${{ github.repository }}/releases/download/${GITHUB_REF_NAME}" + win="copypaste-installer-$v-windows-x86_64.exe" + arm="copypaste-$v-macos-aarch64.app.tar.gz" + intel="copypaste-$v-macos-x86_64.app.tar.gz" + + if [ -s "dist/$win.sig" ]; then + ws=$(cat "dist/$win.sig") + as=$(cat "dist/$arm.sig" 2>/dev/null || true) + is=$(cat "dist/$intel.sig" 2>/dev/null || true) + if [ "${{ needs.version.outputs.macos }}" = "true" ] \ + && { [ -z "$as" ] || [ -z "$is" ]; }; then + echo "::error::no updater signature for a Mac, so no Mac would be offered $v" + exit 1 + fi + jq -n --arg v "$v" \ + --arg n "https://github.com/${{ github.repository }}/releases/tag/${GITHUB_REF_NAME}" \ + --arg wu "$base/$win" --arg ws "$ws" \ + --arg au "$base/$arm" --arg as "$as" \ + --arg iu "$base/$intel" --arg is "$is" \ + '{version: $v, notes: $n, platforms: + ({"windows-x86_64": {url: $wu, signature: $ws}} + + (if $as == "" then {} else {"darwin-aarch64": {url: $au, signature: $as}} end) + + (if $is == "" then {} else {"darwin-x86_64": {url: $iu, signature: $is}} end))}' \ + > "$RUNNER_TEMP/channel.json" + cat "$RUNNER_TEMP/channel.json" + else + echo "::error::no updater signature for $v; the release would offer an update nobody can take" + exit 1 + fi + + if git fetch origin manifest --depth 1 2>/dev/null; then + git checkout -B manifest FETCH_HEAD + else + git checkout --orphan manifest + git rm -rq --cached . 2>/dev/null || true + rm -f release-manifest.json latest.json candidate.json + fi + + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + was=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") + track="candidates" + else + was=$(jq -r '.latest // ""' release-manifest.json 2>/dev/null || echo "") + track="stable" + fi + if [ -n "$was" ] && [ "$was" != "$v" ] \ + && [ "$(printf '%s\n%s\n' "$was" "$v" | sort -V | tail -1)" != "$v" ]; then + echo "::error::the $track feed already says $was, and this run would set it back to \ + $v. Nothing was written." + exit 1 + fi + + latest=$(jq -r '.latest // ""' release-manifest.json 2>/dev/null || echo "") + ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + jq -n --arg l "${latest:-0.0.0}" --arg c "$v" \ + '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json + cp "$RUNNER_TEMP/channel.json" candidate.json + else + keep="" + if [ -n "$ahead" ] \ + && [ "$(printf '%s\n%s\n' "${ahead%%-*}" "$v" | sort -V | tail -1)" != "$v" ]; then + keep="$ahead" + fi + if [ -n "$keep" ]; then + jq -n --arg l "$v" --arg c "$keep" \ + '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json + else + jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json + rm -f candidate.json + fi + cp "$RUNNER_TEMP/channel.json" latest.json + fi + cat release-manifest.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + for one in release-manifest.json latest.json candidate.json; do + git add -A -- "$one" 2>/dev/null || true + done + if git diff --cached --quiet; then + echo "already says $v" + exit 0 + fi + git commit -m "chore: release manifest for $v" + for attempt in 1 2 3 4 5; do + if git push origin manifest; then + exit 0 + fi + echo "the feed would not push (${attempt}/5)" + git fetch origin manifest || true + git rebase FETCH_HEAD || git rebase --abort || true + sleep $((attempt * 5)) + done + echo "::error::the feed could not be pushed; the release is out and nothing announces it" + exit 1 + + verify: + name: The update is installable + needs: [version, publish] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Take the update the way an installed copy would + run: | + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign + v="${{ needs.version.outputs.version }}" + file=latest.json + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then file=candidate.json; fi + feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest/$file" + + said="" + for attempt in 1 2 3 4 5 6; do + if curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed"; then + said=$(jq -r .version channel.json) + [ "$said" = "$v" ] && break + fi + echo "the channel still says «${said:-nothing}» (${attempt}/6)" + sleep $((attempt * 20)) + done + if [ -s channel.json ]; then jq . channel.json; fi + + ahead=$(jq -r .version channel.json 2>/dev/null || echo "") + if [ ! -s channel.json ] && [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + ahead=$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" \ + "https://raw.githubusercontent.com/${{ github.repository }}/manifest/latest.json" \ + | jq -r .version 2>/dev/null || echo "") + fi + + overtaken=no + if printf "%s" "$ahead" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$"; then + mine=${v%%-*} + theirs=${ahead%%-*} + if [ "$theirs" != "$mine" ]; then + if [ "$(printf "%s\n%s\n" "$mine" "$theirs" | sort -V | tail -1)" = "$theirs" ]; then + overtaken=yes + fi + elif [ "$v" != "$mine" ] && [ "$ahead" = "$theirs" ]; then + overtaken=yes + elif [ "$v" != "$mine" ] && [ "$ahead" != "$theirs" ] \ + && [ "$(printf "%s\n%s\n" "$v" "$ahead" | sort -V | tail -1)" = "$ahead" ]; then + overtaken=yes + fi + fi + + if [ "$said" != "$v" ] && [ "$overtaken" = "yes" ]; then + echo "::warning::the feed already carries $ahead, so $v was overtaken before it could be taken here. Its signatures were not checked by this run." + exit 0 + fi + + if [ "$said" != "$v" ]; then + echo "::error::$file says «${said:-nothing}», this release is $v" + exit 1 + fi + + jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub + + for os in $(jq -r '.platforms | keys[]' channel.json); do + url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) + jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig + + case "$url" in + "https://github.com/${{ github.repository }}/releases/download/v$v/"*) ;; + *) echo "::error::$os points at $url, which is not the v$v release"; exit 1 ;; + esac + + curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url" + if minisign -Vm one.bin -p feed.pub -x one.sig; then + echo "$os verifies" + else + echo "::error::$os does not verify against the key this build ships" + exit 1 + fi + done + + msstore: + name: Microsoft Store + needs: [version, publish, verify, bundle-msix] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' + && needs.bundle-msix.result == 'success' + && needs.version.outputs.prerelease == 'false' + runs-on: windows-latest + timeout-minutes: 20 + concurrency: + group: release-msstore-${{ github.ref }} + cancel-in-progress: false + env: + STORE_PUBLISH: ${{ vars.STORE_PUBLISH }} + STORE_APP_ID: ${{ vars.STORE_APP_ID }} + STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }} + STORE_SELLER_ID: ${{ secrets.STORE_SELLER_ID }} + STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }} + STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }} + steps: + - name: Nothing to submit without being asked + id: gate + shell: bash + run: | + if [ "$STORE_PUBLISH" != "true" ]; then + echo "::notice::STORE_PUBLISH is not true; the Store gets nothing until it is" + echo "go=no" >> "$GITHUB_OUTPUT" + elif [ "${{ needs.bundle-msix.outputs.made }}" != "yes" ]; then + echo "::notice::no MSIX was built this run; nothing to submit" + echo "go=no" >> "$GITHUB_OUTPUT" + elif [ -z "$STORE_APP_ID" ] || [ -z "$STORE_TENANT_ID" ] || [ -z "$STORE_SELLER_ID" ] \ + || [ -z "$STORE_CLIENT_ID" ] || [ -z "$STORE_CLIENT_SECRET" ]; then + echo "::notice::the Store credentials are not all here; nothing to submit" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/download-artifact@v8 + if: steps.gate.outputs.go == 'yes' + with: + name: bundle-msix + path: dist + + - uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 + if: steps.gate.outputs.go == 'yes' + with: + version: v0.4.2 + + - name: Submit + id: sent + if: steps.gate.outputs.go == 'yes' + shell: bash + run: | + msix=$(ls dist/*.msix 2>/dev/null | head -1) || true + if [ -z "$msix" ]; then + echo "::error::the artifact carried no package to submit" + exit 1 + fi + msstore reconfigure \ + --tenantId "$STORE_TENANT_ID" \ + --sellerId "$STORE_SELLER_ID" \ + --clientId "$STORE_CLIENT_ID" \ + --clientSecret "$STORE_CLIENT_SECRET" + set +e + msstore publish "$msix" --appId "$STORE_APP_ID" 2>&1 | tee publish.log + code=${PIPESTATUS[0]} + set -e + + if [ "$code" -ne 0 ] && grep -q "CommitStarted" publish.log; then + echo "::warning::the Store took the submission and is processing it; the client lost \ + the poll it was holding. Nothing here has to be sent again." + exit 0 + fi + if [ "$code" -eq 0 ]; then + echo "submitted $msix; the listing is reviewed by people and appears when they say so" + fi + exit "$code" + + - name: What the Store made of it + if: always() && steps.gate.outputs.go == 'yes' && steps.sent.outcome == 'failure' + shell: bash + run: | + echo "::warning::publishing failed before the Store took the submission; asking what state it is in" + msstore submission status "$STORE_APP_ID" || true + echo "::error::the package reached the Store but no commit was confirmed. Read the status \ + above before tagging again: a submission already committed must not be sent twice." + + homebrew: + name: Homebrew tap + needs: [version, publish, verify] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' + && needs.version.outputs.macos == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: release-homebrew-${{ github.ref }} + cancel-in-progress: false + env: + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + steps: + - name: Update the tap + if: env.GIST_TOKEN != '' + run: | + set -euo pipefail + + TAG="${GITHUB_REF_NAME}" + VERSION="${{ needs.version.outputs.version }}" + BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" + DMG_ARM="copypaste-installer-${VERSION}-macos-aarch64.dmg" + DMG_INTEL="copypaste-installer-${VERSION}-macos-x86_64.dmg" + + if [[ "$VERSION" == *-* ]]; then + CASK="copypaste-beta"; CASK_OTHER="copypaste"; SUFFIX=" (beta)" + else + CASK="copypaste"; CASK_OTHER="copypaste-beta"; SUFFIX="" + fi + + fetch() { + for attempt in 1 2 3 4 5; do + if curl -fSL -o "/tmp/$1" "$BASE/$1" && [ -s "/tmp/$1" ]; then + return 0 + fi + echo "$1 not downloadable yet (attempt ${attempt}/5)" + sleep $((attempt * 10)) + done + echo "::error::could not download $1" + return 1 + } + if ! fetch "$DMG_ARM" || ! fetch "$DMG_INTEL"; then + echo "::warning::a disk image is missing for ${VERSION}; leaving the cask as it is" + exit 0 + fi + ARM_SHA=$(sha256sum "/tmp/$DMG_ARM" | awk '{print $1}') + INTEL_SHA=$(sha256sum "/tmp/$DMG_INTEL" | awk '{print $1}') + + git clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap + git -C /tmp/tap config --local http.extraheader \ + "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" + cd /tmp/tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mkdir -p Casks + + if [ -f "Casks/${CASK}.rb" ]; then + had=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK}.rb" | head -1) + if [ -n "$had" ] && [ "$had" != "$VERSION" ] \ + && [ "$(printf '%s\n%s\n' "$had" "$VERSION" | sort -V | tail -1)" != "$VERSION" ]; then + echo "::warning::the tap already offers ${had}; writing ${VERSION} would downgrade every Mac that runs brew upgrade" + exit 0 + fi + fi + + cat > "Casks/${CASK}.rb" <<-CASK + cask "${CASK}" do + arch arm: "aarch64", intel: "x86_64" + + version "${VERSION}" + sha256 arm: "${ARM_SHA}", + intel: "${INTEL_SHA}" + + url "${BASE}/copypaste-installer-${VERSION}-macos-#{arch}.dmg" + name "CopyPaste" + desc "Clipboard history that stays on your machine${SUFFIX}" + homepage "https://rgdevment.com/copypaste/" + + auto_updates true + + conflicts_with cask: "${CASK_OTHER}" + depends_on macos: :ventura + + app "CopyPaste.app" + + uninstall script: { + executable: "#{appdir}/CopyPaste.app/Contents/MacOS/CopyPaste", + args: ["--unregister"], + must_succeed: false, + }, + quit: "com.rgdevment.copypaste" + + zap trash: [ + "~/Library/Application Support/CopyPaste", + "~/Library/Preferences/com.rgdevment.copypaste.plist", + "~/Library/Preferences/com.rgdevment.copypaste.plist", + "~/Library/Saved Application State/com.rgdevment.copypaste.savedState", + "~/Library/Saved Application State/com.rgdevment.copypaste.savedState", + ] + end + CASK + + if [ "${{ needs.version.outputs.prerelease }}" = "false" ] \ + && [ -f "Casks/${CASK_OTHER}.rb" ]; then + beta=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK_OTHER}.rb" | head -1) + if [ -n "$beta" ] \ + && [ "$(printf '%s\n%s\n' "${beta%%-*}" "$VERSION" | sort -V | tail -1)" \ + = "$VERSION" ]; then + git rm -q "Casks/${CASK_OTHER}.rb" + echo "retired Casks/${CASK_OTHER}.rb: it still offered $beta, and $VERSION passes it" + fi + fi + + git add Casks + if git diff --cached --quiet; then + echo "tap already at ${VERSION}" + exit 0 + fi + git commit -m "copypaste ${VERSION}" + + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "push rejected (attempt ${attempt}/5); rebasing onto the concurrent release" + git fetch origin main + if ! git rebase origin/main; then + git rebase --abort || true + echo "::error::the tap moved under this commit and it will not rebase cleanly" + exit 1 + fi + done + echo "::error::could not push the tap after 5 attempts" + exit 1 + + winget: + name: Windows Package Manager + needs: [version, publish, verify] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' + && needs.version.outputs.prerelease == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: release-winget-${{ github.ref }} + cancel-in-progress: false + env: + WINGET_PUBLISH: ${{ vars.WINGET_PUBLISH }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + steps: + - name: Is there anything to add a version to + id: gate + shell: bash + run: | + if [ "$WINGET_PUBLISH" != "true" ]; then + echo "::notice::WINGET_PUBLISH is not true; winget-pkgs gets nothing until it is" + echo "go=no" >> "$GITHUB_OUTPUT" + elif [ -z "$WINGET_TOKEN" ]; then + echo "::notice::no winget token; skipping" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Open the pull request that adds this version + if: steps.gate.outputs.go == 'yes' + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: rgdevment.CopyPaste + version: ${{ needs.version.outputs.version }} + release-tag: ${{ github.ref_name }} + installers-regex: 'copypaste-installer-.*-windows-x86_64\.exe$' + max-versions-to-keep: 5 + fork-user: rgdevment-bot + token: ${{ secrets.WINGET_TOKEN }}