From aa3e96f21820a68065e42ff3c143ad2b2101342d Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:44:00 -0300 Subject: [PATCH] feat(release): ship the setup window, with NSIS as the updater's installer A release now carries two installers (spec 15.7, I2): the setup window people download, as Botloft__x64-setup.exe (the name the README already gives), and the NSIS installer the in-app updater runs, as Botloft__x64-update.exe with its signature. latest.json points to the second. release.yml builds the setup window after the signed installer. release.mjs gains a `stage` step that copies both under their final names and writes latest.json, so the files can be checked without a release; `publish` stages and uploads. The updater tells an NSIS installer by its bytes, not its name (tauri-plugin-updater 2.13, extract_exe), so the new name is safe. CI now also builds the setup page, so it cannot break only at release time. --- .github/workflows/ci.yml | 2 ++ .github/workflows/release.yml | 7 +++-- CLAUDE.md | 2 +- app/scripts/release.mjs | 55 ++++++++++++++++++++++++----------- docs/spec.md | 9 +++--- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fae49bf..bc20efc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,5 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm check - run: pnpm build + - name: The setup window's page builds too (spec 15.7) + run: pnpm exec vite build --mode setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0747615b..6c09891f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: Release # Pushing a tag vX.Y.Z (matching the workspace version) builds the signed -# installer and opens a draft release; publishing it makes the in-app -# updater offer it (spec 15.5). +# installer and the setup window around it, and opens a draft release; +# publishing it makes the in-app updater offer it (spec 15.5, 15.7). on: push: tags: ["v*"] @@ -38,6 +38,9 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + - name: Build the setup window around it + run: pnpm bundle:setup + working-directory: app - name: Open a draft release run: node app/scripts/release.mjs publish env: diff --git a/CLAUDE.md b/CLAUDE.md index 16aa534b..32c5badb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release 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. +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 a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. diff --git a/app/scripts/release.mjs b/app/scripts/release.mjs index 0788743c..a629d2da 100644 --- a/app/scripts/release.mjs +++ b/app/scripts/release.mjs @@ -1,14 +1,19 @@ -// Release steps for .github/workflows/release.yml (spec 15.5). +// Release steps for .github/workflows/release.yml (spec 15.5, 15.7). // // node app/scripts/release.mjs check the tag matches the workspace version -// node app/scripts/release.mjs publish draft GitHub release with the installer, -// its signature and latest.json +// node app/scripts/release.mjs stage the release files, with their final names, +// in target/release/bundle/release +// node app/scripts/release.mjs publish stage, then a draft GitHub release with them // -// latest.json is the feed the in-app updater reads from the latest published -// release. A draft is invisible to it until the owner publishes the release. +// A release carries two installers: the setup window people download +// (`-setup.exe`, from `pnpm bundle:setup`) and the NSIS installer the in-app +// updater runs (`-update.exe`, from `pnpm bundle`, with its signature). +// latest.json, the feed the updater reads from the latest published release, +// points to the second. A draft is invisible to it until the owner publishes +// the release. import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { root, workspaceVersion } from "./workspace.mjs"; @@ -23,11 +28,21 @@ function check() { console.log(`releasing ${version}`); } -function publish() { +/** Copies the release files under their final names and writes latest.json. */ +function stage() { const version = workspaceVersion(); - const dir = join(root, "target", "release", "bundle", "nsis"); - const installer = `Botloft_${version}_x64-setup.exe`; - const signature = readFileSync(join(dir, `${installer}.sig`), "utf8").trim(); + const bundle = join(process.env.CARGO_TARGET_DIR ?? join(root, "target"), "release", "bundle"); + const built = `Botloft_${version}_x64-setup.exe`; + const setup = `Botloft_${version}_x64-setup.exe`; + const update = `Botloft_${version}_x64-update.exe`; + const out = join(bundle, "release"); + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + copyFileSync(join(bundle, "setup", built), join(out, setup)); + copyFileSync(join(bundle, "nsis", built), join(out, update)); + copyFileSync(join(bundle, "nsis", `${built}.sig`), join(out, `${update}.sig`)); + const signature = readFileSync(join(out, `${update}.sig`), "utf8").trim(); const feed = { version, notes: "", @@ -35,21 +50,25 @@ function publish() { platforms: { "windows-x86_64": { signature, - url: `https://github.com/${repo}/releases/download/${tag}/${installer}`, + url: `https://github.com/${repo}/releases/download/${tag}/${update}`, }, }, }; - const latest = join(dir, "latest.json"); - writeFileSync(latest, `${JSON.stringify(feed, null, 2)}\n`); + writeFileSync(join(out, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`); + const files = [setup, update, `${update}.sig`, "latest.json"].map((name) => join(out, name)); + console.log(`staged in ${out}:\n ${files.join("\n ")}`); + return { version, files }; +} + +function publish() { + const { version, files } = stage(); execFileSync( "gh", [ "release", "create", tag, - join(dir, installer), - join(dir, `${installer}.sig`), - latest, + ...files, "--draft", "--title", `Botloft ${version}`, @@ -62,8 +81,10 @@ function publish() { const step = process.argv[2]; if (step === "check") { check(); +} else if (step === "stage") { + stage(); } else if (step === "publish") { publish(); } else { - throw new Error("usage: release.mjs check|publish"); + throw new Error("usage: release.mjs check|stage|publish"); } diff --git a/docs/spec.md b/docs/spec.md index 3614dd80..58ed43b6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -716,7 +716,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - 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 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. +- 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), depois `pnpm bundle:setup` (15.7), e abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json` (`app/scripts/release.mjs`). O updater só enxerga o release depois que o dono o publica. ### 15.6 Idiomas @@ -757,7 +757,8 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a **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 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 stage` copia os dois com esses nomes para `target\release\bundle\release\` e escreve o `latest.json`; o `publish` faz o `stage` e sobe o que ficou lá. O `stage` roda sozinho para conferir os arquivos sem criar release. +- O CI do app também roda `vite build --mode setup`, para a página do setup não quebrar só na hora do release. - 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. @@ -766,7 +767,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - `/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. +- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. Falta ver a atualização de verdade da 0.5.0 para a 0.6.0. - 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. @@ -779,7 +780,7 @@ Um teste que instala uma cópia em outra pasta e depois a desinstala apaga a ent | 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 | +| **I2** Release | `pnpm bundle:setup`, `release.mjs` e `release.yml` com os dois arquivos; o README vai num PR próprio, junto com a 0.6.0 | 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.