diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd615d9..8998291 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,10 +98,21 @@ jobs: - name: Fumaça no app empacotado run: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/fumaca-empacotado.ts "dist/win-unpacked/WSLC UI.exe" + # O latest.yml é o índice que o auto-updater lê para descobrir que existe + # versão nova. Sem ele a release sai completa aos olhos de quem baixa na + # mão, e invisível para quem já tem o app instalado — erro que só apareceria + # uma versão depois. Por isso a checagem é aqui, e é dura. + - name: Conferir o índice do auto-updater + shell: bash + run: test -f dist/latest.yml || { echo '::error::latest.yml não foi gerado'; exit 1; } + - uses: actions/upload-artifact@v7 with: name: instaladores - path: dist/*.exe + path: | + dist/*.exe + dist/*.blockmap + dist/latest.yml if-no-files-found: error retention-days: 7 @@ -146,5 +157,9 @@ jobs: extra=() # Versão com sufixo (0.2.0-rc.1) sai marcada como pré-lançamento. if [[ "$VERSAO" == *-* ]]; then extra+=(--prerelease); fi - gh release create "$TAG" --title "$TAG" --notes-file notas.md --verify-tag "${extra[@]}" dist/*.exe + # O .blockmap é opcional (só o NSIS gera): sem ele o updater baixa o + # instalador inteiro em vez do diff. O latest.yml não é opcional. + shopt -s nullglob + anexos=(dist/*.exe dist/*.blockmap dist/latest.yml) + gh release create "$TAG" --title "$TAG" --notes-file notas.md --verify-tag "${extra[@]}" "${anexos[@]}" echo "[$TAG](https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG) publicada." >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 4f3a887..5049f11 100644 --- a/README.md +++ b/README.md @@ -272,10 +272,10 @@ Nesta fase o app **encapsula a CLI `wslc.exe`** via `execFile` sem shell (imune ### Fronteiras do processo main -O app fala com o mundo por quatro portas, e **todas são injetáveis**: o serviço da CLI -(`WslcService`), o motor nativo, os streams de longa duração e os efeitos externos (diálogos do -Electron, shell do Windows, busca no Docker Hub). Em produção elas são a implementação real; sob -`WSLC_UI_MOCK` viram dublês. É o que permite exercitar o app inteiro — inclusive o motor nativo — +O app fala com o mundo por cinco portas, e **todas são injetáveis**: o serviço da CLI +(`WslcService`), o motor nativo, os streams de longa duração, os efeitos externos (diálogos do +Electron, shell do Windows, busca no Docker Hub) e o auto-updater. Em produção elas são a +implementação real; sob `WSLC_UI_MOCK` viram dublês. É o que permite exercitar o app inteiro — inclusive o motor nativo — sem WSL, sem a `wslcsdk.dll` e sem abrir uma única janela do Windows. ## Requisitos @@ -519,6 +519,35 @@ desconhecido" e exigir _Mais informações → Executar assim mesmo_. Versão com sufixo (`0.2.0-rc.1`) sai marcada como pré-lançamento. +### Atualização automática + +Da 0.3.0 em diante o app se atualiza sozinho a partir das releases deste repositório +(`electron-updater` com o provedor GitHub). Quem estiver na 0.2.0 precisa instalar a 0.3.0 à mão — +a primeira versão que recebe atualização é a primeira que já traz o updater dentro. + +O que o app faz depende de como ele foi instalado: + +| Situação | O que acontece | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Instalado pelo setup | Checa ao abrir e a cada 6 h, baixa em segundo plano e **aplica quando o app fecha**. Dá para aplicar na hora pela aba Sistema. | +| Portátil | Checa, avisa e leva para a release. Trocar o `.exe` é de quem usa — que é o ponto de ser portátil. | +| Rodando do código-fonte | Desligado: não há instalação para atualizar por cima. | + +Só versões estáveis contam. Um `0.4.0-rc.1` continua publicado no GitHub, mas o updater o ignora. + +O que faz isso funcionar é o **`latest.yml`** que o `electron-builder` gera e o workflow anexa à +release: é o índice que o app consulta. Sem ele, a release fica completa para quem baixa à mão e +invisível para quem já tem o app instalado — falha silenciosa que só apareceria uma versão depois. +Por isso o job de empacotamento **falha** se o arquivo não existir, e o teste de fumaça confere que o +`app-update.yml` foi embutido no pacote. + +Instalar pela aba Sistema não é um `quitAndInstall` seco: o app encerra a sessão nativa **antes** de +entregar o processo ao instalador. O NSIS fecha quem demora, e um processo morto assim deixa a sessão +"WslcUi" órfã no WSL. + +O ciclo inteiro é exercitável sem release nenhuma: `WSLC_UI_MOCK_UPDATE=portable|disabled` escolhe o +modo e `WSLC_UI_MOCK_FAIL=updates:check,updates:download` reproduz os dois caminhos tristes. + ## Licença Código deste repositório: **MIT** — ver [LICENSE](LICENSE). diff --git a/e2e/atualizacoes.spec.ts b/e2e/atualizacoes.spec.ts new file mode 100644 index 0000000..b604359 --- /dev/null +++ b/e2e/atualizacoes.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from './fixtures/app' +import { expectToast, goto } from './fixtures/ui' + +/** + * Auto-updater na aba Sistema. + * + * O ciclo real (achar, baixar, instalar) só aconteceria com uma release + * publicada e um app instalado — tarde demais para descobrir que a tela está + * errada. Aqui ele roda contra o updater de demonstração, que percorre os + * mesmos estados em milissegundos e nos três modos. + */ + +const chip = (page: import('@playwright/test').Page, texto: string) => page.getByText(texto, { exact: true }) + +const procurar = (page: import('@playwright/test').Page) => + page.getByRole('button', { name: 'Procurar atualizações' }) + +test.describe('Atualizações · com instalador', () => { + test('encontra, baixa e fica pronta para instalar ao fechar', async ({ page }) => { + await goto(page, 'Sistema') + await expect(chip(page, 'não verificado')).toBeVisible() + + await procurar(page).click() + + await expect(chip(page, 'pronta para instalar')).toBeVisible() + await expectToast(page, /Versão \d+\.\d+\.\d+ pronta/) + await expect(page.getByRole('button', { name: 'Reiniciar e instalar agora' })).toBeVisible() + }) + + test('mostra as notas da versão nova', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + + await expect(page.getByText('Atualização automática a partir das releases do GitHub')).toBeVisible() + }) + + test('instalar encerra o app pelo caminho ordenado', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + await page.getByRole('button', { name: 'Reiniciar e instalar agora' }).click() + + await page.getByRole('button', { name: 'Expandir logs' }).click() + await expect(page.getByText(/\(demo\) app fechado para instalar a versão/)).toBeVisible() + }) +}) + +test.describe('Atualizações · caminho triste', () => { + test.describe('checagem falha', () => { + test.use({ fail: ['updates:check'] }) + + test('o erro aparece na própria seção', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + + await expect(chip(page, 'falhou')).toBeVisible() + await expect(page.getByText(/GitHub respondeu 503/)).toBeVisible() + }) + }) + + test.describe('download falha', () => { + test.use({ fail: ['updates:download'] }) + + // A versão encontrada continua à vista: é o que permite oferecer o caminho + // manual quando o automático não deu certo. + test('mantém a versão encontrada mesmo tendo falhado', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + + await expect(chip(page, 'falhou')).toBeVisible() + await expect(page.getByText(/conexão perdida durante o download/)).toBeVisible() + await expect(page.getByText(/^\d+\.\d+\.\d+$/).nth(1)).toBeVisible() + }) + }) +}) + +test.describe('Atualizações · portátil', () => { + test.use({ update: 'portable' }) + + test('avisa e leva para a release, sem instalar nada', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + + await expect(chip(page, 'versão nova')).toBeVisible() + await expectToast(page, /Versão \d+\.\d+\.\d+ disponível/) + await expect(page.getByRole('button', { name: 'Baixar na release' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Reiniciar e instalar agora' })).toHaveCount(0) + }) + + test('o link da release é um efeito externo registrado', async ({ page }) => { + await goto(page, 'Sistema') + await procurar(page).click() + await page.getByRole('button', { name: 'Baixar na release' }).click() + + await page.getByRole('button', { name: 'Expandir logs' }).click() + await expect(page.getByText(/\(demo\) link aberto no navegador:.*releases\/tag/)).toBeVisible() + }) +}) + +test.describe('Atualizações · rodando do código-fonte', () => { + test.use({ update: 'disabled' }) + + test('não oferece o que não pode cumprir', async ({ page }) => { + await goto(page, 'Sistema') + + await expect(procurar(page)).toBeDisabled() + await expect(page.getByText(/não há instalação para atualizar por cima/)).toBeVisible() + }) +}) diff --git a/e2e/fixtures/app.ts b/e2e/fixtures/app.ts index 16724eb..55c0f1d 100644 --- a/e2e/fixtures/app.ts +++ b/e2e/fixtures/app.ts @@ -33,6 +33,8 @@ export interface AppOptions { fail: string[] /** Caminho devolvido pelos diálogos de arquivo; 'cancel' simula cancelar. */ pick: string | undefined + /** Modo do auto-updater simulado — ver WSLC_UI_MOCK_UPDATE em mock-state.ts. */ + update: 'installer' | 'portable' | 'disabled' } interface AppFixtures { @@ -53,8 +55,9 @@ export const test = base.extend({ engine: ['cli', { option: true }], fail: [[], { option: true }], pick: [undefined, { option: true }], + update: ['installer', { option: true }], - app: async ({ mock, engine, fail, pick }, use) => { + app: async ({ mock, engine, fail, pick, update }, use) => { const userData = mkdtempSync(join(tmpdir(), 'wslc-ui-e2e-')) // O motor é lido do settings.json na primeira consulta: semear o arquivo // faz o app JÁ ABRIR no motor pedido, sem passar pela tela de Sistema. @@ -68,6 +71,7 @@ export const test = base.extend({ WSLC_UI_MOCK: mock, WSLC_UI_MOCK_FAIL: fail.join(','), WSLC_UI_MOCK_TICK_MS: '40', + WSLC_UI_MOCK_UPDATE: update, ...(pick === undefined ? {} : { WSLC_UI_MOCK_PICK: pick }) }) }) diff --git a/electron-builder.yml b/electron-builder.yml index a8ac972..eca74fc 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -63,5 +63,11 @@ nsis: portable: artifactName: ${name}-${version}-portable.${ext} -# Não publica sozinho: quem cria a release é o workflow, a partir da tag. -publish: null +# O provedor NÃO é para publicar daqui (o `dist` roda com --publish never; +# quem cria a release é o workflow). Ele existe por dois efeitos colaterais +# obrigatórios: gerar o `latest.yml`, que é o índice que o auto-updater lê, e +# embutir o `app-update.yml` no pacote, que é como o app sabe onde procurar. +publish: + provider: github + owner: blackfoxsoftware + repo: wslc-ui diff --git a/package-lock.json b/package-lock.json index 29acf0a..7eec281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "wslc-ui", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wslc-ui", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { + "electron-updater": "^6.8.9", "koffi": "^3.1.6", "zod": "^4.4.3" }, @@ -4787,7 +4788,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5069,7 +5069,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -5439,7 +5438,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5814,6 +5812,34 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-vite": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", @@ -6245,7 +6271,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -6510,7 +6535,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/happy-dom": { @@ -6848,7 +6872,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -6919,7 +6942,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -6969,7 +6991,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lightningcss": { @@ -7252,6 +7273,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7494,7 +7528,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8559,7 +8592,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -8948,6 +8980,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9120,7 +9158,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index aecc4c3..48b2057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wslc-ui", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "UI desktop para WSL Containers (wslc) — Electron + Vite", "license": "MIT", @@ -25,11 +25,12 @@ "typecheck:e2e": "tsc --noEmit -p tsconfig.e2e.json", "test:e2e": "npm run build && playwright test", "test:e2e:headed": "npm run build && playwright test --headed", - "dist": "npm run build && electron-builder --win --config electron-builder.yml", - "dist:dir": "npm run build && electron-builder --win --dir --config electron-builder.yml", + "dist": "npm run build && electron-builder --win --publish never --config electron-builder.yml", + "dist:dir": "npm run build && electron-builder --win --dir --publish never --config electron-builder.yml", "cenario:nativo": "esbuild scripts/cenario-nativo.ts --bundle --platform=node --format=esm --packages=external --outfile=node_modules/.cache/cenario-nativo.mjs --log-level=warning && node node_modules/.cache/cenario-nativo.mjs" }, "dependencies": { + "electron-updater": "^6.8.9", "koffi": "^3.1.6", "zod": "^4.4.3" }, diff --git a/patchnotes.json b/patchnotes.json index c6c878c..f616477 100644 --- a/patchnotes.json +++ b/patchnotes.json @@ -1,5 +1,25 @@ { "versoes": [ + { + "versao": "0.3.0", + "data": "2026-09-02", + "titulo": "O app passa a se atualizar sozinho a partir das releases.", + "mudancas": { + "adicionado": [ + "Atualização automática pelas releases deste repositório: o app instalado checa ao abrir e a cada 6 horas, baixa em segundo plano e aplica a versão nova quando você fecha o app.", + "Seção \"Atualizações\" na aba Sistema: versão instalada, última checagem, progresso do download, as notas da versão nova e os botões de procurar e de instalar na hora.", + "A versão portátil também avisa quando sai versão nova — e leva para a release, já que trocar o .exe é de quem usa.", + "Categoria \"Atualização\" no painel de logs: tudo o que o updater faz fica registrado, inclusive quando falha." + ], + "alterado": [ + "O encerramento do app virou um caminho único e compartilhado: fechar a janela e instalar uma atualização passam pelo mesmo desligamento ordenado, que solta a sessão nativa antes de sair.", + "Só versões estáveis contam como atualização. Pré-lançamentos (0.4.0-rc.1) continuam publicados, para quem quiser baixar à mão." + ], + "corrigido": [ + "O empacotamento passou a gerar e publicar o latest.yml, sem o qual uma release fica visível para quem baixa à mão e invisível para quem já tem o app instalado. O job de empacotamento agora falha se o arquivo não existir." + ] + } + }, { "versao": "0.2.0", "data": "2026-09-01", diff --git a/scripts/fumaca-empacotado.ts b/scripts/fumaca-empacotado.ts index 8d92c1c..ad828ae 100644 --- a/scripts/fumaca-empacotado.ts +++ b/scripts/fumaca-empacotado.ts @@ -1,6 +1,6 @@ -import { mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { _electron as electron } from '@playwright/test' import type { WslcApi } from '../src/shared/ipc/api' @@ -14,7 +14,8 @@ import type { WslcApi } from '../src/shared/ipc/api' * quebra sozinha, sem quebrar nada no E2E. * * Então este script abre o .exe de verdade e confere justamente isso: que a - * DLL veio de dentro do pacote e que a chamada CHEGOU nela. + * DLL veio de dentro do pacote, que a chamada CHEGOU nela, e que o app sabe + * onde procurar atualização. * * O que ele NÃO afirma é que o motor nativo funciona — isso depende de haver * WSL na máquina, e runner de CI não tem. Quem mede aquilo são os testes de @@ -53,6 +54,20 @@ try { else if (!/resources/i.test(status.dllPath)) problemas.push(`DLL fora de resources/: ${status.dllPath}`) if (status.source !== 'bundled') problemas.push(`DLL veio de "${status.source}", não do pacote`) + // O auto-updater depende de um arquivo que só existe no pacote: sem o + // app-update.yml em resources/, o app abre normalmente e nunca descobre que + // saiu versão nova — falha silenciosa, visível só uma release depois. + if (!existsSync(join(dirname(exe), 'resources', 'app-update.yml'))) { + problemas.push('resources/app-update.yml não foi embutido: o auto-updater não teria onde procurar') + } + + const update = await page.evaluate(() => + (globalThis as unknown as { wslcApi: WslcApi }).wslcApi.updateStatus() + ) + if (update.mode !== 'installer') { + problemas.push(`o updater se vê como "${update.mode}", e este build é o instalado`) + } + // Um HRESULT na mensagem prova que a chamada CHEGOU na DLL — ou seja, o koffi // carregou de fora do asar e os bindings casaram. Sem WSL a chamada falha // (0x80070032, ERROR_NOT_SUPPORTED), e tudo bem; o que não pode é falhar @@ -70,7 +85,7 @@ try { process.exitCode = 1 } else { console.log( - `\nOK: app empacotado abriu e alcançou a DLL em resources/.` + + `\nOK: app empacotado abriu, alcançou a DLL em resources/ e sabe onde procurar atualização.` + (status.available ? ` Motor nativo disponível, ABI ${status.abi}.` : ` Motor nativo não exercitável aqui: ${status.detail}`) diff --git a/src/main/index.ts b/src/main/index.ts index bba2562..becbf1e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,9 +2,7 @@ import { join } from 'node:path' import { app, BrowserWindow } from 'electron' import { registerIpc } from './ipc' import { initLogger, logInfo } from './services/logger' -import { ops } from './services/wslc/ops' -import { stopAllStreams } from './services/wslc/streams' -import { closeAllTerminals } from './services/wslc/terminals' +import { shutdownWslc } from './shutdown' import { createMainWindow } from './window' // Instância única: a sessão nativa "WslcUi" só pode ser aberta por um @@ -33,17 +31,10 @@ if (!app.requestSingleInstanceLock()) { } app.on('window-all-closed', () => { - logInfo('app', 'Todas as janelas fechadas — encerrando streams, terminais e containers nativos') - stopAllStreams() - // O SDK preview não reabre handles de container em outro processo: sem esta - // limpeza os containers nativos virariam órfãos ingerenciáveis. Remove tudo - // que o app criou e então solta a sessão (que continua viva no WSL). - const { native } = ops() - void closeAllTerminals() - .catch(() => undefined) - .then(() => native.cleanupContainers()) - .finally(() => { - native.releaseSession() - if (process.platform !== 'darwin') app.quit() - }) + // O app.quit() daqui é o que dá ao electron-updater a chance de aplicar uma + // atualização já baixada: ele instala no evento 'quit', depois que este + // encerramento terminou. + void shutdownWslc().finally(() => { + if (process.platform !== 'darwin') app.quit() + }) }) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 7bb8e91..598ae7c 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -6,6 +6,7 @@ import { currentEngine, getEngineStatus, setEngine } from '../services/wslc/engi import { ops } from '../services/wslc/ops' import { stopStream } from '../services/wslc/streams' import { closeTerminal, writeTerminal } from '../services/wslc/terminals' +import { shutdownWslc } from '../shutdown' import { rendererStreamSink, rendererTerminalSink, sendEvent } from './events' import { registerInvokeHandlers } from './router' @@ -15,7 +16,7 @@ export function registerIpc(): void { const service = resolveWslcService() // Fronteiras do processo (FFI nativa, streams da CLI e efeitos externos): // reais em produção, dubladas sob WSLC_UI_MOCK. Ver services/wslc/ops.ts. - const { native, stream, host } = ops() + const { native, stream, host, update } = ops() // Tuning persistido da sessão nativa → aplicado quando a sessão for criada. native.setTuning(readNativeTuning(settingsFile())) @@ -39,6 +40,15 @@ export function registerIpc(): void { } }) + // Auto-updater: cada transição (checou, achou, baixou, falhou) vira evento. + // Sem isso a UI teria que ficar perguntando — e um download leva minutos. + update.setOnChange((status) => { + for (const win of BrowserWindow.getAllWindows()) { + sendEvent(win.webContents, 'updates:status', status) + } + }) + update.start() + // Cada entrada de log vira um evento ao vivo para a view Logs. setOnLogEntry((entry) => { for (const win of BrowserWindow.getAllWindows()) { @@ -191,6 +201,16 @@ export function registerIpc(): void { writeSdkPath(settingsFile(), path) native.setSdkPath(path) }, + 'updates:status': () => update.status(), + 'updates:check': () => update.check(), + // Encerra a sessão nativa ANTES de entregar o app ao instalador: o NSIS + // fecha o processo se ele demorar, e um processo morto assim deixa a + // sessão "WslcUi" órfã no WSL. + 'updates:install': async () => { + await shutdownWslc() + update.install() + }, + 'system:native-status': () => native.status(), 'system:get-engine': () => getEngineStatus(), 'system:set-engine': ({ engine }) => setEngine(engine), diff --git a/src/main/ipc/router.test.ts b/src/main/ipc/router.test.ts index a12f27b..fab456b 100644 --- a/src/main/ipc/router.test.ts +++ b/src/main/ipc/router.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CommandResult, WslcEnvironment } from '@shared/schemas' +import type { CommandResult, UpdateStatus, WslcEnvironment } from '@shared/schemas' import { invokeChannels } from '@shared/ipc/contract' const handleMock = vi.fn() @@ -15,6 +15,18 @@ vi.mock('electron', () => ({ import { registerInvokeHandlers, unregisterInvokeHandlers, type InvokeHandlers } from './router' const okResult: CommandResult = { ok: true, code: 0, stdout: '', stderr: '' } +const updateFixture: UpdateStatus = { + mode: 'installer', + state: 'idle', + currentVersion: '0.0.0', + newVersion: null, + percent: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: null, + error: null, + reason: null +} const envFixture: WslcEnvironment = { wslInstalled: true, wslVersion: '2.9.3.0', @@ -93,6 +105,9 @@ function makeHandlers(overrides: Partial = {}): InvokeHandlers { 'system:open-external': () => undefined, 'system:install-wslc': async () => okResult, 'system:show-item': () => undefined, + 'updates:status': () => updateFixture, + 'updates:check': async () => updateFixture, + 'updates:install': async () => undefined, 'streams:stop': () => undefined, 'terminal:open': async () => 5, 'terminal:write': () => undefined, diff --git a/src/main/services/updater/index.ts b/src/main/services/updater/index.ts new file mode 100644 index 0000000..3e71b73 --- /dev/null +++ b/src/main/services/updater/index.ts @@ -0,0 +1,110 @@ +import { app } from 'electron' +import { autoUpdater } from 'electron-updater' +import type { UpdateStatus } from '@shared/schemas' +import { logDebug, logError, logInfo, logWarn } from '../logger' +import type { UpdateOps } from '../wslc/ops' +import { applyEvent, detectMode, initialStatus, type UpdateEvent } from './state' + +/** + * Auto-updater real: GitHub Releases via electron-updater. + * + * A release publicada pelo workflow leva os dois .exe e o `latest.yml` que o + * updater consulta. O que ele NÃO faz aqui é decidir sozinho o que mostrar — + * cada evento passa por `applyEvent` (state.ts), que é onde as regras moram. + */ + +/** De quanto em quanto tempo reconsultar o GitHub enquanto o app está aberto. */ +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 +/** A abertura do app tem coisa mais urgente a fazer do que falar com a rede. */ +const FIRST_CHECK_DELAY_MS = 15_000 + +/** `releaseNotes` vem como texto ou como lista de releases pulados. */ +function normalizeNotes(notes: unknown): string | null { + if (typeof notes === 'string') return notes.trim() || null + if (Array.isArray(notes)) { + const partes = notes + .map((n: { version?: string; note?: string | null }) => + [n.version ? `## ${n.version}` : '', n.note ?? ''].filter(Boolean).join('\n') + ) + .filter(Boolean) + return partes.length > 0 ? partes.join('\n\n') : null + } + return null +} + +export function createUpdateOps(): UpdateOps { + const mode = detectMode(app.isPackaged) + let status = initialStatus(app.getVersion(), mode) + let onChange: ((s: UpdateStatus) => void) | null = null + + const emit = (ev: UpdateEvent): void => { + const next = applyEvent(status, ev) + if (next === status) return + status = next + onChange?.(status) + } + + // O log do updater vai para o log do app: quando uma atualização falha na + // máquina de alguém, o motivo precisa estar em algum lugar que dê para pedir. + autoUpdater.logger = { + info: (m: unknown) => void logInfo('update', String(m)), + warn: (m: unknown) => void logWarn('update', String(m)), + error: (m: unknown) => void logError('update', String(m)), + debug: (m: string) => void logDebug('update', m) + } + // Baixar sozinho só faz sentido onde existe um instalador para aplicar. + autoUpdater.autoDownload = mode === 'installer' + autoUpdater.autoInstallOnAppQuit = mode === 'installer' + // Decisão de projeto: pré-lançamento (0.3.0-rc.1) não é atualização. Os rc + // continuam publicados, para quem quiser baixar na mão. + autoUpdater.allowPrerelease = false + + autoUpdater.on('checking-for-update', () => emit({ type: 'checking' })) + autoUpdater.on('update-not-available', () => emit({ type: 'up-to-date', at: Date.now() })) + autoUpdater.on('update-available', (info) => + emit({ + type: 'available', + version: info.version, + notes: normalizeNotes(info.releaseNotes), + at: Date.now() + }) + ) + autoUpdater.on('download-progress', (p) => emit({ type: 'progress', percent: p.percent })) + autoUpdater.on('update-downloaded', (info) => emit({ type: 'downloaded', version: info.version })) + autoUpdater.on('error', (e: Error) => emit({ type: 'error', message: e.message || String(e) })) + + const check = async (): Promise => { + if (mode === 'disabled') return status + // Já baixada: checar de novo só serviria para baixar a mesma coisa outra vez. + if (status.state === 'downloaded') return status + try { + await autoUpdater.checkForUpdates() + } catch { + // O evento 'error' já registrou o motivo no estado; aqui só evitamos que + // uma falha de rede vire rejeição não tratada no processo main. + } + return status + } + + return { + status: () => status, + check, + install: () => { + if (mode !== 'installer' || status.state !== 'downloaded') return + logInfo('update', `Instalando a versão ${status.newVersion} e reabrindo o app`) + autoUpdater.quitAndInstall() + }, + setOnChange: (cb) => { + onChange = cb + }, + start: () => { + if (mode === 'disabled') { + logInfo('update', `Auto-updater desligado: ${status.reason}`) + return + } + logInfo('update', `Auto-updater ligado (modo ${mode}, versão ${status.currentVersion})`) + setTimeout(() => void check(), FIRST_CHECK_DELAY_MS).unref() + setInterval(() => void check(), CHECK_INTERVAL_MS).unref() + } + } +} diff --git a/src/main/services/updater/mock.ts b/src/main/services/updater/mock.ts new file mode 100644 index 0000000..180c3c2 --- /dev/null +++ b/src/main/services/updater/mock.ts @@ -0,0 +1,101 @@ +import type { UpdateStatus } from '@shared/schemas' +import { logInfo } from '../logger' +import { mockUpdateMode, shouldFail, tickMs } from '../wslc/mock-state' +import type { UpdateOps } from '../wslc/ops' +import { applyEvent, initialStatus, type UpdateEvent } from './state' + +/** + * Auto-updater de demonstração. + * + * Sem ele o ciclo inteiro (achar, baixar, instalar) só existiria em produção, + * numa release já publicada — tarde demais para descobrir que a tela está + * errada. Aqui ele roda em segundos, nos três modos (instalador, portátil e + * desligado) e com o caminho triste sob demanda: + * + * WSLC_UI_MOCK_UPDATE=portable o app não se instala sozinho + * WSLC_UI_MOCK_FAIL=updates:check a checagem falha + * WSLC_UI_MOCK_FAIL=updates:download acha a versão, mas o download morre + * + * O que ele nunca faz é fechar o app: em teste isso mataria a sessão do + * Playwright no meio da verificação. + */ + +/** Versão fictícia oferecida: a minor seguinte à que está rodando. */ +function nextVersion(current: string): string { + const [major = '0', minor = '0'] = current.split('.') + return `${major}.${Number.parseInt(minor, 10) + 1}.0` +} + +const DEMO_NOTES = [ + '### Adicionado', + '- Atualização automática a partir das releases do GitHub', + '', + '### Corrigido', + '- Progresso do pull não zerava ao trocar de motor' +].join('\n') + +const wait = (): Promise => new Promise((resolve) => setTimeout(resolve, tickMs())) + +export function createMockUpdateOps(currentVersion: string): UpdateOps { + const mode = mockUpdateMode() + let status = initialStatus(currentVersion, mode) + let onChange: ((s: UpdateStatus) => void) | null = null + + const emit = (ev: UpdateEvent): void => { + const next = applyEvent(status, ev) + if (next === status) return + status = next + onChange?.(status) + } + + /** Download simulado: só no modo instalador, e depois que `check` resolveu. */ + const fakeDownload = (version: string): void => { + const steps = [25, 60, 90] + let i = 0 + const timer = setInterval(() => { + if (shouldFail('updates:download')) { + clearInterval(timer) + emit({ type: 'error', message: '(demo) conexão perdida durante o download [falha injetada]' }) + return + } + if (i < steps.length) { + emit({ type: 'progress', percent: steps[i] }) + i++ + return + } + clearInterval(timer) + emit({ type: 'downloaded', version }) + }, tickMs()) + timer.unref?.() + } + + const check = async (): Promise => { + if (mode === 'disabled') return status + if (status.state === 'downloaded') return status + emit({ type: 'checking' }) + await wait() + if (shouldFail('updates:check')) { + emit({ type: 'error', message: '(demo) GitHub respondeu 503 [falha injetada em updates:check]' }) + return status + } + const version = nextVersion(currentVersion) + emit({ type: 'available', version, notes: DEMO_NOTES, at: Date.now() }) + if (mode === 'installer') fakeDownload(version) + return status + } + + return { + status: () => status, + check, + install: () => { + if (mode !== 'installer' || status.state !== 'downloaded') return + logInfo('update', `(demo) app fechado para instalar a versão ${status.newVersion}`) + }, + setOnChange: (cb) => { + onChange = cb + }, + // De propósito não checa sozinho: um aviso de atualização surgindo no meio + // de qualquer teste de outra tela seria ruído injetado por nós mesmos. + start: () => logInfo('update', `(demo) auto-updater pronto (modo ${mode})`) + } +} diff --git a/src/main/services/updater/state.test.ts b/src/main/services/updater/state.test.ts new file mode 100644 index 0000000..041784c --- /dev/null +++ b/src/main/services/updater/state.test.ts @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import type { UpdateStatus } from '@shared/schemas' +import { applyEvent, detectMode, GITHUB_OWNER, GITHUB_REPO, initialStatus, releasePageUrl } from './state' + +const status = (over: Partial = {}): UpdateStatus => ({ + ...initialStatus('0.2.0', 'installer'), + ...over +}) + +describe('detectMode', () => { + it('rodando do código-fonte não tem o que atualizar', () => { + expect(detectMode(false, {})).toBe('disabled') + // Nem mesmo com a variável do portátil presente: sem empacotar, não há app. + expect(detectMode(false, { PORTABLE_EXECUTABLE_DIR: 'C:/tmp' })).toBe('disabled') + }) + + it('PORTABLE_EXECUTABLE_DIR marca o .exe portátil', () => { + expect(detectMode(true, { PORTABLE_EXECUTABLE_DIR: 'C:/Users/x/Downloads' })).toBe('portable') + }) + + it('empacotado sem a variável é uma instalação', () => { + expect(detectMode(true, {})).toBe('installer') + }) +}) + +describe('initialStatus', () => { + it('explica por que está desligado quando está', () => { + expect(initialStatus('0.2.0', 'disabled').reason).toMatch(/código-fonte/) + expect(initialStatus('0.2.0', 'portable').reason).toMatch(/portátil/) + expect(initialStatus('0.2.0', 'installer').reason).toBeNull() + }) +}) + +describe('applyEvent', () => { + it('sem updater, evento nenhum mexe no estado', () => { + const inicial = status({ mode: 'disabled' }) + expect(applyEvent(inicial, { type: 'available', version: '9.9.9', notes: null, at: 1 })).toBe(inicial) + expect(applyEvent(inicial, { type: 'downloaded', version: '9.9.9' })).toBe(inicial) + }) + + it('checar limpa o erro anterior', () => { + const depois = applyEvent(status({ state: 'error', error: 'rede caiu' }), { type: 'checking' }) + expect(depois.state).toBe('checking') + expect(depois.error).toBeNull() + }) + + it('não recheca o que já foi baixado', () => { + const baixada = status({ state: 'downloaded', newVersion: '0.3.0', percent: 100 }) + expect(applyEvent(baixada, { type: 'checking' })).toBe(baixada) + }) + + it('em dia apaga a versão nova de uma checagem anterior', () => { + const antes = status({ state: 'available', newVersion: '0.3.0', releaseNotes: 'nota' }) + const depois = applyEvent(antes, { type: 'up-to-date', at: 1_700_000_000_000 }) + expect(depois).toMatchObject({ + state: 'up-to-date', + newVersion: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: 1_700_000_000_000 + }) + }) + + it('achar uma versão traz notas e o endereço da release', () => { + const depois = applyEvent(status(), { + type: 'available', + version: '0.3.0', + notes: '### Corrigido', + at: 42 + }) + expect(depois).toMatchObject({ + state: 'available', + newVersion: '0.3.0', + releaseNotes: '### Corrigido', + releaseUrl: releasePageUrl('0.3.0'), + checkedAt: 42 + }) + }) + + it('progresso vira percentual inteiro entre 0 e 100', () => { + expect(applyEvent(status(), { type: 'progress', percent: 33.7 }).percent).toBe(34) + expect(applyEvent(status(), { type: 'progress', percent: -5 }).percent).toBe(0) + expect(applyEvent(status(), { type: 'progress', percent: 150 }).percent).toBe(100) + expect(applyEvent(status(), { type: 'progress', percent: 10 }).state).toBe('downloading') + }) + + it('progresso atrasado não desfaz um download concluído', () => { + const baixada = status({ state: 'downloaded', percent: 100 }) + expect(applyEvent(baixada, { type: 'progress', percent: 60 })).toBe(baixada) + }) + + it('falhar no download preserva a versão, para poder oferecer a release', () => { + const baixando = status({ state: 'downloading', newVersion: '0.3.0', percent: 40 }) + const depois = applyEvent(baixando, { type: 'error', message: 'conexão perdida' }) + expect(depois).toMatchObject({ state: 'error', newVersion: '0.3.0', error: 'conexão perdida' }) + expect(depois.releaseUrl).toBe(baixando.releaseUrl) + }) + + // O caso que mais importa: o arquivo já está no disco e vai ser instalado ao + // fechar o app. Uma falha DEPOIS disso (a checagem seguinte, por exemplo) não + // pode fazer a UI dizer que não há atualização nenhuma. + it('erro depois de baixada não apaga a atualização pronta', () => { + const baixada = status({ state: 'downloaded', newVersion: '0.3.0', percent: 100 }) + const depois = applyEvent(baixada, { type: 'error', message: 'GitHub fora do ar' }) + expect(depois.state).toBe('downloaded') + expect(depois.percent).toBe(100) + expect(depois.error).toBe('GitHub fora do ar') + }) +}) + +describe('releasePageUrl', () => { + it('aponta para a tag que o workflow cria', () => { + expect(releasePageUrl('0.3.0')).toBe('https://github.com/blackfoxsoftware/wslc-ui/releases/tag/v0.3.0') + }) + + // O updater procura a versão nova pelo publish do electron-builder; o link + // "baixar na release" é montado aqui. Se os dois apontarem para repositórios + // diferentes, o app avisa de uma versão e manda buscar em outro lugar. + it('usa o mesmo repositório que o publish do electron-builder', () => { + const yml = readFileSync('electron-builder.yml', 'utf8') + expect(yml).toContain(`owner: ${GITHUB_OWNER}`) + expect(yml).toContain(`repo: ${GITHUB_REPO}`) + }) +}) diff --git a/src/main/services/updater/state.ts b/src/main/services/updater/state.ts new file mode 100644 index 0000000..e070a9c --- /dev/null +++ b/src/main/services/updater/state.ts @@ -0,0 +1,131 @@ +import type { UpdateMode, UpdateStatus } from '@shared/schemas' + +/** + * Núcleo do auto-updater: o estado e as transições, sem Electron e sem rede. + * + * A parte do updater que erra não é a que fala com o GitHub — é a que decide o + * que mostrar depois. Aqui ficam as regras que importam, testáveis sem subir + * app nenhum: quem pode se atualizar, e o que cada evento faz com o estado. + */ + +/** As mesmas coordenadas do `publish` do electron-builder.yml. */ +export const GITHUB_OWNER = 'blackfoxsoftware' +export const GITHUB_REPO = 'wslc-ui' + +/** + * Página da release de uma versão. O provedor do electron-updater devolve os + * arquivos, não o endereço da release — e o modo portátil precisa justamente + * do endereço, porque lá a atualização é manual. + */ +export function releasePageUrl(version: string): string { + return `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tag/v${version}` +} + +export const DISABLED_REASON = + 'O app está rodando do código-fonte: não há instalação para atualizar por cima.' +export const PORTABLE_REASON = 'A versão portátil não se instala sozinha — o app avisa e leva para a release.' + +/** + * Em que situação este processo está. + * + * `PORTABLE_EXECUTABLE_DIR` é posta pelo .exe auto-extraível do alvo portable: + * é o único sinal confiável de que não existe instalação por baixo. Sem esta + * checagem, o portátil baixaria um instalador NSIS e o rodaria contra uma + * pasta temporária — trocando o app da pessoa por um que ela não pediu. + */ +export function detectMode(packaged: boolean, env: NodeJS.ProcessEnv = process.env): UpdateMode { + if (!packaged) return 'disabled' + if (env['PORTABLE_EXECUTABLE_DIR']) return 'portable' + return 'installer' +} + +export function initialStatus(currentVersion: string, mode: UpdateMode): UpdateStatus { + return { + mode, + state: 'idle', + currentVersion, + newVersion: null, + percent: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: null, + error: null, + reason: mode === 'disabled' ? DISABLED_REASON : mode === 'portable' ? PORTABLE_REASON : null + } +} + +export type UpdateEvent = + | { type: 'checking' } + | { type: 'up-to-date'; at: number } + | { type: 'available'; version: string; notes: string | null; at: number } + | { type: 'progress'; percent: number } + | { type: 'downloaded'; version: string } + | { type: 'error'; message: string } + +const clampPercent = (n: number): number => Math.max(0, Math.min(100, Math.round(n))) + +/** + * Próximo estado. Três regras aqui não são óbvias e são as que sustentam o + * resto: + * + * 1. Sem updater (modo 'disabled'), evento nenhum mexe no estado. Um evento + * perdido não pode acender na UI um botão que não instala nada. + * 2. Erro DEPOIS de uma versão já baixada não apaga a versão baixada: ela + * continua no disco e vai ser instalada ao fechar o app. O erro vira só uma + * mensagem ao lado. + * 3. Falha ao baixar preserva `newVersion`/`releaseUrl` — é o que permite + * oferecer "baixar da release" quando o caminho automático não deu certo. + */ +export function applyEvent(status: UpdateStatus, ev: UpdateEvent): UpdateStatus { + if (status.mode === 'disabled') return status + + switch (ev.type) { + case 'checking': + // Já baixada é o fim da linha desta execução: a próxima abertura já é a + // versão nova, então checar de novo não teria o que fazer com o resultado. + if (status.state === 'downloaded') return status + return { ...status, state: 'checking', error: null } + + case 'up-to-date': + return { + ...status, + state: 'up-to-date', + newVersion: null, + percent: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: ev.at, + error: null + } + + case 'available': + return { + ...status, + state: 'available', + newVersion: ev.version, + releaseNotes: ev.notes, + releaseUrl: releasePageUrl(ev.version), + checkedAt: ev.at, + percent: null, + error: null + } + + case 'progress': + if (status.state === 'downloaded') return status + return { ...status, state: 'downloading', percent: clampPercent(ev.percent) } + + case 'downloaded': + return { + ...status, + state: 'downloaded', + newVersion: ev.version, + releaseUrl: releasePageUrl(ev.version), + percent: 100, + error: null + } + + case 'error': + if (status.state === 'downloaded') return { ...status, error: ev.message } + return { ...status, state: 'error', percent: null, error: ev.message } + } +} diff --git a/src/main/services/wslc/mock-ops.ts b/src/main/services/wslc/mock-ops.ts index 3776f1f..8b6cd0d 100644 --- a/src/main/services/wslc/mock-ops.ts +++ b/src/main/services/wslc/mock-ops.ts @@ -15,7 +15,9 @@ import type { VhdVolumeOptions, VolumeInfo } from '@shared/schemas' +import { app } from 'electron' import { logInfo } from '../logger' +import { createMockUpdateOps } from '../updater/mock' import { currentMockWorld } from './mock' import { failHard, failure, markEnvironmentInstalled, pickedPath, shouldFail, tickMs } from './mock-state' import { allocStreamId, registerStream, releaseStream, type StreamSink } from './streams' @@ -644,5 +646,10 @@ const hostOps: HostOps = { } export function createMockOps(): Ops { - return { native: nativeOps, stream: streamOps, host: hostOps } + return { + native: nativeOps, + stream: streamOps, + host: hostOps, + update: createMockUpdateOps(app.getVersion()) + } } diff --git a/src/main/services/wslc/mock-state.ts b/src/main/services/wslc/mock-state.ts index 24b05ca..b2b9ecc 100644 --- a/src/main/services/wslc/mock-state.ts +++ b/src/main/services/wslc/mock-state.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from '@shared/schemas' +import type { CommandResult, UpdateMode } from '@shared/schemas' /** * Estado e ajustes do modo de demonstração (WSLC_UI_MOCK). @@ -12,6 +12,7 @@ import type { CommandResult } from '@shared/schemas' * WSLC_UI_MOCK_PICK=cancel diálogo de arquivo cancelado * WSLC_UI_MOCK_PICK=C:\caminho\x.tar caminho devolvido pelo diálogo * WSLC_UI_MOCK_TICK_MS=40 cadência dos streams falsos + * WSLC_UI_MOCK_UPDATE=portable modo do auto-updater simulado * * As chaves de falha são os canais do contrato IPC (`volumes:create`), mais * `engine:native` para a criação da sessão nativa. @@ -50,6 +51,16 @@ export function tickMs(): number { return Number.isInteger(raw) && raw > 0 ? raw : 80 } +/** + * Modo do auto-updater simulado. O padrão é 'installer' porque é o caso que + * a maioria das pessoas terá; 'portable' e 'disabled' existem para as telas + * que só aparecem neles serem alcançáveis em teste. + */ +export function mockUpdateMode(): UpdateMode { + const raw = process.env['WSLC_UI_MOCK_UPDATE'] + return raw === 'portable' || raw === 'disabled' ? raw : 'installer' +} + /** Caminho devolvido pelos diálogos de arquivo; 'cancel' simula o cancelamento. */ export function pickedPath(fallback: string): string | null { const raw = process.env['WSLC_UI_MOCK_PICK'] diff --git a/src/main/services/wslc/ops.ts b/src/main/services/wslc/ops.ts index 3332fcf..fa69c28 100644 --- a/src/main/services/wslc/ops.ts +++ b/src/main/services/wslc/ops.ts @@ -12,6 +12,7 @@ import type { RegistryImage, RunContainerOptions, SdkProbe, + UpdateStatus, VhdVolumeOptions, VolumeInfo } from '@shared/schemas' @@ -23,10 +24,11 @@ import type { TerminalSink } from './terminals' /** * Fronteiras injetáveis do processo main. * - * O app já trocava a CLI por um dublê (`WslcService` / `mock.ts`), mas três - * superfícies continuavam presas ao mundo real: o motor NATIVO (FFI na - * wslcsdk.dll), os STREAMS da CLI (spawn do wslc.exe) e os EFEITOS externos - * (diálogos do Electron, shell do Windows, busca no Docker Hub). + * O app já trocava a CLI por um dublê (`WslcService` / `mock.ts`), mas o resto + * continuava preso ao mundo real: o motor NATIVO (FFI na wslcsdk.dll), os + * STREAMS da CLI (spawn do wslc.exe), os EFEITOS externos (diálogos do + * Electron, shell do Windows, busca no Docker Hub) e o AUTO-UPDATER (GitHub + * Releases, e o fechamento do app para aplicar a versão nova). * * Isso deixava metade do app fora de alcance de teste — inclusive o motor * nativo inteiro. Aqui elas viram interface, com implementação real @@ -117,10 +119,25 @@ export interface HostOps { searchRegistry(query: string): Promise } +/** Auto-updater: rede, disco e o fechamento do app para aplicar a versão nova. */ +export interface UpdateOps { + /** Estado atual. Não fala com a rede. */ + status(): UpdateStatus + /** Procura atualização agora; resolve com o estado depois da checagem. */ + check(): Promise + /** Aplica a atualização já baixada (fecha o app). */ + install(): void + /** Cada transição de estado — a UI acompanha sem ficar perguntando. */ + setOnChange(cb: (status: UpdateStatus) => void): void + /** Liga a checagem automática (na abertura e periódica). */ + start(): void +} + export interface Ops { native: NativeOps stream: StreamOps host: HostOps + update: UpdateOps } /** WSLC_UI_MOCK=1 (demo) ou =setup (ambiente incompleto) usam os dublês. */ diff --git a/src/main/services/wslc/real-ops.ts b/src/main/services/wslc/real-ops.ts index ec99713..223373a 100644 --- a/src/main/services/wslc/real-ops.ts +++ b/src/main/services/wslc/real-ops.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process' import { dialog, shell } from 'electron' import { searchDockerHub } from '../registry' +import { createUpdateOps } from '../updater' import { WSLC } from './cli' import { cleanupNativeContainers, @@ -159,5 +160,5 @@ const hostOps: HostOps = { } export function createRealOps(): Ops { - return { native: nativeOps, stream: streamOps, host: hostOps } + return { native: nativeOps, stream: streamOps, host: hostOps, update: createUpdateOps() } } diff --git a/src/main/shutdown.ts b/src/main/shutdown.ts new file mode 100644 index 0000000..a6480f0 --- /dev/null +++ b/src/main/shutdown.ts @@ -0,0 +1,36 @@ +import { logInfo } from './services/logger' +import { ops } from './services/wslc/ops' +import { stopAllStreams } from './services/wslc/streams' +import { closeAllTerminals } from './services/wslc/terminals' + +/** + * Encerramento ordenado do lado wslc, na ordem que importa: parar o que está + * em curso, limpar os containers que precisam ser limpos e só então soltar a + * sessão nativa. + * + * Soltar a sessão não é opcional. Um processo que morre sem soltá-la deixa a + * sessão "WslcUi" viva no WSL, e a abertura seguinte recebe + * ERROR_ALREADY_EXISTS — o app fica sem motor nativo até alguém desligar o WSL + * na mão. + * + * Roda UMA vez por processo: fechar a janela e instalar uma atualização levam + * os dois a este mesmo caminho, e a segunda passada não teria o que fazer além + * de atrasar a saída. + */ +let running: Promise | null = null + +export function shutdownWslc(): Promise { + running ??= (async () => { + logInfo('app', 'Encerrando: streams, terminais e containers nativos') + stopAllStreams() + await closeAllTerminals().catch(() => undefined) + // Na ABI 2.9.3 os containers nativos precisam sair aqui, senão viram + // registros órfãos e invisíveis; da 2.9.9 em diante eles ficam, e o app os + // reabre pelo nome. Quem decide é cleanupContainers, pela ABI da DLL. + await ops() + .native.cleanupContainers() + .catch(() => undefined) + ops().native.releaseSession() + })() + return running +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 85aadd0..bcfcb7e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,6 +85,10 @@ const api: WslcApi = { pickSaveFile: (title, defaultName, extensions) => invoke('system:pick-save', { title, defaultName, extensions }), openExternal: (url) => invoke('system:open-external', { url }), + updateStatus: () => invoke('updates:status', undefined), + checkForUpdates: () => invoke('updates:check', undefined), + installUpdate: () => invoke('updates:install', undefined), + onUpdateStatus: (cb) => subscribe('updates:status', cb), streamLogs: (id) => invoke('containers:logs', { id }), pullImage: (ref) => invoke('images:pull', { ref }), stopStream: (streamId) => invoke('streams:stop', { streamId }), diff --git a/src/renderer/src/components/app-shell.tsx b/src/renderer/src/components/app-shell.tsx index 7757f49..69851e3 100644 --- a/src/renderer/src/components/app-shell.tsx +++ b/src/renderer/src/components/app-shell.tsx @@ -12,6 +12,7 @@ import { useLogsStore } from '@/features/logs/store' import { useEngineStore } from '@/stores/engine-store' import { useEnvStore } from '@/stores/env-store' import { initStreamSubscriptions } from '@/stores/stream-store' +import { initUpdateSubscription } from '@/stores/update-store' const SESSION_END_REASONS: Record = { shutdown: 'o WSL foi desligado', @@ -30,6 +31,9 @@ export default function AppShell(): React.JSX.Element { useEffect(() => initStreamSubscriptions(), []) + // Auto-updater: o main empurra cada transição; o aviso sai do store. + useEffect(() => initUpdateSubscription(), []) + // Entradas de log ao vivo do processo main → store da view Logs. useEffect(() => window.wslcApi.onLogEntry((entry) => useLogsStore.getState().append(entry)), []) diff --git a/src/renderer/src/components/logs-panel.tsx b/src/renderer/src/components/logs-panel.tsx index 2c1d009..682237b 100644 --- a/src/renderer/src/components/logs-panel.tsx +++ b/src/renderer/src/components/logs-panel.tsx @@ -31,7 +31,8 @@ const CATEGORY_LABEL: Record = { native: 'Nativo', engine: 'Motor', stream: 'Streams', - terminal: 'Terminal' + terminal: 'Terminal', + update: 'Atualização' } const LEVEL_OPTIONS = (Object.keys(LEVEL_LABEL) as LogLevel[]).map((l) => ({ diff --git a/src/renderer/src/features/system/Fact.tsx b/src/renderer/src/features/system/Fact.tsx new file mode 100644 index 0000000..be9f308 --- /dev/null +++ b/src/renderer/src/features/system/Fact.tsx @@ -0,0 +1,9 @@ +/** Linha de dado dos blocos de Sistema: rótulo à esquerda, valor à direita. */ +export function Fact({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element { + return ( +
+
{label}
+
{children}
+
+ ) +} diff --git a/src/renderer/src/features/system/SystemView.tsx b/src/renderer/src/features/system/SystemView.tsx index 89812c1..3f18dcf 100644 --- a/src/renderer/src/features/system/SystemView.tsx +++ b/src/renderer/src/features/system/SystemView.tsx @@ -26,6 +26,8 @@ import { confirmDialog } from '@/stores/confirm-store' import { useEngineStore } from '@/stores/engine-store' import { useEnvStore } from '@/stores/env-store' import { useNativeStore } from '@/stores/native-store' +import { Fact } from './Fact' +import UpdateCard from './UpdateCard' /** Rótulo de onde a DLL em uso saiu (nativeStatus.source). */ const SDK_SOURCES: Record, string> = { @@ -98,16 +100,6 @@ const parseField = (raw: string): number | undefined => { return Number.isInteger(n) && n > 0 ? n : undefined } -/** Linha de dado do bloco de ambiente: rótulo à esquerda, valor à direita. */ -function Fact({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element { - return ( -
-
{label}
-
{children}
-
- ) -} - export default function SystemView(): React.JSX.Element { const env = useEnvStore((s) => s.env) const recheck = useEnvStore((s) => s.refresh) @@ -313,6 +305,8 @@ export default function SystemView(): React.JSX.Element { ))} + + = { + idle: { label: 'não verificado', tone: 'default' }, + checking: { label: 'verificando…', tone: 'default' }, + 'up-to-date': { label: 'em dia', tone: 'success' }, + available: { label: 'versão nova', tone: 'accent' }, + downloading: { label: 'baixando', tone: 'accent' }, + downloaded: { label: 'pronta para instalar', tone: 'success' }, + error: { label: 'falhou', tone: 'danger' } +} + +const formatMoment = (ms: number): string => + new Date(ms).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) + +export default function UpdateCard(): React.JSX.Element { + const status = useUpdateStore((s) => s.status) + const checking = useUpdateStore((s) => s.checking) + const check = useUpdateStore((s) => s.check) + const install = useUpdateStore((s) => s.install) + + const chip = status ? CHIP[status.state] : { label: 'consultando…', tone: 'default' as const } + const disabled = status?.mode === 'disabled' + const busy = checking || status?.state === 'checking' + + return ( + } + className="xl:col-span-2" + title="Atualizações" + > +
+ + {status?.currentVersion ?? '-'} + + + {status?.checkedAt ? formatMoment(status.checkedAt) : 'ainda não'} + + + {status?.newVersion ?? '-'} + + + estáveis + + +
+ + {status?.state === 'downloading' && ( + + + + + + )} + +

+ {disabled && status?.reason} + {status?.mode === 'portable' && + 'A versão portátil não se instala sozinha: o app avisa quando sai uma versão nova e leva para a release, onde você baixa o .exe novo por cima do antigo.'} + {status?.mode === 'installer' && + 'O app procura atualização ao abrir e a cada 6 horas, baixa em segundo plano e aplica quando você fecha o app — nada é interrompido no meio do caminho.'} +

+ + {status?.error && ( +

+ {status.state === 'downloaded' + ? `A atualização já baixada continua valendo. Último erro: ${status.error}` + : status.error} +

+ )} + + {status?.releaseNotes && status.state !== 'up-to-date' && ( +
+          {status.releaseNotes}
+        
+ )} + +
+ + {status?.state === 'downloaded' && ( + + )} + {status?.releaseUrl && status.mode === 'portable' && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/stores/update-store.test.ts b/src/renderer/src/stores/update-store.test.ts new file mode 100644 index 0000000..46573e3 --- /dev/null +++ b/src/renderer/src/stores/update-store.test.ts @@ -0,0 +1,86 @@ +import { toast } from '@/design' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '@shared/schemas' +import { installWslcApiMock } from '../test/wslc-api' +import { useUpdateStore } from './update-store' + +const status = (over: Partial = {}): UpdateStatus => ({ + mode: 'installer', + state: 'idle', + currentVersion: '0.2.0', + newVersion: null, + percent: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: null, + error: null, + reason: null, + ...over +}) + +beforeEach(() => { + vi.spyOn(toast, 'success').mockImplementation(() => '') + vi.spyOn(toast, 'info').mockImplementation(() => '') + vi.spyOn(toast, 'danger').mockImplementation(() => '') + useUpdateStore.setState({ status: null, checking: false }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useUpdateStore', () => { + it('avisa uma vez quando a atualização fica pronta', () => { + const { receive } = useUpdateStore.getState() + receive(status({ state: 'downloading', newVersion: '0.3.0', percent: 40 })) + expect(toast.success).not.toHaveBeenCalled() + + receive(status({ state: 'downloaded', newVersion: '0.3.0', percent: 100 })) + expect(toast.success).toHaveBeenCalledWith( + 'Versão 0.3.0 pronta — será instalada quando você fechar o app.', + expect.anything() + ) + }) + + // O main empurra o status a cada evento, e o download emite muitos: sem a + // comparação de estado anterior, isso viraria uma cascata de avisos. + it('progresso não vira uma enxurrada de avisos', () => { + const { receive } = useUpdateStore.getState() + for (const percent of [10, 30, 60, 90]) { + receive(status({ state: 'downloading', newVersion: '0.3.0', percent })) + } + expect(toast.success).not.toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + expect(useUpdateStore.getState().status?.percent).toBe(90) + }) + + it('no portátil o aviso leva para a release, porque não há o que instalar', () => { + installWslcApiMock() + useUpdateStore.getState().receive( + status({ + mode: 'portable', + state: 'available', + newVersion: '0.3.0', + releaseUrl: 'https://github.com/blackfoxsoftware/wslc-ui/releases/tag/v0.3.0' + }) + ) + expect(toast.info).toHaveBeenCalledWith('Versão 0.3.0 disponível.', expect.anything()) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('estar em dia não avisa nada', () => { + useUpdateStore.getState().receive(status({ state: 'up-to-date', checkedAt: 1 })) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + }) + + it('checar guarda o resultado e desliga o "procurando"', async () => { + const api = installWslcApiMock({ + checkForUpdates: vi.fn(async () => status({ state: 'up-to-date', checkedAt: 7 })) + }) + await useUpdateStore.getState().check() + expect(api.checkForUpdates).toHaveBeenCalled() + expect(useUpdateStore.getState().status?.state).toBe('up-to-date') + expect(useUpdateStore.getState().checking).toBe(false) + }) +}) diff --git a/src/renderer/src/stores/update-store.ts b/src/renderer/src/stores/update-store.ts new file mode 100644 index 0000000..d54ec8f --- /dev/null +++ b/src/renderer/src/stores/update-store.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand' +import type { UpdateStatus } from '@shared/schemas' +import { toast } from '@/design' + +interface UpdateState { + status: UpdateStatus | null + /** Uma checagem manual está em curso (o botão da aba Sistema). */ + checking: boolean + load: () => Promise + check: () => Promise + install: () => Promise + /** Estado empurrado pelo processo main. */ + receive: (status: UpdateStatus) => void +} + +/** + * Avisa só quando há o que fazer, e uma vez por transição. + * + * O aviso é disparado pela MUDANÇA de estado, não pelo estado: o main empurra + * o status a cada evento (incluindo progresso de download), e um toast por + * evento viraria uma cascata. + */ +function notify(before: UpdateStatus | null, after: UpdateStatus): void { + if (before?.state === after.state) return + if (after.state === 'downloaded') { + toast.success(`Versão ${after.newVersion} pronta — será instalada quando você fechar o app.`, { + timeout: 10_000 + }) + return + } + // No portátil o ciclo para em 'available': não há instalador para aplicar. + if (after.state === 'available' && after.mode === 'portable' && after.releaseUrl) { + const url = after.releaseUrl + toast.info(`Versão ${after.newVersion} disponível.`, { + description: 'A versão portátil é trocada à mão: baixe o .exe novo na release.', + timeout: 12_000, + actionProps: { + children: 'Abrir a release', + onPress: () => void window.wslcApi.openExternal(url) + } + }) + } +} + +export const useUpdateStore = create()((set, get) => ({ + status: null, + checking: false, + load: async () => { + try { + set({ status: await window.wslcApi.updateStatus() }) + } catch { + // melhor-esforço: sem status o card mostra "verificando…" + } + }, + check: async () => { + set({ checking: true }) + try { + const status = await window.wslcApi.checkForUpdates() + notify(get().status, status) + set({ status }) + } catch (e) { + toast.danger(e instanceof Error ? e.message : String(e)) + } finally { + set({ checking: false }) + } + }, + install: async () => { + // Não há "depois" para tratar: o app fecha para o instalador rodar. + await window.wslcApi.installUpdate() + }, + receive: (status) => { + notify(get().status, status) + set({ status }) + } +})) + +/** Liga o store aos eventos do main. Devolve o cancelamento da assinatura. */ +export function initUpdateSubscription(): () => void { + void useUpdateStore.getState().load() + return window.wslcApi.onUpdateStatus((status) => useUpdateStore.getState().receive(status)) +} diff --git a/src/renderer/src/test/wslc-api.ts b/src/renderer/src/test/wslc-api.ts index 175ad74..373d4d1 100644 --- a/src/renderer/src/test/wslc-api.ts +++ b/src/renderer/src/test/wslc-api.ts @@ -1,9 +1,22 @@ import { vi } from 'vitest' import type { WslcApi } from '@shared/ipc/api' -import type { CommandResult, Engine, WslcEnvironment } from '@shared/schemas' +import type { CommandResult, Engine, UpdateStatus, WslcEnvironment } from '@shared/schemas' const okResult = (): CommandResult => ({ ok: true, code: 0, stdout: '', stderr: '' }) +const idleUpdate = (): UpdateStatus => ({ + mode: 'installer', + state: 'idle', + currentVersion: '0.0.0-test', + newVersion: null, + percent: null, + releaseNotes: null, + releaseUrl: null, + checkedAt: null, + error: null, + reason: null +}) + const readyEnv: WslcEnvironment = { wslInstalled: true, wslVersion: '2.9.3.0', @@ -91,6 +104,10 @@ export function installWslcApiMock(overrides: Partial = {}): WslcApi { pickFile: vi.fn(async () => null), pickSaveFile: vi.fn(async () => null), openExternal: vi.fn(async () => undefined), + updateStatus: vi.fn(async () => idleUpdate()), + checkForUpdates: vi.fn(async () => idleUpdate()), + installUpdate: vi.fn(async () => undefined), + onUpdateStatus: vi.fn(() => () => {}), streamLogs: vi.fn(async () => 1), pullImage: vi.fn(async () => 1), stopStream: vi.fn(async () => undefined), diff --git a/src/shared/ipc/api.ts b/src/shared/ipc/api.ts index b7a6f7d..30d1b53 100644 --- a/src/shared/ipc/api.ts +++ b/src/shared/ipc/api.ts @@ -23,6 +23,7 @@ import type { StreamProgressEvent, TerminalDataEvent, TerminalExitEvent, + UpdateStatus, VhdVolumeOptions, VolumeInfo, WindowStateEvent, @@ -123,6 +124,14 @@ export interface WslcApi { /** Diálogo nativo de salvar arquivo. */ pickSaveFile(title: string, defaultName: string, extensions: string[]): Promise openExternal(url: string): Promise + /** Estado do auto-updater (não dispara rede). */ + updateStatus(): Promise + /** Procura atualização agora; resolve com o estado já atualizado. */ + checkForUpdates(): Promise + /** Fecha o app e aplica a atualização baixada (só no modo instalador). */ + installUpdate(): Promise + /** Cada transição do updater: checou, achou, baixou, falhou. */ + onUpdateStatus(cb: (status: UpdateStatus) => void): () => void streamLogs(id: string): Promise pullImage(ref: string): Promise stopStream(streamId: number): Promise diff --git a/src/shared/ipc/contract.ts b/src/shared/ipc/contract.ts index 7d9b177..62c2e85 100644 --- a/src/shared/ipc/contract.ts +++ b/src/shared/ipc/contract.ts @@ -25,6 +25,7 @@ import { streamProgressEventSchema, terminalDataEventSchema, terminalExitEventSchema, + updateStatusSchema, vhdVolumeOptionsSchema, volumeSchema, windowStateEventSchema, @@ -179,6 +180,13 @@ export const invokeContract = { 'system:pick-sdk': { input: z.void(), output: sdkProbeSchema.nullable() }, 'system:set-sdk-path': { input: z.object({ path: z.string().nullable() }), output: z.void() }, + // Auto-updater. `check` devolve o estado JÁ com o resultado da checagem; o + // resto do ciclo (download, erro) chega por evento, porque leva minutos. + 'updates:status': { input: z.void(), output: updateStatusSchema }, + 'updates:check': { input: z.void(), output: updateStatusSchema }, + // Fecha o app e aplica a atualização já baixada. Só existe no modo instalador. + 'updates:install': { input: z.void(), output: z.void() }, + 'streams:stop': { input: z.object({ streamId: streamIdSchema }), output: z.void() }, // Terminal embutido: shell interativo dentro do container (os dois motores). @@ -210,7 +218,8 @@ export const eventContract = { 'window:state': windowStateEventSchema, 'native:session-ended': nativeSessionEndedEventSchema, 'native:crash-dump': nativeCrashDumpEventSchema, - 'setup:install-progress': installProgressEventSchema + 'setup:install-progress': installProgressEventSchema, + 'updates:status': updateStatusSchema } as const satisfies Record export type InvokeContract = typeof invokeContract diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index fe288c0..520720d 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -223,6 +223,49 @@ export const sdkProbeSchema = z.object({ detail: z.string() }) +/** + * Como este app consegue (ou não) se atualizar sozinho. + * + * 'installer' — instalado pelo setup: baixa e aplica a atualização ao fechar. + * 'portable' — o .exe portátil não se instala: o app só avisa e leva para a + * release. Trocar o arquivo é de quem usa, que é o ponto de ser + * portátil. + * 'disabled' — rodando do código-fonte (electron-vite): não há release para + * comparar, e sobrescrever `out/` seria destruir o repositório. + */ +export const updateModeSchema = z.enum(['installer', 'portable', 'disabled']) + +export const updateStateSchema = z.enum([ + 'idle', + 'checking', + 'up-to-date', + 'available', + 'downloading', + 'downloaded', + 'error' +]) + +/** Estado do auto-updater, como a aba Sistema o mostra. */ +export const updateStatusSchema = z.object({ + mode: updateModeSchema, + state: updateStateSchema, + currentVersion: z.string(), + /** Versão nova encontrada; null enquanto não houver. */ + newVersion: z.string().nullable(), + /** 0–100 durante o download. */ + percent: z.number().nullable(), + /** Corpo da release (as notas do patchnotes.json), quando o GitHub o manda. */ + releaseNotes: z.string().nullable(), + /** Página da release nova — para onde o portátil manda quem quer atualizar. */ + releaseUrl: z.string().nullable(), + /** epoch ms da última checagem concluída; null se nunca checou. */ + checkedAt: z.number().nullable(), + /** Mensagem do último erro, ao lado do estado que sobreviveu a ele. */ + error: z.string().nullable(), + /** Por que o updater está desligado (só quando mode é 'disabled'). */ + reason: z.string().nullable() +}) + /** Motor de execução: CLI (wslc.exe) ou API nativa (wslcsdk.dll via FFI). */ export const engineSchema = z.enum(['cli', 'native']) @@ -335,7 +378,16 @@ export const terminalExitEventSchema = z.object({ export const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error']) -export const logCategorySchema = z.enum(['app', 'ipc', 'cli', 'native', 'engine', 'stream', 'terminal']) +export const logCategorySchema = z.enum([ + 'app', + 'ipc', + 'cli', + 'native', + 'engine', + 'stream', + 'terminal', + 'update' +]) /** Uma entrada do sistema de logs do app (processo main). */ export const logEntrySchema = z.object({ @@ -385,3 +437,6 @@ export type EngineStatus = z.infer export type NativeSessionEndedEvent = z.infer export type NativeCrashDumpEvent = z.infer export type InstallProgressEvent = z.infer +export type UpdateMode = z.infer +export type UpdateState = z.infer +export type UpdateStatus = z.infer