Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ node_modules/
!.vscode/extensions.json
.DS_Store
Thumbs.db

# Setup window build (spec 15.7)
/app/dist-setup/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
14 changes: 2 additions & 12 deletions app/scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) {
Expand Down
47 changes: 47 additions & 0 deletions app/scripts/setup.mjs
Original file line number Diff line number Diff line change
@@ -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_<version>_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}`);
17 changes: 17 additions & 0 deletions app/scripts/workspace.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions app/setup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Botloft</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/setup/main.tsx"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions app/src-setup/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generated by Tauri (capability schemas for editor autocompletion)
/gen/schemas
22 changes: 22 additions & 0 deletions app/src-setup/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions app/src-setup/build.rs
Original file line number Diff line number Diff line change
@@ -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");
}
12 changes: 12 additions & 0 deletions app/src-setup/capabilities/default.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
125 changes: 125 additions & 0 deletions app/src-setup/src/installed.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub folder: Option<PathBuf>,
}

/// Registry strings may come quoted (`"C:\..."`).
fn unquote(value: &str) -> Option<String> {
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<PathBuf>,
remembered: Option<PathBuf>,
local_app_data: Option<PathBuf>,
) -> Option<PathBuf> {
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\<publisher>\<product>`; 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<String> {
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(UNINSTALL_KEY)
.ok()?
.get_value::<String, _>(name)
.ok()
}

pub fn remembered_folder() -> Option<String> {
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(FOLDER_KEY)
.ok()?
.get_value::<String, _>("")
.ok()
}
}

#[cfg(not(windows))]
mod registry {
pub fn entry(_name: &str) -> Option<String> {
None
}

pub fn remembered_folder() -> Option<String> {
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<PathBuf> {
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);
}
}
Loading
Loading