From eb033c3c90945af24b87d72e75eef0aeb5f9e122 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:20:18 -0300 Subject: [PATCH 1/2] refactor(scripts): share the workspace version between release and setup builds --- app/scripts/release.mjs | 14 ++------------ app/scripts/workspace.mjs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 app/scripts/workspace.mjs diff --git a/app/scripts/release.mjs b/app/scripts/release.mjs index 5b4f488c..0788743c 100644 --- a/app/scripts/release.mjs +++ b/app/scripts/release.mjs @@ -9,22 +9,12 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { root, workspaceVersion } from "./workspace.mjs"; -const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const tag = process.env.GITHUB_REF_NAME ?? ""; const repo = process.env.GITHUB_REPOSITORY ?? ""; -function workspaceVersion() { - const cargo = readFileSync(join(root, "Cargo.toml"), "utf8"); - const version = cargo.match(/\[workspace\.package\][^[]*?\nversion = "([^"]+)"/)?.[1]; - if (!version) { - throw new Error("Cargo.toml has no [workspace.package] version"); - } - return version; -} - function check() { const version = workspaceVersion(); if (tag !== `v${version}`) { diff --git a/app/scripts/workspace.mjs b/app/scripts/workspace.mjs new file mode 100644 index 00000000..92afc713 --- /dev/null +++ b/app/scripts/workspace.mjs @@ -0,0 +1,17 @@ +// The repository root and the one version everything ships with: the +// workspace's, in Cargo.toml (spec 15.5). + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export function workspaceVersion() { + const cargo = readFileSync(join(root, "Cargo.toml"), "utf8"); + const version = cargo.match(/\[workspace\.package\][^[]*?\nversion = "([^"]+)"/)?.[1]; + if (!version) { + throw new Error("Cargo.toml has no [workspace.package] version"); + } + return version; +} From d54cd470312063f97a27b72eb1324abc5abc9430 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:20:18 -0300 Subject: [PATCH 2/2] feat(setup): Botloft's own install window, with NSIS doing the work quietly The file people download opens a window of Botloft's own (spec 15.7): the mascot, the name and one button, in the app's look, three languages and the Windows theme. Behind it, the NSIS installer it carries runs with /S, so shortcuts, Installed apps, the uninstaller, WebView2 and the service hook stay as they were, and so do updates. - botloft-setup (app/src-setup): reads what Installed apps says, closes the open app, runs the installer from a folder of its own under %TEMP%, deletes it and opens the installed app. Its manifest asks for asInvoker, since Windows takes a "setup" program for an installer. Without WebView2 it hands over to the classic installer. A build without the installer inside only rehearses. - The page (app/src/setup, setup.html) depends on SetupHost only; the fake drives the tests and the dev preview at /setup.html. - pnpm bundle:setup builds the page with `vite build --mode setup` and the crate with the installer from `pnpm bundle` inside. Tested for real on Windows 11: a fresh install took about 3 s, created the Installed apps entry and opened the app; "Install again" with the app open closed it, installed and opened it again, and the daemon kept running throughout. Spec 15.7 records the results. --- .gitignore | 3 + CLAUDE.md | 1 + Cargo.lock | 11 ++ Cargo.toml | 2 +- app/package.json | 1 + app/scripts/setup.mjs | 47 +++++ app/setup.html | 12 ++ app/src-setup/.gitignore | 2 + app/src-setup/Cargo.toml | 22 +++ app/src-setup/build.rs | 25 +++ app/src-setup/capabilities/default.json | 12 ++ app/src-setup/src/installed.rs | 125 +++++++++++++ app/src-setup/src/main.rs | 152 ++++++++++++++++ app/src-setup/src/payload.rs | 125 +++++++++++++ app/src-setup/src/processes.rs | 154 ++++++++++++++++ app/src-setup/src/version.rs | 60 +++++++ app/src-setup/tauri.conf.json | 19 ++ app/src-setup/windows/setup.manifest | 24 +++ app/src/i18n/en/index.ts | 2 + app/src/i18n/en/setup.ts | 27 +++ app/src/i18n/es/index.ts | 2 + app/src/i18n/es/setup.ts | 27 +++ app/src/i18n/pt-BR/index.ts | 2 + app/src/i18n/pt-BR/setup.ts | 27 +++ app/src/setup/Setup.tsx | 223 ++++++++++++++++++++++++ app/src/setup/fakeSetupHost.ts | 85 +++++++++ app/src/setup/host.ts | 48 +++++ app/src/setup/main.tsx | 33 ++++ app/src/setup/setup.css | 38 ++++ app/src/setup/setup.test.tsx | 102 +++++++++++ app/src/setup/tauriSetupHost.ts | 21 +++ app/vite.config.ts | 7 +- docs/spec.md | 67 ++++++- 33 files changed, 1501 insertions(+), 7 deletions(-) create mode 100644 app/scripts/setup.mjs create mode 100644 app/setup.html create mode 100644 app/src-setup/.gitignore create mode 100644 app/src-setup/Cargo.toml create mode 100644 app/src-setup/build.rs create mode 100644 app/src-setup/capabilities/default.json create mode 100644 app/src-setup/src/installed.rs create mode 100644 app/src-setup/src/main.rs create mode 100644 app/src-setup/src/payload.rs create mode 100644 app/src-setup/src/processes.rs create mode 100644 app/src-setup/src/version.rs create mode 100644 app/src-setup/tauri.conf.json create mode 100644 app/src-setup/windows/setup.manifest create mode 100644 app/src/i18n/en/setup.ts create mode 100644 app/src/i18n/es/setup.ts create mode 100644 app/src/i18n/pt-BR/setup.ts create mode 100644 app/src/setup/Setup.tsx create mode 100644 app/src/setup/fakeSetupHost.ts create mode 100644 app/src/setup/host.ts create mode 100644 app/src/setup/main.tsx create mode 100644 app/src/setup/setup.css create mode 100644 app/src/setup/setup.test.tsx create mode 100644 app/src/setup/tauriSetupHost.ts diff --git a/.gitignore b/.gitignore index 873940ff..a664364d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ node_modules/ !.vscode/extensions.json .DS_Store Thumbs.db + +# Setup window build (spec 15.7) +/app/dist-setup/ diff --git a/CLAUDE.md b/CLAUDE.md index ec2d6667..16aa534b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME; "Start the daemon" in pnpm dev # só a UI num navegador comum, com o FakeBotloft (src/dev/preview.ts) pnpm check # tsc --noEmit + biome check + vitest run pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis +pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup ``` Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com instalador, `.sig` e `latest.json`; o updater só o vê depois de publicado. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. diff --git a/Cargo.lock b/Cargo.lock index b95b8b04..c858e58c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,6 +444,17 @@ dependencies = [ "ulid", ] +[[package]] +name = "botloft-setup" +version = "0.5.0" +dependencies = [ + "serde", + "tauri", + "tauri-build", + "windows", + "winreg", +] + [[package]] name = "botloft-store" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 9e680e63..9f819ed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd", "app/src-tauri"] +members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd", "app/src-tauri", "app/src-setup"] default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd"] [workspace.package] diff --git a/app/package.json b/app/package.json index c09a4fcf..51d28ad8 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "preview": "vite preview", "tauri": "tauri", "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", + "bundle:setup": "node scripts/setup.mjs", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "lint": "biome check .", "format": "biome check --write .", diff --git a/app/scripts/setup.mjs b/app/scripts/setup.mjs new file mode 100644 index 00000000..aa94e19e --- /dev/null +++ b/app/scripts/setup.mjs @@ -0,0 +1,47 @@ +// Builds the setup window (spec 15.7) around the NSIS installer that +// `pnpm bundle` made: the page with Vite, then the botloft-setup crate with +// the installer inside, copied to +// target/release/bundle/setup/Botloft__x64-setup.exe. +// +// node scripts/setup.mjs with the installer inside +// node scripts/setup.mjs --rehearse without it: the pages only pretend + +import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { root, workspaceVersion } from "./workspace.mjs"; + +const app = join(root, "app"); +const target = process.env.CARGO_TARGET_DIR ?? join(root, "target"); +const version = workspaceVersion(); +const rehearse = process.argv.includes("--rehearse"); +const installer = join(target, "release", "bundle", "nsis", `Botloft_${version}_x64-setup.exe`); +if (!rehearse && !existsSync(installer)) { + throw new Error(`${installer} is missing: run pnpm bundle first`); +} + +execFileSync( + process.execPath, + [join(app, "node_modules", "vite", "bin", "vite.js"), "build", "--mode", "setup"], + { + cwd: app, + stdio: "inherit", + }, +); + +const env = { ...process.env }; +delete env.BOTLOFT_SETUP_PAYLOAD; +if (!rehearse) { + env.BOTLOFT_SETUP_PAYLOAD = installer; +} +execFileSync( + "cargo", + ["build", "--release", "-p", "botloft-setup", "--features", "tauri/custom-protocol"], + { cwd: root, stdio: "inherit", env }, +); + +const out = join(target, "release", "bundle", "setup"); +mkdirSync(out, { recursive: true }); +const setup = join(out, `Botloft_${version}_x64-setup.exe`); +copyFileSync(join(target, "release", "botloft-setup.exe"), setup); +console.log(`setup${rehearse ? " (rehearsal)" : ""}: ${setup}`); diff --git a/app/setup.html b/app/setup.html new file mode 100644 index 00000000..360ab9cc --- /dev/null +++ b/app/setup.html @@ -0,0 +1,12 @@ + + + + + + Botloft + + +
+ + + diff --git a/app/src-setup/.gitignore b/app/src-setup/.gitignore new file mode 100644 index 00000000..1e40295a --- /dev/null +++ b/app/src-setup/.gitignore @@ -0,0 +1,2 @@ +# Generated by Tauri (capability schemas for editor autocompletion) +/gen/schemas diff --git a/app/src-setup/Cargo.toml b/app/src-setup/Cargo.toml new file mode 100644 index 00000000..c3653368 --- /dev/null +++ b/app/src-setup/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "botloft-setup" +description = "Botloft setup window: runs the NSIS installer it carries, quietly" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +serde = { version = "1.0.229", features = ["derive"] } +tauri = { version = "2", features = [] } + +[target."cfg(windows)".dependencies] +winreg = "0.55.0" +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] } + +[lints] +workspace = true diff --git a/app/src-setup/build.rs b/app/src-setup/build.rs new file mode 100644 index 00000000..ae1d7cd1 --- /dev/null +++ b/app/src-setup/build.rs @@ -0,0 +1,25 @@ +//! Puts the NSIS installer inside the setup (spec 15.7). Its path comes in +//! `BOTLOFT_SETUP_PAYLOAD`; without it, as in dev builds and clippy, the +//! payload is empty and the setup only rehearses. + +use std::path::PathBuf; +use std::{env, fs}; + +fn main() { + println!("cargo:rerun-if-changed=windows/setup.manifest"); + println!("cargo:rerun-if-env-changed=BOTLOFT_SETUP_PAYLOAD"); + let out = + PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("payload.exe"); + match env::var_os("BOTLOFT_SETUP_PAYLOAD") { + Some(path) => { + println!("cargo:rerun-if-changed={}", PathBuf::from(&path).display()); + fs::copy(&path, &out).expect("BOTLOFT_SETUP_PAYLOAD must point to the NSIS installer"); + } + None => fs::write(&out, []).expect("cannot write the empty payload"), + } + + let windows = + tauri_build::WindowsAttributes::new().app_manifest(include_str!("windows/setup.manifest")); + tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows)) + .expect("failed to run tauri-build"); +} diff --git a/app/src-setup/capabilities/default.json b/app/src-setup/capabilities/default.json new file mode 100644 index 00000000..b9d3ce7b --- /dev/null +++ b/app/src-setup/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the setup window", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-close", + "core:window:allow-show" + ] +} diff --git a/app/src-setup/src/installed.rs b/app/src-setup/src/installed.rs new file mode 100644 index 00000000..43a64037 --- /dev/null +++ b/app/src-setup/src/installed.rs @@ -0,0 +1,125 @@ +//! The Botloft already on this computer, as the NSIS installer left it +//! (spec 15.7): its entry in Installed apps, and the folder it goes to. + +use std::path::PathBuf; + +/// The app's executable in its folder (`mainBinaryName` in the app's +/// tauri.conf.json). +pub const APP_EXE: &str = "Botloft.exe"; + +/// What Installed apps says about Botloft. +#[derive(Debug, Default)] +pub struct Installed { + pub version: Option, + pub folder: Option, +} + +/// Registry strings may come quoted (`"C:\..."`). +fn unquote(value: &str) -> Option { + let value = value.trim().trim_matches('"').trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +/// Where the NSIS installer puts Botloft, in its own order: the installed +/// folder, the one a past install remembered, or %LOCALAPPDATA%\Botloft. +pub fn target_folder( + installed: Option, + remembered: Option, + local_app_data: Option, +) -> Option { + installed + .or(remembered) + .or_else(|| local_app_data.map(|base| base.join("Botloft"))) +} + +#[cfg(windows)] +mod registry { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + + const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Botloft"; + /// Tauri's NSIS remembers the install folder under + /// `Software\\`; the publisher is "github", the second + /// part of the identifier (spec 15.4). + const FOLDER_KEY: &str = r"Software\github\Botloft"; + + pub fn entry(name: &str) -> Option { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(UNINSTALL_KEY) + .ok()? + .get_value::(name) + .ok() + } + + pub fn remembered_folder() -> Option { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(FOLDER_KEY) + .ok()? + .get_value::("") + .ok() + } +} + +#[cfg(not(windows))] +mod registry { + pub fn entry(_name: &str) -> Option { + None + } + + pub fn remembered_folder() -> Option { + None + } +} + +/// Reads Botloft's entry in Installed apps; none when it is not installed. +pub fn read() -> Installed { + Installed { + version: registry::entry("DisplayVersion").and_then(|value| unquote(&value)), + folder: registry::entry("InstallLocation") + .and_then(|value| unquote(&value)) + .map(PathBuf::from), + } +} + +/// Where this setup's installer will put Botloft. +pub fn folder(installed: &Installed) -> Option { + target_folder( + installed.folder.clone(), + registry::remembered_folder() + .and_then(|value| unquote(&value)) + .map(PathBuf::from), + std::env::var_os("LOCALAPPDATA").map(PathBuf::from), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn follows_the_installer_order() { + let installed = Some(PathBuf::from("C:/A")); + let remembered = Some(PathBuf::from("C:/B")); + let base = Some(PathBuf::from("C:/Local")); + assert_eq!( + target_folder(installed.clone(), remembered.clone(), base.clone()), + installed + ); + assert_eq!( + target_folder(None, remembered.clone(), base.clone()), + remembered + ); + assert_eq!( + target_folder(None, None, base), + Some(PathBuf::from("C:/Local").join("Botloft")) + ); + assert_eq!(target_folder(None, None, None), None); + } + + #[test] + fn strips_quotes_and_blanks() { + assert_eq!(unquote(r#""C:\Botloft""#).as_deref(), Some(r"C:\Botloft")); + assert_eq!(unquote(" 0.5.0 ").as_deref(), Some("0.5.0")); + assert_eq!(unquote(r#""""#), None); + } +} diff --git a/app/src-setup/src/main.rs b/app/src-setup/src/main.rs new file mode 100644 index 00000000..cd39bbf2 --- /dev/null +++ b/app/src-setup/src/main.rs @@ -0,0 +1,152 @@ +//! The window people see when they install Botloft (spec 15.7): the mascot, +//! the name and one button. The NSIS installer this executable carries +//! runs quietly behind it and does the installing. + +// No console window in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod installed; +mod payload; +mod processes; +mod version; + +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +use installed::APP_EXE; +use payload::Failure; +use version::Relation; + +/// What the window shows before anything happens. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SetupState { + /// The version this setup installs. + version: &'static str, + /// No installer inside: the pages only pretend. + rehearsal: bool, + /// Where Botloft goes. + folder: Option, + /// The version already installed. + installed: Option, + relation: Relation, + /// The app is open from its folder and will close to install. + app_running: bool, +} + +fn state() -> SetupState { + let installed = installed::read(); + let folder = installed::folder(&installed); + let version = env!("CARGO_PKG_VERSION"); + SetupState { + version, + rehearsal: payload::rehearsal(), + relation: version::relation(installed.version.as_deref(), version), + app_running: folder + .as_deref() + .is_some_and(|folder| processes::running(folder, APP_EXE)), + folder: folder.map(|folder| folder.display().to_string()), + installed: installed.version, + } +} + +#[tauri::command] +async fn setup_state() -> Result { + tauri::async_runtime::spawn_blocking(state) + .await + .map_err(|err| err.to_string()) +} + +/// Closes the open app, then runs the installer quietly and waits for it. +#[tauri::command] +async fn setup_install() -> Result<(), Failure> { + tauri::async_runtime::spawn_blocking(|| { + if payload::rehearsal() { + std::thread::sleep(Duration::from_secs(3)); + return Ok(()); + } + if let Some(folder) = installed::folder(&installed::read()) { + processes::close(&folder, APP_EXE); + } + payload::install_quietly() + }) + .await + .map_err(|err| Failure { + code: None, + detail: err.to_string(), + })? +} + +/// Opens the installed app and closes the setup. +#[tauri::command] +fn setup_open_app(app: AppHandle) -> Result<(), String> { + if !payload::rehearsal() { + let folder = installed::read() + .folder + .ok_or("Botloft is not in Installed apps")?; + std::process::Command::new(folder.join(APP_EXE)) + .current_dir(&folder) + .spawn() + .map_err(|err| format!("cannot open {APP_EXE}: {err}"))?; + } + app.exit(0); + Ok(()) +} + +/// Hides this window and runs the installer with its own pages, then +/// closes: for when the quiet install failed. +#[tauri::command] +async fn setup_classic(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + let ran = tauri::async_runtime::spawn_blocking(|| { + if payload::rehearsal() { + Ok(()) + } else { + payload::install_classic() + } + }) + .await; + app.exit(0); + // The classic installer speaks for itself; a cancel there is no error. + ran.map(drop).map_err(|err| err.to_string()) +} + +fn main() { + // Without WebView2 this window cannot open. The classic installer has + // its own pages and fetches WebView2 (spec 15.7). + if tauri::webview_version().is_err() { + if !payload::rehearsal() { + let _ = payload::install_classic(); + } + return; + } + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + setup_state, + setup_install, + setup_open_app, + setup_classic + ]) + .setup(|app| { + // WebView2 keeps its files under %TEMP%, reused each time, and + // not in a folder of the setup's own under %LOCALAPPDATA%. + let data = std::env::temp_dir().join("Botloft-setup-webview"); + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("setup.html".into())) + .title("Botloft") + .inner_size(480.0, 380.0) + .resizable(false) + .maximizable(false) + .decorations(false) + .center() + .visible(false) + .data_directory(data) + .build()?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("failed to run the setup window"); +} diff --git a/app/src-setup/src/payload.rs b/app/src-setup/src/payload.rs new file mode 100644 index 00000000..31509872 --- /dev/null +++ b/app/src-setup/src/payload.rs @@ -0,0 +1,125 @@ +//! The NSIS installer this setup carries (spec 15.7): written to a folder of +//! its own under %TEMP%, run, and deleted with the folder afterwards. + +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +/// Empty in dev builds, which only rehearse (build.rs). +static PAYLOAD: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/payload.exe")); + +/// Without the installer inside, the setup shows its pages and pretends. +pub fn rehearsal() -> bool { + PAYLOAD.is_empty() +} + +/// Why the installer did not finish, for "Details". +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Failure { + /// The installer's exit code, when it ran. + pub code: Option, + pub detail: String, +} + +impl From for Failure { + fn from(err: io::Error) -> Self { + Self { + code: None, + detail: err.to_string(), + } + } +} + +/// A folder of our own under %TEMP%, deleted when dropped. +pub struct TempFolder(PathBuf); + +impl TempFolder { + pub fn new(parent: &Path) -> io::Result { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.subsec_nanos()); + for attempt in 0..16u32 { + let tag = seed ^ std::process::id().rotate_left(16) ^ attempt.wrapping_mul(0x9e37_79b9); + let path = parent.join(format!("Botloft-setup-{tag:08x}")); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(Self(path)), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(io::Error::other("no free folder name under %TEMP%")) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + /// Writes the installer into the folder and returns its path. + fn write_installer(&self, bytes: &[u8]) -> io::Result { + let exe = self.0.join("Botloft-installer.exe"); + std::fs::write(&exe, bytes)?; + Ok(exe) + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + // Best effort: %TEMP% is cleaned by Windows anyway. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Runs the installer with `args` from a fresh folder, waits for it and +/// deletes the folder. +fn run(args: &[&str]) -> Result<(), Failure> { + let folder = TempFolder::new(&std::env::temp_dir())?; + let exe = folder.write_installer(PAYLOAD)?; + let status = Command::new(&exe) + .args(args) + .current_dir(folder.path()) + .status()?; + if status.success() { + return Ok(()); + } + let name = exe + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + Err(Failure { + code: status.code(), + detail: format!("{name} {} stopped with {status}", args.join(" ")), + }) +} + +/// The installer with no pages of its own (`/S`). +pub fn install_quietly() -> Result<(), Failure> { + run(&["/S"]) +} + +/// The installer with its own pages, for when this window cannot help: +/// no WebView2, or a quiet install that failed. +pub fn install_classic() -> Result<(), Failure> { + run(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_temp_folder_is_unique_and_goes_away() { + let parent = std::env::temp_dir(); + let first = TempFolder::new(&parent).expect("first folder"); + let second = TempFolder::new(&parent).expect("second folder"); + assert_ne!(first.path(), second.path()); + let exe = first.write_installer(b"MZ").expect("write"); + assert_eq!(std::fs::read(&exe).expect("read"), b"MZ"); + let path = first.path().to_path_buf(); + drop(first); + assert!(!path.exists()); + } +} diff --git a/app/src-setup/src/processes.rs b/app/src-setup/src/processes.rs new file mode 100644 index 00000000..4f9619cf --- /dev/null +++ b/app/src-setup/src/processes.rs @@ -0,0 +1,154 @@ +//! Botloft's own app running from its install folder: whether it is open, +//! and closing it before the installer replaces its files (spec 15.7). The +//! bots are not in it: they run in the daemon's scheduled task (spec 14). + +use std::path::Path; + +/// Whether `exe` sits directly in `folder`, compared as Windows does: +/// without regard to case or a trailing separator. +pub fn is_in(exe: &Path, folder: &Path) -> bool { + let normalize = |path: &Path| { + path.to_string_lossy() + .trim_end_matches(['\\', '/']) + .replace('/', "\\") + .to_lowercase() + }; + exe.parent() + .is_some_and(|parent| normalize(parent) == normalize(folder)) +} + +#[cfg(windows)] +mod win { + use std::path::{Path, PathBuf}; + + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + PROCESS_TERMINATE, QueryFullProcessImageNameW, TerminateProcess, WaitForSingleObject, + }; + use windows::core::PWSTR; + + struct Owned(HANDLE); + + impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: the handle came from a successful call and is closed once. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + fn image_path(process: HANDLE) -> Option { + let mut buffer = [0u16; 1024]; + let mut size = buffer.len() as u32; + // SAFETY: the buffer and its size match; the handle is open. + unsafe { + QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + PWSTR(buffer.as_mut_ptr()), + &mut size, + ) + } + .ok()?; + Some(PathBuf::from(String::from_utf16_lossy( + &buffer[..size as usize], + ))) + } + + /// The processes running `exe_name` from `folder`, open to be closed. + pub fn find(folder: &Path, exe_name: &str) -> Vec { + // SAFETY: a plain snapshot of the process list. + let Ok(snapshot) = (unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }) else { + return Vec::new(); + }; + let snapshot = Owned(snapshot); + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = Vec::new(); + // SAFETY: `entry` has its size set, as the API requires. + let mut more = unsafe { Process32FirstW(snapshot.0, &mut entry) }.is_ok(); + while more { + let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(0); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + if name.eq_ignore_ascii_case(exe_name) { + let access = + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; + // SAFETY: opening a process by id; failure just skips it. + if let Ok(process) = unsafe { OpenProcess(access, false, entry.th32ProcessID) } { + let process = Owned(process); + if image_path(process.0).is_some_and(|exe| super::is_in(&exe, folder)) { + found.push(HandleOf(process)); + } + } + } + // SAFETY: same snapshot and entry as above. + more = unsafe { Process32NextW(snapshot.0, &mut entry) }.is_ok(); + } + found + } + + pub struct HandleOf(Owned); + + impl HandleOf { + /// Ends the process and waits up to 5 s for it to go. + pub fn close(&self) { + // SAFETY: the handle was opened with PROCESS_TERMINATE and SYNCHRONIZE. + unsafe { + let _ = TerminateProcess(self.0.0, 0); + let _ = WaitForSingleObject(self.0.0, 5_000); + } + } + } +} + +/// Whether the app runs from `folder`. +pub fn running(folder: &Path, exe_name: &str) -> bool { + #[cfg(windows)] + return !win::find(folder, exe_name).is_empty(); + #[cfg(not(windows))] + { + let _ = (folder, exe_name); + false + } +} + +/// Closes the app running from `folder`, as the NSIS installer would. +pub fn close(folder: &Path, exe_name: &str) { + #[cfg(windows)] + for process in win::find(folder, exe_name) { + process.close(); + } + #[cfg(not(windows))] + let _ = (folder, exe_name); +} + +#[cfg(test)] +mod tests { + use super::*; + + // Windows paths: `\` separates them only there. + #[cfg(windows)] + #[test] + fn matches_the_folder_as_windows_does() { + let folder = Path::new(r"C:\Users\Ana Lima\AppData\Local\Botloft"); + assert!(is_in( + Path::new(r"c:\users\ana lima\appdata\local\botloft\Botloft.exe"), + folder + )); + assert!(is_in( + Path::new(r"C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe"), + Path::new(r"C:\Users\Ana Lima\AppData\Local\Botloft\") + )); + assert!(!is_in( + Path::new(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\Botloft.exe"), + folder + )); + assert!(!is_in(Path::new(r"D:\Botloft\Botloft.exe"), folder)); + } +} diff --git a/app/src-setup/src/version.rs b/app/src-setup/src/version.rs new file mode 100644 index 00000000..34f45c23 --- /dev/null +++ b/app/src-setup/src/version.rs @@ -0,0 +1,60 @@ +//! How the installed Botloft compares with the one this setup carries. + +use serde::Serialize; + +/// What is already on this computer, next to this setup's version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Relation { + /// Botloft is not installed. + None, + /// An older version: the setup updates it. + Older, + /// This same version. + Same, + /// A newer version: the setup only opens it. + Newer, +} + +/// `major.minor.patch`, ignoring a pre-release or build tag. +fn parse(version: &str) -> Option<(u64, u64, u64)> { + let core = version.trim().split(['-', '+']).next()?; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + let found = (parts.next()??, parts.next()??, parts.next()??); + parts.next().is_none().then_some(found) +} + +/// An installed version that cannot be read counts as older, so the setup +/// offers to update it rather than refuse. +pub fn relation(installed: Option<&str>, this: &str) -> Relation { + let Some(installed) = installed else { + return Relation::None; + }; + match (parse(installed), parse(this)) { + (Some(old), Some(new)) if old == new => Relation::Same, + (Some(old), Some(new)) if old > new => Relation::Newer, + _ => Relation::Older, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compares_by_number_not_text() { + assert_eq!(relation(Some("0.9.0"), "0.10.0"), Relation::Older); + assert_eq!(relation(Some("0.10.0"), "0.9.0"), Relation::Newer); + assert_eq!(relation(Some("0.5.0"), "0.5.0"), Relation::Same); + assert_eq!(relation(None, "0.5.0"), Relation::None); + } + + #[test] + fn ignores_tags_and_treats_the_unreadable_as_older() { + assert_eq!(relation(Some("0.5.0-beta.1"), "0.5.0"), Relation::Same); + assert_eq!(relation(Some(" 1.2.3 "), "1.2.3"), Relation::Same); + assert_eq!(relation(Some("banana"), "0.5.0"), Relation::Older); + assert_eq!(relation(Some("1.2"), "0.5.0"), Relation::Older); + assert_eq!(relation(Some("1.2.3.4"), "0.5.0"), Relation::Older); + } +} diff --git a/app/src-setup/tauri.conf.json b/app/src-setup/tauri.conf.json new file mode 100644 index 00000000..8c3713cb --- /dev/null +++ b/app/src-setup/tauri.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Botloft", + "identifier": "io.github.httpsphl.botloft.setup", + "build": { + "devUrl": "http://localhost:1420", + "frontendDist": "../dist-setup" + }, + "app": { + "windows": [], + "security": { + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" + } + }, + "bundle": { + "copyright": "Copyright 2026 Phelipe Lorran", + "icon": ["../src-tauri/icons/icon.ico"] + } +} diff --git a/app/src-setup/windows/setup.manifest b/app/src-setup/windows/setup.manifest new file mode 100644 index 00000000..f4372571 --- /dev/null +++ b/app/src-setup/windows/setup.manifest @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index 704542ae..5d268479 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -12,6 +12,7 @@ import { messages } from "./messages"; import { onboarding } from "./onboarding"; import { routines } from "./routines"; import { screens } from "./screens"; +import { setup } from "./setup"; import { shell } from "./shell"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -31,6 +32,7 @@ export const en = { messages, routines, screens, + setup, tools, }; diff --git a/app/src/i18n/en/setup.ts b/app/src/i18n/en/setup.ts new file mode 100644 index 00000000..c9ed7fb0 --- /dev/null +++ b/app/src/i18n/en/setup.ts @@ -0,0 +1,27 @@ +// The window people install Botloft with (spec 15.7). + +export const setup = { + tagline: "Always-on bots that work together for you.", + install: "Install", + update: "Update", + forYou: "Installs just for you, without asking for an administrator.", + older: (installed: string, version: string) => `You have ${installed}. This is ${version}.`, + same: "Botloft is already installed.", + newer: (installed: string) => `You already have a newer version (${installed}).`, + open: "Open Botloft", + reinstall: "Install again", + appOpen: "Botloft is open and closes to install. Your bots keep working.", + installing: "Installing Botloft…", + installingLine: "This takes a few seconds.", + progress: "Installing", + done: "All set.", + opening: "Opening Botloft…", + openFailed: "Botloft is installed. Open it from the Start menu.", + failed: "Botloft didn't install.", + failedLine: "Try again, or use the classic installer.", + retry: "Try again", + classic: "Use the classic installer", + folder: (path: string) => `Folder: ${path}`, + version: (version: string) => `Version: ${version}`, + exitCode: (code: number) => `Exit code: ${code}`, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index d6183ffa..b28d4461 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -13,6 +13,7 @@ import { messages } from "./messages"; import { onboarding } from "./onboarding"; import { routines } from "./routines"; import { screens } from "./screens"; +import { setup } from "./setup"; import { shell } from "./shell"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -32,5 +33,6 @@ export const es: Messages = { messages, routines, screens, + setup, tools, }; diff --git a/app/src/i18n/es/setup.ts b/app/src/i18n/es/setup.ts new file mode 100644 index 00000000..05d216a3 --- /dev/null +++ b/app/src/i18n/es/setup.ts @@ -0,0 +1,27 @@ +import type { Messages } from "../en"; + +export const setup: Messages["setup"] = { + tagline: "Bots siempre encendidos que trabajan juntos para ti.", + install: "Instalar", + update: "Actualizar", + forYou: "Se instala solo para ti, sin pedir un administrador.", + older: (installed: string, version: string) => `Tienes la ${installed}. Esta es la ${version}.`, + same: "Botloft ya está instalado.", + newer: (installed: string) => `Ya tienes una versión más nueva (${installed}).`, + open: "Abrir Botloft", + reinstall: "Instalar de nuevo", + appOpen: "Botloft está abierto y se cierra para instalar. Tus bots siguen trabajando.", + installing: "Instalando Botloft…", + installingLine: "Solo tarda unos segundos.", + progress: "Instalando", + done: "Todo listo.", + opening: "Abriendo Botloft…", + openFailed: "Botloft está instalado. Ábrelo desde el menú Inicio.", + failed: "No se pudo instalar Botloft.", + failedLine: "Inténtalo de nuevo o usa el instalador clásico.", + retry: "Intentar de nuevo", + classic: "Usar el instalador clásico", + folder: (path: string) => `Carpeta: ${path}`, + version: (version: string) => `Versión: ${version}`, + exitCode: (code: number) => `Código de salida: ${code}`, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index 8362d2f6..09eb9136 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -13,6 +13,7 @@ import { messages } from "./messages"; import { onboarding } from "./onboarding"; import { routines } from "./routines"; import { screens } from "./screens"; +import { setup } from "./setup"; import { shell } from "./shell"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -32,5 +33,6 @@ export const ptBR: Messages = { messages, routines, screens, + setup, tools, }; diff --git a/app/src/i18n/pt-BR/setup.ts b/app/src/i18n/pt-BR/setup.ts new file mode 100644 index 00000000..b3539d64 --- /dev/null +++ b/app/src/i18n/pt-BR/setup.ts @@ -0,0 +1,27 @@ +import type { Messages } from "../en"; + +export const setup: Messages["setup"] = { + tagline: "Bots sempre ligados, trabalhando juntos para você.", + install: "Instalar", + update: "Atualizar", + forYou: "Instala só para você, sem pedir administrador.", + older: (installed: string, version: string) => `Você tem a ${installed}. Esta é a ${version}.`, + same: "O Botloft já está instalado.", + newer: (installed: string) => `Você já tem uma versão mais nova (${installed}).`, + open: "Abrir o Botloft", + reinstall: "Instalar de novo", + appOpen: "O Botloft está aberto e fecha para instalar. Seus bots continuam trabalhando.", + installing: "Instalando o Botloft…", + installingLine: "Leva só alguns segundos.", + progress: "Instalando", + done: "Tudo pronto.", + opening: "Abrindo o Botloft…", + openFailed: "O Botloft está instalado. Abra pelo menu Iniciar.", + failed: "Não deu para instalar o Botloft.", + failedLine: "Tente de novo ou use o instalador clássico.", + retry: "Tentar de novo", + classic: "Usar o instalador clássico", + folder: (path: string) => `Pasta: ${path}`, + version: (version: string) => `Versão: ${version}`, + exitCode: (code: number) => `Código de saída: ${code}`, +}; diff --git a/app/src/setup/Setup.tsx b/app/src/setup/Setup.tsx new file mode 100644 index 00000000..74c7127d --- /dev/null +++ b/app/src/setup/Setup.tsx @@ -0,0 +1,223 @@ +// The setup window (spec 15.7): the mascot, the name and one button. The +// NSIS installer does the work behind it, quietly. + +import { X } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { BOTLOFT_COLOR, BotAvatar, type Mood } from "../features/bots/BotAvatar"; +import { useT } from "../i18n"; +import { useThemeRoot } from "../shell/theme"; +import { Button } from "../ui/Button"; +import { Details } from "../ui/Details"; +import { asFailure, type InstallFailure, type SetupHost, type SetupState } from "./host"; + +type Phase = + | { kind: "loading" } + | { kind: "ready"; state: SetupState } + | { kind: "installing"; state: SetupState } + | { kind: "done"; state: SetupState; openFailed: boolean } + | { kind: "failed"; state: SetupState | null; failure: InstallFailure }; + +/** How long "All set." shows before the app opens, in ms. */ +const OPEN_AFTER = 900; + +export function Setup({ host, openAfter = OPEN_AFTER }: { host: SetupHost; openAfter?: number }) { + useThemeRoot(); + const [phase, setPhase] = useState({ kind: "loading" }); + + useEffect(() => { + let alive = true; + host.state().then( + (state) => alive && setPhase({ kind: "ready", state }), + (error) => alive && setPhase({ kind: "failed", state: null, failure: asFailure(error) }), + ); + return () => { + alive = false; + }; + }, [host]); + + // The window stays hidden until there is something to show. + const drawn = phase.kind !== "loading"; + useEffect(() => { + if (drawn) { + host.show(); + } + }, [drawn, host]); + + const install = useCallback( + (state: SetupState) => { + setPhase({ kind: "installing", state }); + host.install().then( + () => setPhase({ kind: "done", state, openFailed: false }), + (error) => setPhase({ kind: "failed", state, failure: asFailure(error) }), + ); + }, + [host], + ); + + const open = useCallback( + (state: SetupState) => { + host.openApp().catch(() => setPhase({ kind: "done", state, openFailed: true })); + }, + [host], + ); + + // Done: the app opens by itself after a moment. + useEffect(() => { + if (phase.kind !== "done" || phase.openFailed) { + return; + } + const timer = setTimeout(() => open(phase.state), openAfter); + return () => clearTimeout(timer); + }, [phase, open, openAfter]); + + const closable = phase.kind !== "installing"; + return ( +
+
+ {closable && host.close()} />} +
+
+ {phase.kind === "loading" ? null : ( + + )} +
+
+ ); +} + +function CloseButton({ onClose }: { onClose: () => void }) { + const t = useT(); + return ( + + ); +} + +function Page({ + phase, + host, + onInstall, + onOpen, +}: { + phase: Exclude; + host: SetupHost; + onInstall: (state: SetupState) => void; + onOpen: (state: SetupState) => void; +}) { + const s = useT().setup; + switch (phase.kind) { + case "ready": + return ; + case "installing": + return ( + +
+ + ); + case "done": + return ( + + ); + case "failed": { + const { state, failure } = phase; + return ( + +
+ {state && ( + + )} + +
+
+
+ {failure.code !== null && `${s.exitCode(failure.code)}\n`} + {failure.detail} +
+
+ + ); + } + } +} + +function Ready({ + state, + onInstall, + onOpen, +}: { + state: SetupState; + onInstall: (state: SetupState) => void; + onOpen: (state: SetupState) => void; +}) { + const s = useT().setup; + const installed = state.installed ?? ""; + const line = { + none: s.tagline, + older: s.older(installed, state.version), + same: s.same, + newer: s.newer(installed), + }[state.relation]; + const closesApp = state.appRunning && state.relation !== "newer"; + return ( + +
+ {state.relation === "same" || state.relation === "newer" ? ( + + ) : ( + + )} + {state.relation === "same" && ( + + )} +
+ {closesApp &&

{s.appOpen}

} +
+ {state.relation !== "newer" &&

{s.forYou}

} +
+ {state.folder && `${s.folder(state.folder)}\n`} + {s.version(state.version)} +
+
+ + ); +} + +/** The mascot, a title and a line, with what goes under them. */ +function Frame({ + mood, + title, + line, + children, +}: { + mood: Mood; + title: string; + line: string; + children?: ReactNode; +}) { + return ( + <> + + + +

{title}

+

{line}

+ {children} + + ); +} + +function Footer({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/app/src/setup/fakeSetupHost.ts b/app/src/setup/fakeSetupHost.ts new file mode 100644 index 00000000..af8a0b5f --- /dev/null +++ b/app/src/setup/fakeSetupHost.ts @@ -0,0 +1,85 @@ +// A `SetupHost` in memory, for tests and the dev preview (`/setup.html`, +// with `?relation=older&installed=0.4.0&running&fail` to try each page). + +import type { InstallFailure, Relation, SetupHost, SetupState } from "./host"; + +export interface FakeSetupOptions { + relation?: Relation; + installed?: string | null; + appRunning?: boolean; + /** Makes `install` fail with this. */ + failure?: InstallFailure | null; + /** How long `install` takes, in ms. */ + delay?: number; +} + +export class FakeSetupHost implements SetupHost { + readonly calls: string[] = []; + private readonly current: SetupState; + private failure: InstallFailure | null; + private readonly delay: number; + + constructor(options: FakeSetupOptions = {}) { + const relation = options.relation ?? "none"; + this.current = { + version: "0.6.0", + rehearsal: true, + folder: "C:UsersAna LimaAppDataLocalBotloft", + installed: relation === "none" ? null : (options.installed ?? "0.5.0"), + relation, + appRunning: options.appRunning ?? false, + }; + this.failure = options.failure ?? null; + this.delay = options.delay ?? 0; + } + + /** Lets the next `install` succeed. */ + succeed(): void { + this.failure = null; + } + + async state(): Promise { + this.calls.push("state"); + return { ...this.current }; + } + + async install(): Promise { + this.calls.push("install"); + await new Promise((resolve) => setTimeout(resolve, this.delay)); + if (this.failure) { + throw this.failure; + } + } + + async openApp(): Promise { + this.calls.push("openApp"); + } + + async classic(): Promise { + this.calls.push("classic"); + } + + close(): void { + this.calls.push("close"); + } + + show(): void { + this.calls.push("show"); + } +} + +/** The dev preview's fake, set up from the page's query string. */ +export function previewSetupHost(search: string): SetupHost { + const params = new URLSearchParams(search); + const relation = params.get("relation"); + return new FakeSetupHost({ + relation: + relation === "older" || relation === "same" || relation === "newer" ? relation : "none", + installed: params.get("installed"), + appRunning: params.has("running"), + failure: params.has("fail") + ? { code: 2, detail: "Botloft-installer.exe /S stopped with exit code: 2" } + : null, + delay: 3000, + }); +} diff --git a/app/src/setup/host.ts b/app/src/setup/host.ts new file mode 100644 index 00000000..9423e7a4 --- /dev/null +++ b/app/src/setup/host.ts @@ -0,0 +1,48 @@ +// What the setup window needs from its host (spec 15.7): the commands in +// `src-setup/src/main.rs`, or a fake for tests and the dev preview. + +/** The installed Botloft next to the one this setup carries. */ +export type Relation = "none" | "older" | "same" | "newer"; + +export interface SetupState { + /** The version this setup installs. */ + version: string; + /** No installer inside (a dev build): the pages only pretend. */ + rehearsal: boolean; + /** Where Botloft goes. */ + folder: string | null; + /** The version already installed. */ + installed: string | null; + relation: Relation; + /** The app is open and closes to install. */ + appRunning: boolean; +} + +/** Why the installer did not finish. */ +export interface InstallFailure { + /** The installer's exit code, when it ran. */ + code: number | null; + detail: string; +} + +export interface SetupHost { + state(): Promise; + /** Runs the installer quietly; rejects with an `InstallFailure`. */ + install(): Promise; + /** Opens the installed app and closes the setup. */ + openApp(): Promise; + /** Hands over to the installer's own pages and closes the setup. */ + classic(): Promise; + close(): void; + /** Shows the window once the first page is drawn. */ + show(): void; +} + +/** A rejection from `install`, whatever shape it came in. */ +export function asFailure(error: unknown): InstallFailure { + if (typeof error === "object" && error !== null && "detail" in error) { + const { code, detail } = error as { code?: unknown; detail?: unknown }; + return { code: typeof code === "number" ? code : null, detail: String(detail) }; + } + return { code: null, detail: String(error) }; +} diff --git a/app/src/setup/main.tsx b/app/src/setup/main.tsx new file mode 100644 index 00000000..2ed180d6 --- /dev/null +++ b/app/src/setup/main.tsx @@ -0,0 +1,33 @@ +// Entry of the setup window (setup.html, spec 15.7). + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import type { SetupHost } from "./host"; +import { Setup } from "./Setup"; +import "../index.css"; +import "./setup.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("missing #root element"); +} + +async function host(): Promise { + if ("__TAURI_INTERNALS__" in window) { + const { tauriSetupHost } = await import("./tauriSetupHost"); + return tauriSetupHost(); + } + if (import.meta.env.DEV) { + const { previewSetupHost } = await import("./fakeSetupHost"); + return previewSetupHost(window.location.search); + } + throw new Error("The Botloft setup runs inside its own window"); +} + +host().then((setupHost) => { + createRoot(root).render( + + + , + ); +}); diff --git a/app/src/setup/setup.css b/app/src/setup/setup.css new file mode 100644 index 00000000..1150e7e3 --- /dev/null +++ b/app/src/setup/setup.css @@ -0,0 +1,38 @@ +/* The setup window's install bar (spec 15.7): the quiet installer reports + no percentage, so a stretch of the accent sweeps across. With less + motion the bar stays full and still. */ + +.setup-progress { + position: relative; + height: 4px; + width: 12rem; + overflow: hidden; + border-radius: 999px; + background: var(--sunken); +} + +.setup-progress::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 40%; + border-radius: inherit; + background: var(--accent); + animation: setup-sweep 1.4s ease-in-out infinite; +} + +@keyframes setup-sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(250%); + } +} + +@media (prefers-reduced-motion: reduce) { + .setup-progress::after { + width: 100%; + animation: none; + } +} diff --git a/app/src/setup/setup.test.tsx b/app/src/setup/setup.test.tsx new file mode 100644 index 00000000..6330cfba --- /dev/null +++ b/app/src/setup/setup.test.tsx @@ -0,0 +1,102 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeSetupHost, type FakeSetupOptions } from "./fakeSetupHost"; +import { asFailure } from "./host"; +import { Setup } from "./Setup"; + +afterEach(cleanup); + +async function open(options: FakeSetupOptions = {}) { + const host = new FakeSetupHost(options); + render(); + await screen.findByRole("heading"); + return host; +} + +const button = (name: string) => screen.queryByRole("button", { name }); + +describe("setup window", () => { + test("installs, then opens the app", async () => { + const host = await open(); + expect(screen.getByRole("heading", { name: "Botloft" })).toBeDefined(); + expect(screen.getByText(/without asking for an administrator/)).toBeDefined(); + expect(host.calls).toContain("show"); + + fireEvent.click(screen.getByRole("button", { name: "Install" })); + expect(screen.getByRole("progressbar")).toBeDefined(); + // Nothing closes the window halfway through. + expect(button("Close")).toBeNull(); + + await screen.findByText("All set."); + await waitFor(() => expect(host.calls).toContain("openApp")); + }); + + test("updates an older version", async () => { + await open({ relation: "older", installed: "0.5.0" }); + expect(screen.getByText("You have 0.5.0. This is 0.6.0.")).toBeDefined(); + expect(button("Update")).not.toBeNull(); + expect(button("Install")).toBeNull(); + }); + + test("the same version opens it, or installs again", async () => { + const host = await open({ relation: "same", installed: "0.6.0" }); + expect(screen.getByText("Botloft is already installed.")).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Install again" })); + await screen.findByText("All set."); + expect(host.calls).toContain("install"); + }); + + test("a newer version is only opened", async () => { + const host = await open({ relation: "newer", installed: "0.9.0" }); + expect(screen.getByText("You already have a newer version (0.9.0).")).toBeDefined(); + expect(button("Install")).toBeNull(); + expect(button("Install again")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Open Botloft" })); + expect(host.calls).toContain("openApp"); + expect(host.calls).not.toContain("install"); + }); + + test("says the open app closes and the bots keep working", async () => { + await open({ relation: "older", appRunning: true }); + expect(screen.getByText(/closes to install\. Your bots keep working/)).toBeDefined(); + }); + + test("a failed install offers to retry or the classic installer", async () => { + const host = await open({ failure: { code: 2, detail: "installer exited with 2" } }); + fireEvent.click(screen.getByRole("button", { name: "Install" })); + await screen.findByText("Botloft didn't install."); + expect(screen.getByText(/Exit code: 2/)).toBeDefined(); + expect(button("Close")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Use the classic installer" })); + expect(host.calls).toContain("classic"); + + host.succeed(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("All set."); + }); + + test("tells where to open the app when opening it fails", async () => { + const host = new FakeSetupHost(); + host.openApp = async () => { + throw new Error("cannot open Botloft.exe"); + }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Install" })); + await screen.findByText("Botloft is installed. Open it from the Start menu."); + }); + + test("closes from its own title strip", async () => { + const host = await open(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Close" }))); + expect(host.calls).toContain("close"); + }); +}); + +describe("install failures", () => { + test("keeps the exit code and detail, or makes do with any rejection", () => { + expect(asFailure({ code: 3, detail: "boom" })).toEqual({ code: 3, detail: "boom" }); + expect(asFailure({ code: null, detail: "no code" })).toEqual({ code: null, detail: "no code" }); + expect(asFailure("plain text")).toEqual({ code: null, detail: "plain text" }); + }); +}); diff --git a/app/src/setup/tauriSetupHost.ts b/app/src/setup/tauriSetupHost.ts new file mode 100644 index 00000000..19b81dea --- /dev/null +++ b/app/src/setup/tauriSetupHost.ts @@ -0,0 +1,21 @@ +// `SetupHost` over the commands in `src-setup/src/main.rs`. + +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { SetupHost, SetupState } from "./host"; + +export function tauriSetupHost(): SetupHost { + const window = getCurrentWindow(); + return { + state: () => invoke("setup_state"), + install: () => invoke("setup_install"), + openApp: () => invoke("setup_open_app"), + classic: () => invoke("setup_classic"), + close: () => { + window.close().catch(() => {}); + }, + show: () => { + window.show().catch(() => {}); + }, + }; +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 2635a21c..cfa8eb7d 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -8,7 +8,9 @@ import { defineConfig } from "vitest/config"; // Set by `tauri dev` when targeting a remote device; unused on desktop. const host = process.env.TAURI_DEV_HOST; -export default defineConfig({ +// `vite build --mode setup` builds the setup window alone (setup.html, +// spec 15.7) into dist-setup, which the botloft-setup crate embeds. +export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], // Keep Rust compiler errors visible in the terminal. clearScreen: false, @@ -32,6 +34,7 @@ export default defineConfig({ build: { // WebView2 on Windows is evergreen Chromium. target: "chrome120", + ...(mode === "setup" && { outDir: "dist-setup", rollupOptions: { input: "setup.html" } }), }, test: { environment: "jsdom", @@ -39,4 +42,4 @@ export default defineConfig({ // The mascot test reads the flame frames as text; other CSS stays empty. css: { include: [/mascot-flame\.css/] }, }, -}); +})); diff --git a/docs/spec.md b/docs/spec.md index 7c9f6ec3..3614dd80 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -67,8 +67,9 @@ botloft/ botloft-store/ SQLite: conexão, migrations, repositórios botloftd/ binário do daemon (rpc, supervisor, runtime, chat, courier, tools, platform) app/ - src/ React + src/ React (o app e, em src/setup/, a tela de instalação) src-tauri/ shell nativa + src-setup/ tela de instalação: crate botloft-setup, com o instalador NSIS dentro (15.7) docs/ spec.md adr/ @@ -606,6 +607,7 @@ app/src/ store/ stores Zustand alimentados por notificações ui/ componentes base dev/ prévia: `pnpm dev` num navegador comum usa FakeBotloft, ou um daemon de dev real com `?live=` (só em dev) + setup/ a tela de instalação (setup.html, 15.7): SetupHost, FakeSetupHost ``` Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem do Tauri. @@ -702,6 +704,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a ### 15.4 Instalador e sidecar - `pnpm bundle` (em `app/`) roda `scripts/sidecar.mjs`, que compila o `botloftd` em release e o copia para `src-tauri/binaries/botloftd-.exe`, e depois `tauri build --config src-tauri/tauri.bundle.conf.json`. Esse arquivo liga o `externalBin` e o NSIS; ele fica fora do `tauri.conf.json` porque o `tauri-build` copia o `externalBin` também em dev e no `cargo clippy`, o que exigiria o sidecar em todo build e sobrescreveria o `target\debug\botloftd.exe` com a cópia de release. +- Quem baixa o Botloft instala pela tela de instalação (15.7), que roda este instalador NSIS em silêncio. O NSIS com as próprias telas só aparece na atualização (15.5), na desinstalação e quando a tela de instalação não pode abrir. - NSIS por usuário (`installMode = currentUser`), sem pedir administrador, em `%LOCALAPPDATA%\Botloft` (seção 5). O instalador não mexe no daemon: ele roda da própria cópia em `\bin`, então o arquivo do sidecar nunca está em uso. - Hook `NSIS_HOOK_PREUNINSTALL` (`src-tauri/windows/hooks.nsh`): numa desinstalação de verdade, `botloftd service uninstall` para o daemon e apaga a tarefa e o binário, e o valor `Botloft` da chave Run sai (15.2); bots e dados ficam. Uma atualização também roda o desinstalador antigo, com `/UPDATE`: aí o hook não faz nada e o daemon continua rodando até o app novo abrir e atualizá-lo (15.2). - Marca do instalador, sem nome de terceiros à vista: o instalador e o desinstalador usam o ícone do app (`icons/icon.ico`); as telas de boas-vindas e de conclusão têm a imagem lateral `windows/installer-sidebar.bmp` (164×314, o mascote e "Botloft" sobre o preto) e as outras telas o cabeçalho `windows/installer-header.bmp` (150×57, à direita, com `MUI_HEADERIMAGE_RIGHT` no arquivo de hooks). As duas imagens saem do `app-icon.svg` sem o quadrado preto. O rodapé mostra o `bundle.copyright` no lugar de "Nullsoft Install System", e o `NSIS_HOOK_POSTINSTALL` grava "Botloft" como editor em Aplicativos instalados. O `bundle.publisher` fica vazio de propósito: o Tauri dá o nome dele à chave que guarda a pasta de instalação (`Software\\Botloft`, hoje `github`, a segunda parte do identificador), e trocá-lo faria a primeira atualização de uma instalação existente rodar o desinstalador antigo sem a pasta. Por isso a empresa nas propriedades do `Botloft.exe` continua `github`. @@ -711,20 +714,76 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - O app usa o `tauri-plugin-updater`. O feed é `latest.json` do último release publicado no GitHub (`releases/latest/download/latest.json`). O app o consulta ao abrir e a cada 6 h; sem rede ou sem release, fica quieto. Build de dev não consulta, para não se trocar pelo app publicado. - Com versão nova, aparece "Update available" na barra de título. Um clique abre o diálogo: a versão, "What's new" (se o release tiver notas) e o aviso de que o Botloft fecha, instala e abre de novo, e de que os bots pausam por um instante e continuam de onde pararam. "Update now" baixa com progresso e roda o instalador; se falhar, o erro fica em "Details" e dá para tentar de novo. -- O instalador roda como `/P /UPDATE /R`: passivo, sem perguntas, e reabre o app. O hook de desinstalação não faz nada com `/UPDATE` (15.4), o daemon segue rodando, e o app reaberto o atualiza porque ele ficou `outdated` (15.2). +- O feed aponta para o instalador NSIS do release, `Botloft__x64-update.exe`, e não para a tela de instalação (15.7). O instalador roda como `/P /UPDATE /R`: passivo, sem perguntas, e reabre o app. O hook de desinstalação não faz nada com `/UPDATE` (15.4), o daemon segue rodando, e o app reaberto o atualiza porque ele ficou `outdated` (15.2). - Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução. - Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório) e abre um release **rascunho** com o instalador, o `.sig` e o `latest.json` (`app/scripts/release.mjs`). O updater só enxerga o release depois que o dono o publica. ### 15.6 Idiomas -- O app fala **inglês, português (Brasil) e espanhol**. Todo texto que o dono lê fica em `app/src/i18n//`, um arquivo por área (`common`, `shell`, `onboarding`, `updates`, `bots`, `chat`, `crews`, `messages`). O inglês é a referência: o formato dele é o tipo `Messages`, e um texto que falte ou sobre em outro idioma não compila. Texto com valores é função (`ready(version)`), com o plural escrito para cada idioma. +- O app fala **inglês, português (Brasil) e espanhol**. Todo texto que o dono lê fica em `app/src/i18n//`, um arquivo por área (`common`, `shell`, `onboarding`, `updates`, `bots`, `chat`, `crews`, `messages`, `setup`). O inglês é a referência: o formato dele é o tipo `Messages`, e um texto que falte ou sobre em outro idioma não compila. Texto com valores é função (`ready(version)`), com o plural escrito para cada idioma. - Componentes leem com `useT()`; código fora do React (toasts, formatação, erros da conexão) com `t()` na hora do uso. - Escolha na área da conta (Idioma, ou Configurações), e no botão de idioma da barra de título só nas telas de preparo: "Idioma do sistema" segue o Windows (o primeiro idioma suportado entre os preferidos; `pt-PT` vira `pt-BR`; nenhum, inglês) ou um idioma fixo. A escolha fica no `localStorage` do app (`botloft.locale`) e marca ``. - Datas e horas (`lib/format.ts`) usam o idioma escolhido. - O daemon não escreve texto para o dono: avisos vêm com `code` e a linha da conversa com `kind` (8.2, 11.2), e o app escreve. Continuam como vêm: nomes, mensagens, respostas e saídas de ferramenta; o resumo de ferramenta que o daemon tira da entrada (o comando, o arquivo), sem o nome da ferramenta, que o app escreve; e as mensagens de erro do daemon (validação, falhas), mostradas como texto técnico. -- O instalador NSIS também traz os três idiomas e escolhe pelo idioma do Windows. +- O instalador NSIS e a tela de instalação (15.7) também trazem os três idiomas e escolhem pelo idioma do Windows. - Tom: palavras simples, sem jargão; "você" em português, "tú" em espanhol. Glossário: crew = equipe / equipo; task = tarefa / tarea; Allow / Deny = Permitir / Negar / Denegar; role = função / rol. +### 15.7 Tela de instalação + +**O que é.** O arquivo que o dono baixa, `Botloft__x64-setup.exe`, abre uma janela do próprio Botloft: o mascote, o nome, um botão e mais nada. Por baixo, ela roda o instalador NSIS (15.4) em modo silencioso (`/S`), que continua fazendo o trabalho pesado: arquivos, atalhos com a identidade que os avisos do Windows usam (15.1), a entrada em Aplicativos instalados, o desinstalador, o WebView2 e o hook do serviço. Assim o dono só vê a marca do Botloft, e a atualização, a desinstalação e o serviço não mudam. + +**Processo.** + +- É um app Tauri pequeno, o crate `botloft-setup` em `app/src-setup/`, com a interface em React em `app/src/setup/` (entrada `app/setup.html`). Ela usa os mesmos tokens, componentes (`ui/`), mascote (`BotAvatar`) e textos (`i18n//setup.ts`) do app. A interface depende só de `SetupHost` (`src/setup/host.ts`), com o `FakeSetupHost` para os testes e para a prévia: com `pnpm dev`, `/setup.html` num navegador comum mostra a tela, e `?relation=older&installed=0.5.0&running&fail` experimenta cada caso. +- O instalador NSIS vai dentro do executável (`include_bytes!`, pelo caminho em `BOTLOFT_SETUP_PAYLOAD` na compilação). Sem ele (build de dev, `cargo clippy`), o setup fica em modo de ensaio: mostra as telas e finge instalar em 3 s, sem tocar na máquina. +- Janela de 480×380, fixa, centrada e sem a moldura do Windows, com uma faixa de título própria (arrastável, só com o botão de fechar), no tema do Windows, claro ou escuro. Ela abre escondida e aparece quando a primeira tela está pronta. O WebView2 da janela guarda os arquivos dele em `%TEMP%\Botloft-setup-webview`, a mesma pasta a cada vez, e não numa pasta do setup em `%LOCALAPPDATA%`. +- O manifesto do setup declara `requestedExecutionLevel` `asInvoker`: o Windows trata programas com "setup" no nome como instaladores e pediria administrador sem isso. Nada no setup precisa de administrador. +- Antes de abrir a janela, o setup confere se o WebView2 existe. Sem ele, grava o instalador NSIS numa pasta temporária, roda-o com as telas dele (que já têm a marca, 15.4, e baixam o WebView2) e sai. + +**Telas.** + +- **Pronto para instalar:** o mascote parado, "Botloft", uma linha sobre o que ele é e o botão **Instalar**. Embaixo, "Instala só para você, sem pedir administrador." Em "Details", a pasta (`%LOCALAPPDATA%\Botloft`, ou a da instalação anterior) e a versão. O dono não escolhe pasta. +- **Já instalado**, lido da entrada em Aplicativos instalados (`HKCU\...\Uninstall\Botloft`: `DisplayVersion` e `InstallLocation`): + - versão mais antiga: o botão vira **Atualizar**, com "Você tem a . Esta é a ."; + - a mesma versão: "O Botloft já está instalado.", com **Abrir o Botloft** e **Instalar de novo**; + - versão mais nova: "Você já tem uma versão mais nova ().", só com **Abrir o Botloft**. +- **App aberto:** uma linha avisa que o Botloft fecha para instalar e que os bots continuam trabalhando, porque eles rodam no serviço (14). O setup fecha o `Botloft.exe` da pasta instalada antes de rodar o NSIS. +- **Instalando:** o mascote trabalhando, "Instalando o Botloft…" e uma barra sem porcentagem (o NSIS em silêncio não diz o progresso, e leva poucos segundos). O botão de fechar some até terminar, para a instalação não ficar pela metade. +- **Pronto:** "Tudo pronto." e, um instante depois, o setup abre o `Botloft.exe` da pasta em Aplicativos instalados e fecha. Se não conseguir abrir, diz para abrir o Botloft pelo menu Iniciar. Na primeira instalação, o app segue para o preparo de sempre (15.1). +- **Falhou:** o mascote cansado, "Não deu para instalar o Botloft.", **Tentar de novo** e **Usar o instalador clássico**, que roda o NSIS com as telas dele. Em "Details", o código de saída do NSIS. + +**Arquivos.** O setup grava o instalador NSIS em `%TEMP%\Botloft-setup-\`, roda `/S`, espera e apaga a pasta, também quando falha. O instalador clássico também espera: a janela se esconde, o setup aguarda o NSIS terminar, apaga a pasta e sai. Fora a pasta do WebView2, o setup não escreve log, não usa a rede e não guarda nada. A instalação em si, a pasta e o registro são os do NSIS (5, 15.4). + +**Build e release.** + +- `pnpm bundle:setup` (em `app/`, `scripts/setup.mjs`) roda `vite build --mode setup`, que gera só a `setup.html` em `app/dist-setup`, e `cargo build -p botloft-setup --release --features tauri/custom-protocol` com `BOTLOFT_SETUP_PAYLOAD` apontando para o instalador NSIS que o `pnpm bundle` acabou de gerar. O resultado vai para `target\release\bundle\setup\Botloft__x64-setup.exe`. Com `-- --rehearse`, sai sem o instalador dentro. O setup não usa o CLI do Tauri, porque não precisa de bundle: basta o executável. +- O release (15.5) passa a ter a tela de instalação como `Botloft__x64-setup.exe`, o nome que o README já indica, e o instalador NSIS como `Botloft__x64-update.exe` com o `.sig`, que é para onde o `latest.json` aponta. O `release.mjs` copia os dois com esses nomes antes de publicar. +- O setup não tem assinatura Authenticode, como o NSIS (15.5): o SmartScreen avisa ao abrir. O NSIS gravado na pasta temporária não tem a marca da internet e não passa de novo pelo SmartScreen. +- O executável fica maior, com a janela mais o NSIS inteiro: 15,4 MB na 0.5.0, contra 6,8 MB do NSIS sozinho. + +**A verificar** (com o NSIS do Tauri 2.12; anotar aqui o resultado): + +- `/S` por cima de uma versão instalada: se desinstala a anterior (como o `/P` faz pela página de reinstalação) ou só sobrescreve, e se algum arquivo antigo fica. **Mesma versão** (0.5.0 sobre 0.5.0, 2026-09-29): nenhum processo de desinstalador apareceu (olhando a cada 100 ms); o NSIS só sobrescreveu os arquivos. Falta ver com uma versão mais antiga (I3). +- `/S` com o app aberto: se o NSIS fecha o app sozinho, pergunta ou falha. O setup fecha o app antes (verificado: "Instalar de novo" com o app aberto fechou o app, instalou e abriu de novo), mas o comportamento do NSIS sozinho precisa ficar anotado. +- `/S` com uma versão mais nova instalada: se sai com erro (`silentDowngrades`) e com qual código. +- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. +- Com o manifesto `asInvoker`, o Windows não pede administrador para o setup. **Verificado** (Windows 11 25H2, 2026-09-29): o `Botloft_0.5.0_x64-setup.exe` abriu a janela direto, sem pedido do UAC. +- O identificador dos atalhos continua o mesmo, para os avisos saírem com o nome do Botloft. + +**Instalação real** (I1, Windows 11 25H2, 2026-09-29, 0.5.0): numa máquina com os arquivos do Botloft mas sem a entrada em Aplicativos instalados, a tela nova instalou em cerca de 3 s, criou a entrada (editor "Botloft", pasta `%LOCALAPPDATA%\Botloft`), abriu o app dessa pasta e apagou a pasta temporária. O daemon seguiu rodando, porque o instalador não mexe nele (15.4). + +Um teste que instala uma cópia em outra pasta e depois a desinstala apaga a entrada da instalação de verdade, porque a entrada tem o nome do produto e não o da pasta; e deixa a pasta de teste como a lembrada (`Software\github\Botloft`). Foi o que aconteceu nesta máquina. Para testar numa pasta separada, use outra conta do Windows. + +**Marcos.** + +| Marco | Entrega | Pronto quando | +|---|---|---| +| **I1** Tela | crate `botloft-setup`, telas com `FakeSetup` e a prévia `?setup`, instalação pelo NSIS em silêncio, abrir o app ao terminar, modo de ensaio sem o instalador dentro | instalar pela tela nova numa máquina sem o Botloft e o app abrir; testes das telas e do `SetupHost` | +| **I2** Release | `pnpm bundle:setup`, `release.mjs` e `release.yml` com os dois arquivos, README | um release rascunho com a tela de instalação, o `-update.exe`, o `.sig` e o `latest.json` certo | +| **I3** Acabamento | versão já instalada (mais antiga, igual, mais nova), app aberto, falha com o instalador clássico, sem WebView2 | cada caso testado à mão no Windows e anotado em "A verificar" | + +Esta seção entra no PR do I1, junto com o código. + ## 16. Qualidade - Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`.