diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e097c5b..b32b8f21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: "### Downloads" "" '- **Installer (recommended):** `ControlFS-Setup-x64.exe` — per-user install, no admin; updates itself automatically.' - '- **Portable:** `ControlFS-Portable-x64.zip` — unzip anywhere and run `ControlFS.exe`; updates are manual.' + '- **Portable:** `ControlFS-Portable-x64.exe` — a single executable; keeps its data in the `ControlFS_Data` folder next to it; updates are manual.' '- **Integrity:** `SHA256SUMS.txt`; `release-manifest.json` + `.sig` are the signed update manifest the app verifies.' '- Windows 11 x64. Not code-signed yet: Windows SmartScreen may warn (see the code signing policy).' "" @@ -94,22 +94,18 @@ jobs: shell: pwsh run: .\build\Publish-ControlFS.ps1 -Runtime x64 -Target All -Version "${{ steps.version.outputs.version }}" - # Instala e desinstala em silêncio numa pasta temporária do runner: prova que o instalador funciona - # sem administrador, grava o marcador de atualização e remove os arquivos ao desinstalar. - - name: Smoke-test installer (silent install and uninstall) + # Abre o app de verdade antes de publicar: o .exe portátil e a versão instalada (instalar, abrir, desinstalar). + - name: Smoke-test portable exe (launch) shell: pwsh run: | - $dir = Join-Path $env:RUNNER_TEMP "controlfs-install" - $p = Start-Process -Wait -PassThru -FilePath dist\ControlFS-Setup-x64.exe -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-',"/DIR=$dir" - if ($p.ExitCode -ne 0) { throw "Instalação falhou (código $($p.ExitCode))" } - foreach ($f in "ControlFS.exe", "ControlFS.installed", "SDL3.dll", "SharpCompress.dll", "unins000.exe") { - if (-not (Test-Path -LiteralPath (Join-Path $dir $f))) { throw "Instalação incompleta: falta $f" } - } - $u = Start-Process -Wait -PassThru -FilePath (Join-Path $dir "unins000.exe") -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' - if ($u.ExitCode -ne 0) { throw "Desinstalação falhou (código $($u.ExitCode))" } - Start-Sleep -Seconds 3 - if (Test-Path -LiteralPath (Join-Path $dir "ControlFS.exe")) { throw "Desinstalação deixou o executável" } - Write-Host "Instalação e desinstalação silenciosas OK" + $dir = Join-Path $env:RUNNER_TEMP "portable" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Copy-Item dist\ControlFS-Portable-x64.exe (Join-Path $dir "ControlFS.exe") + .\build\Test-Startup.ps1 -Exe (Join-Path $dir "ControlFS.exe") -OutDir smoke-portable -Seconds 40 + + - name: Smoke-test installer (install, launch, uninstall) + shell: pwsh + run: .\build\Test-Installed.ps1 -Setup dist\ControlFS-Setup-x64.exe -OutDir smoke-installed # A chave privada existe só no secret UPDATE_SIGNING_KEY; o script confere a assinatura contra a chave # pública embutida no app e falha se não bater. Sem manifesto assinado, o app não aceita a atualização. @@ -125,7 +121,7 @@ jobs: name: ControlFS-${{ steps.version.outputs.version }} path: | dist/ControlFS-Setup-x64.exe - dist/ControlFS-Portable-x64.zip + dist/ControlFS-Portable-x64.exe dist/SHA256SUMS.txt dist/release-manifest.json dist/release-manifest.json.sig @@ -143,7 +139,7 @@ jobs: body_path: release-notes.md files: | dist/ControlFS-Setup-x64.exe - dist/ControlFS-Portable-x64.zip + dist/ControlFS-Portable-x64.exe dist/SHA256SUMS.txt dist/release-manifest.json dist/release-manifest.json.sig diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index cb5b5523..59bde666 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -41,9 +41,9 @@ jobs: path: smoke-released source: - name: Portable from source + name: Launch portable exe and installed app runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 40 env: DOTNET_CLI_TELEMETRY_OPTOUT: "1" DOTNET_NOLOGO: "1" @@ -52,16 +52,28 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - - name: Build portable + - name: Install Inno Setup (if missing) shell: pwsh - run: .\build\Publish-ControlFS.ps1 -Runtime x64 -Target Portable -Version 0.0.0-smoke - - name: Launch and collect evidence + run: | + if (-not (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe")) { choco install innosetup -y --no-progress } + - name: Build portable exe and installer + shell: pwsh + run: .\build\Publish-ControlFS.ps1 -Runtime x64 -Target All -Version 0.0.0-smoke + - name: Launch portable exe shell: pwsh run: | - Expand-Archive dist\ControlFS-Portable-x64.zip -DestinationPath (Join-Path $env:RUNNER_TEMP portable) - .\build\Test-Startup.ps1 -Exe (Join-Path $env:RUNNER_TEMP "portable\ControlFS.exe") -OutDir smoke-source + $dir = Join-Path $env:RUNNER_TEMP "portable" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Copy-Item dist\ControlFS-Portable-x64.exe (Join-Path $dir "ControlFS.exe") + .\build\Test-Startup.ps1 -Exe (Join-Path $dir "ControlFS.exe") -OutDir smoke-portable -Seconds 40 + - name: Install, launch and uninstall + if: always() + shell: pwsh + run: .\build\Test-Installed.ps1 -Setup dist\ControlFS-Setup-x64.exe -OutDir smoke-installed - uses: actions/upload-artifact@v4 if: always() with: - name: smoke-source - path: smoke-source + name: smoke-evidence + path: | + smoke-portable + smoke-installed diff --git a/.gitignore b/.gitignore index 67e0a30d..c2a4b6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ release-notes.md Thumbs.db Desktop.ini *.suo + +# Dados da versão portátil (preferências e logs ao lado do .exe) +ControlFS_Data/ diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index e15eb197..0e83039e 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,19 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +## [0.1.0-alpha.3] +### Fixes +- The app closed right after opening (installed and portable) in 0.1.0-alpha.1 and 0.1.0-alpha.2: the app resource file (`ControlFS.pri`) was missing and WinUI couldn't find its styles. Fixed. + +### What's new +- Single-file portable version, `ControlFS-Portable-x64.exe`: keeps settings and logs in the `ControlFS_Data` folder next to it (if that folder isn't writable, it uses `%LOCALAPPDATA%\ControlFS` and tells you). +- Local startup and crash log in `logs` inside the data folder, to diagnose problems (no passwords or file contents). +- Every release is now published only after CI actually opens the portable and the installed app on Windows and confirms the window shows up. + +### Updating +- From 0.1.0-alpha.1 or 0.1.0-alpha.2: those versions didn't open, so they can't update themselves. Download and run `ControlFS-Setup-x64.exe` once. + + ## [0.1.0-alpha.2] ### What's new - Windows installer (`ControlFS-Setup-x64.exe`): per-user install, no admin, Start menu shortcut and an optional desktop shortcut. diff --git a/CHANGELOG.md b/CHANGELOG.md index b1eeebaf..9ad58a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +## [0.1.0-alpha.3] +### Correções +- O app fechava logo ao abrir (instalado e portátil) nas versões 0.1.0-alpha.1 e 0.1.0-alpha.2: faltava o arquivo de recursos do app (`ControlFS.pri`) e o WinUI não encontrava seus estilos. Corrigido. + +### Novidades +- Versão portátil em um único arquivo, `ControlFS-Portable-x64.exe`: guarda preferências e logs na pasta `ControlFS_Data` ao lado dele (se a pasta não aceitar gravação, usa `%LOCALAPPDATA%\ControlFS` e avisa). +- Log local de inicialização e falhas em `logs` dentro da pasta de dados, para diagnosticar problemas (sem senhas nem conteúdo de arquivos). +- Cada release agora só é publicada depois que a CI abre de verdade o portátil e a versão instalada num Windows e confirma que a janela aparece. + +### Atualizando +- Da 0.1.0-alpha.1 ou 0.1.0-alpha.2: essas versões não abriam, então não conseguem se atualizar sozinhas. Baixe e rode o `ControlFS-Setup-x64.exe` uma vez. + + ## [0.1.0-alpha.2] ### Novidades - Instalador para Windows (`ControlFS-Setup-x64.exe`): instala por usuário, sem administrador, com atalho no menu Iniciar e opção de atalho na Área de Trabalho. diff --git a/PROGRESS.md b/PROGRESS.md index 3a74d19b..e54fc62f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -30,6 +30,18 @@ não foi aberto numa sessão Windows interativa**. Nenhum controle físico foi t - **Configuração:** JSON versionado com gravação atômica e recuperação de arquivo corrompido. - **Documentação e CI:** todos os documentos exigidos; ADRs 0001–0004; workflow de CI (não executado). +## Correção de inicialização (0.1.0-alpha.3) + +- **As releases 0.1.0-alpha.1 e 0.1.0-alpha.2 não abriam** (instalado e portátil): o processo terminava em ~1 s com + `0xC000027B` em `Microsoft.UI.Xaml.dll`. Reproduzido pelo novo workflow `smoke.yml` num Windows do GitHub; o log + novo mostrou `Cannot locate resource from 'ms-appx:///Microsoft.UI.Xaml/Themes/themeresources.xaml'`. Causa: app sem + pacote MSIX precisa de `EnableMsixTooling=true` para gerar `ControlFS.pri`. Os testes anteriores cobriam a lógica, + mas nunca abriam o app — lacuna fechada: CI e release agora abrem o portátil e o instalado antes de publicar. +- Evidência pós-correção (runner `windows-latest`, Windows Server 2025 26100): portátil `.exe` único e app instalado + abrem, mantêm a janela "ControlFS" (40 s e 25 s), log até "Janela ativada", SDL 3.5.0 carregado; print mostra a + interface desenhada e o aviso de nova versão vindo do GitHub real. Instalar → abrir → desinstalar: OK. +- Portátil agora é `ControlFS-Portable-x64.exe` (arquivo único) com dados em `ControlFS_Data` ao lado. + ## Instalador e atualizações (0.1.0-alpha.2) - Instalador Inno Setup por usuário, sem administrador (`build/ControlFS.iss`), com marcador que habilita atualização. diff --git a/README.md b/README.md index cd2dd61a..04825950 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ A **file manager for Windows made for the controller**, with a **built-in extrac ## Download -Get **[0.1.0-alpha.2](https://github.com/nextestudios/ControlFS/releases/tag/v0.1.0-alpha.2)** (pre-release): +Get **[0.1.0-alpha.3](https://github.com/nextestudios/ControlFS/releases/tag/v0.1.0-alpha.3)** (pre-release): - **`ControlFS-Setup-x64.exe`** (recommended): per-user install, no admin, **updates itself automatically** (verified, signed updates). -- **`ControlFS-Portable-x64.zip`**: unzip anywhere and run `ControlFS.exe`; it tells you about new versions, replacing it is manual. +- **`ControlFS-Portable-x64.exe`**: a single executable that keeps its data in the `ControlFS_Data` folder next to it; it tells you about new versions, replacing it is manual. -Windows 11 x64. Not code-signed yet, so SmartScreen may warn ([policy](docs/CODE_SIGNING.md)). On 0.1.0-alpha.1? Run the installer once; updates are automatic from then on. +Windows 11 x64. Not code-signed yet, so SmartScreen may warn ([policy](docs/CODE_SIGNING.md)). On 0.1.0-alpha.1 or alpha.2? Those didn't open: run the installer once; updates are automatic from then on. ## How it works diff --git a/README.pt-BR.md b/README.pt-BR.md index 0e9669b8..9bc8090d 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -13,12 +13,12 @@ Um **gerenciador de arquivos para Windows feito para o controle**, com **extrato ## Download -Baixe a **[0.1.0-alpha.2](https://github.com/nextestudios/ControlFS/releases/tag/v0.1.0-alpha.2)** (pré-lançamento): +Baixe a **[0.1.0-alpha.3](https://github.com/nextestudios/ControlFS/releases/tag/v0.1.0-alpha.3)** (pré-lançamento): - **`ControlFS-Setup-x64.exe`** (recomendado): instala por usuário, sem admin, e **se atualiza sozinho** (atualizações assinadas e verificadas). -- **`ControlFS-Portable-x64.zip`**: descompacte em qualquer pasta e rode o `ControlFS.exe`; ele avisa de novas versões, a troca é manual. +- **`ControlFS-Portable-x64.exe`**: um único executável que guarda os dados na pasta `ControlFS_Data` ao lado dele; avisa de novas versões, a troca é manual. -Windows 11 x64. Ainda sem assinatura de código, então o SmartScreen pode avisar ([política](docs/CODE_SIGNING.md)). Está na 0.1.0-alpha.1? Rode o instalador uma vez; daí em diante as atualizações são automáticas. +Windows 11 x64. Ainda sem assinatura de código, então o SmartScreen pode avisar ([política](docs/CODE_SIGNING.md)). Está na 0.1.0-alpha.1 ou alpha.2? Elas não abriam: rode o instalador uma vez; daí em diante as atualizações são automáticas. ## Como funciona diff --git a/build/New-ReleaseManifest.ps1 b/build/New-ReleaseManifest.ps1 index 05b91f3b..1803ba05 100644 --- a/build/New-ReleaseManifest.ps1 +++ b/build/New-ReleaseManifest.ps1 @@ -27,7 +27,7 @@ $manifest = [ordered]@{ version = $Version releasedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") installer = Get-Asset "ControlFS-Setup-x64.exe" - portable = Get-Asset "ControlFS-Portable-x64.zip" + portable = Get-Asset "ControlFS-Portable-x64.exe" } $json = $manifest | ConvertTo-Json -Compress -Depth 4 $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json) diff --git a/build/Publish-ControlFS.ps1 b/build/Publish-ControlFS.ps1 index fe6eeb95..d412a959 100644 --- a/build/Publish-ControlFS.ps1 +++ b/build/Publish-ControlFS.ps1 @@ -1,10 +1,11 @@ <# .SYNOPSIS - Publica o ControlFS (win-x64, self-contained) e gera em dist/: - ControlFS-Setup-x64.exe (instalador por usuário), ControlFS-Portable-x64.zip e SHA256SUMS.txt. + Publica o ControlFS (WinUI 3, sem pacote MSIX, self-contained) e gera em dist/: + ControlFS-Portable-x64.exe um único .exe portátil (dados em ControlFS_Data ao lado dele) + ControlFS-Setup-x64.exe instalador por usuário (Inno Setup; dados em %LOCALAPPDATA%\ControlFS) + SHA256SUMS.txt .EXAMPLE - .\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.2 - .\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.2 -Target Portable + .\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.3 #> param( [ValidateSet("x64")] [string] $Runtime = "x64", @@ -15,39 +16,53 @@ $ErrorActionPreference = "Stop" if ($Version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.\-]+)?$') { throw "Versão inválida: $Version" } $root = Split-Path -Parent $PSScriptRoot -$out = Join-Path $root "artifacts/publish-$Runtime" +$project = Join-Path $root "src/ControlFS.App/ControlFS.App.csproj" +$singleDir = Join-Path $root "artifacts/publish-$Runtime-single" +$appDir = Join-Path $root "artifacts/publish-$Runtime-app" $dist = Join-Path $root "dist" -Remove-Item -LiteralPath $out, $dist -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $singleDir, $appDir, $dist -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $dist | Out-Null - -# O RID vem do próprio ControlFS.App.csproj: passar -r aqui propagaria o RID às bibliotecas e quebraria o restore travado. -# Versão numérica para o assembly (sem sufixo de pré-release). $numeric = ($Version -split '-')[0] -dotnet publish (Join-Path $root "src/ControlFS.App/ControlFS.App.csproj") -c Release --self-contained true ` - -p:Platform=$Runtime -p:Version=$Version -p:AssemblyVersion="$numeric.0" -p:FileVersion="$numeric.0" ` - -p:DebugType=none -p:DebugSymbols=false -o $out --nologo -if ($LASTEXITCODE -ne 0) { throw "dotnet publish falhou" } -foreach ($required in "ControlFS.exe", "SDL3.dll", "SharpCompress.dll") { - if (-not (Test-Path -LiteralPath (Join-Path $out $required))) { throw "Pacote incompleto: falta $required" } +function Invoke-Publish([string]$OutDir, [bool]$SingleFile) { + # O RID vem do próprio ControlFS.App.csproj: passar -r aqui propagaria o RID às bibliotecas e quebraria o restore travado. + # PublishSingleFile acrescenta implicitamente Microsoft.NET.ILLink.Tasks (do próprio SDK), que os lock files não + # listam. Só nesta publicação (feita por último) o restore não é travado; as versões continuam fixadas em + # Directory.Packages.props, o instalador é publicado antes em modo travado e o build da CI segue travado. + $pubArgs = [System.Collections.Generic.List[string]]@( + "publish", $project, "-c", "Release", "--self-contained", "true", + "-p:Platform=$Runtime", "-p:PublishSingleFile=$($SingleFile.ToString().ToLowerInvariant())", + "-p:Version=$Version", "-p:AssemblyVersion=$numeric.0", "-p:FileVersion=$numeric.0", + "-o", $OutDir, "--nologo") + if ($SingleFile) { $pubArgs.Add("-p:RestoreLockedMode=false") } + & dotnet @pubArgs + if ($LASTEXITCODE -ne 0) { throw "dotnet publish falhou ($OutDir)" } + if (-not (Test-Path -LiteralPath (Join-Path $OutDir "ControlFS.exe"))) { throw "Publish não gerou ControlFS.exe em $OutDir" } } -Copy-Item -LiteralPath (Join-Path $root "LICENSE"), (Join-Path $root "THIRD_PARTY_NOTICES.md") -Destination $out $files = @() -if ($Target -in "All", "Portable") { - $zip = Join-Path $dist "ControlFS-Portable-$Runtime.zip" - Compress-Archive -Path (Join-Path $out "*") -DestinationPath $zip - $files += $zip -} if ($Target -in "All", "Installer") { + Write-Host "Publicando versão para o instalador (pasta)..." + Invoke-Publish $appDir $false + foreach ($required in "ControlFS.exe", "ControlFS.pri", "SDL3.dll", "SharpCompress.dll") { + if (-not (Test-Path -LiteralPath (Join-Path $appDir $required))) { throw "Pacote incompleto: falta $required" } + } + Copy-Item -LiteralPath (Join-Path $root "LICENSE"), (Join-Path $root "THIRD_PARTY_NOTICES.md") -Destination $appDir $iscc = @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe", "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe") | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $iscc) { throw "Inno Setup 6 (ISCC.exe) não encontrado." } - & $iscc /Q "/DAppVersion=$Version" "/DNumericVersion=$numeric.0" "/DSourceDir=$out" "/DOutputDir=$dist" (Join-Path $PSScriptRoot "ControlFS.iss") + & $iscc /Q "/DAppVersion=$Version" "/DNumericVersion=$numeric.0" "/DSourceDir=$appDir" "/DOutputDir=$dist" (Join-Path $PSScriptRoot "ControlFS.iss") if ($LASTEXITCODE -ne 0) { throw "ISCC falhou" } $files += Join-Path $dist "ControlFS-Setup-$Runtime.exe" } +if ($Target -in "All", "Portable") { + Write-Host "Publicando versão portátil (um único .exe)..." + Invoke-Publish $singleDir $true + $portable = Join-Path $dist "ControlFS-Portable-$Runtime.exe" + Copy-Item -LiteralPath (Join-Path $singleDir "ControlFS.exe") -Destination $portable + $files += $portable +} # LF explícito: "sha256sum -c" / "shasum -c" falham com CRLF no nome do arquivo. $lines = foreach ($f in $files) { "$((Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash.ToLowerInvariant()) $(Split-Path -Leaf $f)" } [IO.File]::WriteAllText((Join-Path $dist "SHA256SUMS.txt"), (($lines -join "`n") + "`n"), [Text.Encoding]::ASCII) diff --git a/build/Test-Installed.ps1 b/build/Test-Installed.ps1 new file mode 100644 index 00000000..a3080dc6 --- /dev/null +++ b/build/Test-Installed.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Instala o ControlFS em silêncio numa pasta temporária, confere os arquivos, abre o app instalado + (Test-Startup.ps1) e desinstala. Falha se qualquer etapa falhar. Usado pela CI. +#> +param( + [Parameter(Mandatory = $true)] [string] $Setup, + [Parameter(Mandatory = $true)] [string] $OutDir +) +$ErrorActionPreference = "Stop" +$dir = Join-Path $env:RUNNER_TEMP "controlfs-install" +$p = Start-Process -Wait -PassThru -FilePath $Setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', "/DIR=$dir" +if ($p.ExitCode -ne 0) { throw "Instalação falhou (código $($p.ExitCode))" } +foreach ($f in "ControlFS.exe", "ControlFS.pri", "ControlFS.installed", "SDL3.dll", "SharpCompress.dll", "unins000.exe") { + if (-not (Test-Path -LiteralPath (Join-Path $dir $f))) { throw "Instalação incompleta: falta $f" } +} +& (Join-Path $PSScriptRoot "Test-Startup.ps1") -Exe (Join-Path $dir "ControlFS.exe") -OutDir $OutDir +$startupOk = $LASTEXITCODE -eq 0 +$u = Start-Process -Wait -PassThru -FilePath (Join-Path $dir "unins000.exe") -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' +if ($u.ExitCode -ne 0) { throw "Desinstalação falhou (código $($u.ExitCode))" } +Start-Sleep -Seconds 3 +if (Test-Path -LiteralPath (Join-Path $dir "ControlFS.exe")) { throw "Desinstalação deixou o executável" } +if (-not $startupOk) { throw "O app instalado não abriu" } +Write-Host "Instalar, abrir e desinstalar: OK" diff --git a/build/Test-Startup.ps1 b/build/Test-Startup.ps1 index 0d6a5f26..011cf7e2 100644 --- a/build/Test-Startup.ps1 +++ b/build/Test-Startup.ps1 @@ -11,6 +11,7 @@ param( $ErrorActionPreference = "Continue" New-Item -ItemType Directory -Force -Path $OutDir | Out-Null $started = Get-Date +Remove-Item (Join-Path $env:LOCALAPPDATA "ControlFS\logs"), (Join-Path (Split-Path -Parent $Exe) "ControlFS_Data\logs") -Recurse -Force -ErrorAction SilentlyContinue $p = Start-Process -FilePath $Exe -WorkingDirectory (Split-Path -Parent $Exe) -PassThru Start-Sleep -Seconds $Seconds $p.Refresh() @@ -38,13 +39,16 @@ $events = Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = Where-Object { $_.ProviderName -in '.NET Runtime', 'Application Error', 'Windows Error Reporting', 'SideBySide', 'Application Hang' } $events | Format-List TimeCreated, ProviderName, Id, Message | Out-String -Width 400 | Set-Content (Join-Path $OutDir "events.txt") -$appData = Join-Path $env:LOCALAPPDATA "ControlFS" -if (Test-Path $appData) { - Get-ChildItem $appData -Recurse | Select-Object FullName, Length | Out-String -Width 400 | Set-Content (Join-Path $OutDir "appdata.txt") - Get-ChildItem (Join-Path $appData "logs") -Filter *.log -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName $OutDir } +foreach ($data in (Join-Path $env:LOCALAPPDATA "ControlFS"), (Join-Path (Split-Path -Parent $Exe) "ControlFS_Data")) { + if (-not (Test-Path $data)) { continue } + Get-ChildItem $data -Recurse | Select-Object FullName, Length | Out-String -Width 400 | Add-Content (Join-Path $OutDir "appdata.txt") + Get-ChildItem (Join-Path $data "logs") -Filter *.log -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName $OutDir -Force } } if ($alive) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } +Get-ChildItem (Split-Path -Parent $Exe) -File | Where-Object { $_.Extension -in '.pri', '.json' -or $_.Name -like 'Microsoft.UI.Xaml*' -or $_.Name -like 'Microsoft.WindowsAppRuntime*' } | + Select-Object Name, Length | Out-String -Width 200 | Set-Content (Join-Path $OutDir "package-files.txt") +Write-Host "---- arquivos relevantes do pacote ----"; Get-Content (Join-Path $OutDir "package-files.txt") $report | ConvertTo-Json | Set-Content (Join-Path $OutDir "report.json") Write-Host ($report | ConvertTo-Json) Write-Host "---- eventos ----" @@ -52,3 +56,4 @@ Get-Content (Join-Path $OutDir "events.txt") | Select-Object -First 80 Get-ChildItem $OutDir -Filter *.log | ForEach-Object { Write-Host "---- $($_.Name) ----"; Get-Content $_.FullName | Select-Object -First 120 } if (-not $alive -or $report.mainWindowHandle -eq 0) { Write-Host "::error::ControlFS não ficou aberto com janela"; exit 1 } Write-Host "ControlFS abriu e manteve a janela por $Seconds s" +exit 0 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0c37a7a0..6566e047 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -48,10 +48,11 @@ The **portable** version only tells you a new version exists; download it from t ## Privacy -Everything stays on your PC. Settings live in `%LOCALAPPDATA%\ControlFS\settings.json`. Passwords are never saved or logged. Core features never use the network. The only network access is the update check, which sends nothing but a `ControlFS/` User-Agent to GitHub and can be turned off. +Everything stays on your PC. Settings and logs live in `%LOCALAPPDATA%\ControlFS` (installed) or in `ControlFS_Data` next to `ControlFS-Portable-x64.exe` (portable). Passwords are never saved or logged. Core features never use the network. The only network access is the update check, which sends nothing but a `ControlFS/` User-Agent to GitHub and can be turned off. ## Troubleshooting +- **The app doesn't open:** send us `logs\startup.log` and `logs\crash.log` from the data folder above (they contain no passwords or file contents). - **No controller detected:** plug it in and press a button; the header shows the active pad. Keyboard and mouse always work. - **Windows SmartScreen warning:** the build isn't code-signed yet ([policy](CODE_SIGNING.md)). - **"Format recognized but not supported":** only ZIP is supported for now. @@ -66,5 +67,5 @@ dotnet build ControlFS.slnx dotnet test ControlFS.slnx ``` -The app runs only on Windows 11 x64. Installer + portable (needs Inno Setup 6): `.\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.2`. +The app runs only on Windows 11 x64. Installer + portable (needs Inno Setup 6): `.\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.3`. More in [build-and-release.md](build-and-release.md) (Portuguese). diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 6ab900d7..77b9a563 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -48,10 +48,11 @@ A versão **portátil** só avisa que existe versão nova; baixe-a na página da ## Privacidade -Tudo fica no seu PC. As preferências ficam em `%LOCALAPPDATA%\ControlFS\settings.json`. Senhas nunca são gravadas nem registradas. As funções centrais nunca usam a rede. O único acesso à rede é a verificação de atualizações, que envia ao GitHub apenas o User-Agent `ControlFS/` e pode ser desligada. +Tudo fica no seu PC. Preferências e logs ficam em `%LOCALAPPDATA%\ControlFS` (instalado) ou em `ControlFS_Data` ao lado do `ControlFS-Portable-x64.exe` (portátil). Senhas nunca são gravadas nem registradas. As funções centrais nunca usam a rede. O único acesso à rede é a verificação de atualizações, que envia ao GitHub apenas o User-Agent `ControlFS/` e pode ser desligada. ## Solução de problemas +- **O app não abre:** envie `logs\startup.log` e `logs\crash.log` da pasta de dados acima (não contêm senhas nem conteúdo de arquivos). - **Controle não detectado:** conecte e aperte um botão; o cabeçalho mostra o controle ativo. Teclado e mouse sempre funcionam. - **Aviso do SmartScreen:** o build ainda não tem assinatura de código ([política](CODE_SIGNING.md)). - **"Formato reconhecido, mas não suportado":** por enquanto só ZIP. @@ -66,5 +67,5 @@ dotnet build ControlFS.slnx dotnet test ControlFS.slnx ``` -O app roda só no Windows 11 x64. Instalador + portátil (requer Inno Setup 6): `.\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.2`. +O app roda só no Windows 11 x64. Instalador + portátil (requer Inno Setup 6): `.\build\Publish-ControlFS.ps1 -Version 0.1.0-alpha.3`. Mais em [build-and-release.md](build-and-release.md). diff --git a/docs/TESTING.md b/docs/TESTING.md index b5c13bc8..8fd7ee5a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -5,7 +5,7 @@ versão, Windows, controle e conexão. Resultados de controles vão para `contro ## Antes de cada release -- [ ] Baixar `ControlFS-Portable-x64.zip` da release, conferir o SHA-256 com `SHA256SUMS.txt`, descompactar e abrir `ControlFS.exe` numa conta padrão. +- [ ] Baixar `ControlFS-Portable-x64.exe` da release, conferir o SHA-256 com `SHA256SUMS.txt` e abrir numa conta padrão; a pasta `ControlFS_Data` aparece ao lado. - [ ] Tela inicial mostra Downloads/Documentos reais e as unidades. - [ ] Teclado físico: setas, Enter, Esc, Espaço, F2, F10, F11 (tela cheia). - [ ] Conectar um controle **depois** de abrir o app: o cabeçalho mostra o controle ao apertar um botão. diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 7320df02..d8209f27 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -40,8 +40,10 @@ artifacts\ControlFS-win-x64\ControlFS.exe - `release.yml`: push de tag `vX.Y.Z` ou `vX.Y.Z-pre.N` roda os testes, gera `ControlFS-Portable-x64.zip` + `SHA256SUMS.txt` com `build/Publish-ControlFS.ps1` e publica a release (pré-lançamento quando há sufixo). As notas vêm de `CHANGELOG.en-US.md`, com link para `CHANGELOG.md`; o workflow falha se faltar a seção em algum dos dois. -- Release inclui também `ControlFS-Setup-x64.exe` (Inno Setup, por usuário), testado por instalação e desinstalação - silenciosas no runner, e `release-manifest.json` + `.sig` assinados com o secret `UPDATE_SIGNING_KEY` +- Release inclui `ControlFS-Setup-x64.exe` (Inno Setup, por usuário) e `ControlFS-Portable-x64.exe` (arquivo único). + Antes de publicar, a CI **abre de verdade** o portátil e o app instalado (`build/Test-Startup.ps1`, + `build/Test-Installed.ps1`: processo vivo, janela, eventos do Windows, logs e print). O workflow `smoke.yml` faz o + mesmo em PRs, na `main` e sob demanda para uma release já publicada. Inclui também e `release-manifest.json` + `.sig` assinados com o secret `UPDATE_SIGNING_KEY` (`build/New-ReleaseManifest.ps1` confere a assinatura com a chave pública do app antes de publicar). - `codeql.yml` (C# e workflows) e `dependabot.yml` (NuGet e Actions, mensal, agrupado). diff --git a/src/ControlFS.App/App.cs b/src/ControlFS.App/App.cs index a8df7b47..7f512806 100644 --- a/src/ControlFS.App/App.cs +++ b/src/ControlFS.App/App.cs @@ -15,11 +15,29 @@ public sealed class App : Microsoft.UI.Xaml.Application, IXamlMetadataProvider private readonly XamlControlsXamlMetaDataProvider _provider = new(); private MainWindow? _window; + public App() + { + UnhandledException += (_, e) => AppLog.Crash(e.Exception, "Application.UnhandledException: " + e.Message); + AppLog.Info("App criado"); + } + protected override void OnLaunched(LaunchActivatedEventArgs args) { - Resources.MergedDictionaries.Add(new XamlControlsResources()); - _window = new MainWindow(); - _window.Activate(); + try + { + AppLog.Info("OnLaunched: carregando XamlControlsResources"); + Resources.MergedDictionaries.Add(new XamlControlsResources()); + AppLog.Info("OnLaunched: criando janela"); + _window = new MainWindow(); + AppLog.Info("OnLaunched: ativando janela"); + _window.Activate(); + AppLog.Info("Janela ativada"); + } + catch (Exception ex) + { + AppLog.Crash(ex, "App.OnLaunched"); + throw; + } } public IXamlType GetXamlType(Type type) => _provider.GetXamlType(type); diff --git a/src/ControlFS.App/AppLog.cs b/src/ControlFS.App/AppLog.cs new file mode 100644 index 00000000..8a2a3aed --- /dev/null +++ b/src/ControlFS.App/AppLog.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Reflection; + +namespace ControlFS.App; + +/// +/// Log local mínimo de inicialização e falhas, em <pasta de dados>\logs (rotação simples por tamanho). +/// Nunca contém senhas nem conteúdo de arquivos; fica só no computador do usuário. +/// +internal static class AppLog +{ + private const long MaxBytes = 512 * 1024; + private static readonly object Gate = new(); + + public static string Directory => Path.Join(AppPaths.DataDirectory, "logs"); + + public static void Info(string message) => Write("startup.log", "INFO", message); + + public static void Crash(Exception exception, string context) + { + var detail = $"{context}\n{exception.GetType().FullName} (HRESULT 0x{exception.HResult:X8}): {exception.Message}\n{exception}"; + Write("crash.log", "FATAL", detail); + Write("startup.log", "FATAL", $"{context}: {exception.GetType().Name}: {exception.Message}"); + } + + public static void Session() + { + var version = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "?"; + Info($"ControlFS {version} · {(AppPaths.IsInstalled ? "instalado" : "portátil")} · {Environment.OSVersion.VersionString} · {(Environment.Is64BitProcess ? "x64" : "x86")} · .NET {Environment.Version}"); + } + + private static void Write(string file, string level, string message) + { + try + { + lock (Gate) + { + System.IO.Directory.CreateDirectory(Directory); + var path = Path.Join(Directory, file); + if (File.Exists(path) && new FileInfo(path).Length > MaxBytes) File.Move(path, path + ".old", overwrite: true); + File.AppendAllText(path, $"{DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)} [{level}] {message}{Environment.NewLine}"); + } + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/src/ControlFS.App/AppPaths.cs b/src/ControlFS.App/AppPaths.cs new file mode 100644 index 00000000..717d0bf3 --- /dev/null +++ b/src/ControlFS.App/AppPaths.cs @@ -0,0 +1,55 @@ +namespace ControlFS.App; + +/// +/// Onde o app guarda dados. Instalado (marcador ControlFS.installed ao lado do .exe): %LOCALAPPDATA%\ControlFS. +/// Portátil: pasta ControlFS_Data ao lado do .exe; se ela não aceitar escrita, usa %LOCALAPPDATA%\ControlFS e avisa. +/// Usa o caminho do processo (não AppContext.BaseDirectory), que no .exe único aponta para a pasta de extração. +/// +internal static class AppPaths +{ + public const string InstalledMarker = "ControlFS.installed"; + public const string PortableDataFolder = "ControlFS_Data"; + + static AppPaths() + { + ExeDirectory = Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory; + IsInstalled = File.Exists(Path.Join(ExeDirectory, InstalledMarker)); + var local = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ControlFS"); + if (IsInstalled) + { + DataDirectory = local; + return; + } + var portable = Path.Join(ExeDirectory, PortableDataFolder); + if (IsWritable(portable)) + { + DataDirectory = portable; + } + else + { + DataDirectory = local; + Notice = $"A pasta do ControlFS não permite gravação; preferências e logs ficam em {local}."; + } + } + + public static string ExeDirectory { get; } + public static bool IsInstalled { get; } + public static string DataDirectory { get; } + public static string? Notice { get; } + + private static bool IsWritable(string directory) + { + try + { + Directory.CreateDirectory(directory); + var probe = Path.Join(directory, ".write-test-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(probe, string.Empty); + File.Delete(probe); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/src/ControlFS.App/ControlFS.App.csproj b/src/ControlFS.App/ControlFS.App.csproj index 314d5e7e..a70c6512 100644 --- a/src/ControlFS.App/ControlFS.App.csproj +++ b/src/ControlFS.App/ControlFS.App.csproj @@ -14,9 +14,21 @@ x64 win-x64 None + + true $(DefineConstants);DISABLE_XAML_GENERATED_MAIN app.manifest + + + true + true + true + embedded + false + false + true diff --git a/src/ControlFS.App/Program.cs b/src/ControlFS.App/Program.cs index 633b450f..7a1bc206 100644 --- a/src/ControlFS.App/Program.cs +++ b/src/ControlFS.App/Program.cs @@ -8,12 +8,26 @@ public static class Program [STAThread] public static void Main() { - WinRT.ComWrappersSupport.InitializeComWrappers(); - Microsoft.UI.Xaml.Application.Start(_callbackParams => + AppLog.Session(); + AppDomain.CurrentDomain.UnhandledException += (_, e) => { - // Continuações assíncronas e o AppController rodam na thread de UI. - SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); - _ = new App(); - }); + if (e.ExceptionObject is Exception ex) AppLog.Crash(ex, "AppDomain.UnhandledException"); + }; + try + { + WinRT.ComWrappersSupport.InitializeComWrappers(); + Microsoft.UI.Xaml.Application.Start(_callbackParams => + { + // Continuações assíncronas e o AppController rodam na thread de UI. + SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); + _ = new App(); + }); + AppLog.Info("Encerrado normalmente"); + } + catch (Exception ex) + { + AppLog.Crash(ex, "Program.Main"); + throw; + } } } diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index 8121774e..7433b05a 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -43,13 +43,15 @@ public sealed class MainWindow : Window public MainWindow() { Title = "ControlFS"; - var settingsStore = new JsonSettingsStore(JsonSettingsStore.DefaultDirectory()); - _updates = GitHubReleaseUpdateService.CreateDefault(); + var settingsStore = new JsonSettingsStore(AppPaths.DataDirectory); + _updates = GitHubReleaseUpdateService.CreateDefault(AppPaths.IsInstalled, Path.Join(AppPaths.DataDirectory, "updates")); _app = new AppController(new LocalFileSystemProvider(), new ArchiveService(), settingsStore, _updates); _input = new InputHost(_app, DispatcherQueue); + AppLog.Info("MainWindow: serviços criados; montando layout"); Content = _root; _root.Content = BuildLayout(); + AppLog.Info("MainWindow: layout montado"); _root.PreviewKeyDown += (_, e) => { if (e.Key == Windows.System.VirtualKey.F11) { ToggleFullScreen(); e.Handled = true; return; } @@ -75,6 +77,8 @@ public MainWindow() _app.ExitRequested += Close; _input.StatusChanged += Render; _app.Start(); + if (AppPaths.Notice is { } notice) _app.ShowNotice(notice); + AppLog.Info($"MainWindow: controlador iniciado; entrada: {(_input.BackendReady ? _input.BackendDescription : "SDL indisponível: " + _input.BackendError)}"); } private Grid BuildLayout() diff --git a/src/ControlFS.Application/AppController.cs b/src/ControlFS.Application/AppController.cs index f1002faa..bef1dad7 100644 --- a/src/ControlFS.Application/AppController.cs +++ b/src/ControlFS.Application/AppController.cs @@ -365,6 +365,9 @@ internal void Track(Task task) internal void RaiseChanged() => Changed?.Invoke(); + /// Aviso não modal no rodapé (ex.: pasta de dados alternativa no modo portátil). + public void ShowNotice(string message) => SetStatus(message); + internal void SetStatus(string message) { StatusMessage = message; diff --git a/src/ControlFS.Infrastructure.Updates/GitHubReleaseUpdateService.cs b/src/ControlFS.Infrastructure.Updates/GitHubReleaseUpdateService.cs index a348ee96..580020eb 100644 --- a/src/ControlFS.Infrastructure.Updates/GitHubReleaseUpdateService.cs +++ b/src/ControlFS.Infrastructure.Updates/GitHubReleaseUpdateService.cs @@ -33,14 +33,12 @@ public GitHubReleaseUpdateService(ReleaseVersion currentVersion, bool isInstalle public ReleaseVersion CurrentVersion { get; } public bool IsInstalled { get; } - /// Configuração padrão do app: versão do assembly, marcador do instalador e %LOCALAPPDATA%\ControlFS\updates. - public static GitHubReleaseUpdateService CreateDefault() + /// Configuração do app: versão do assembly; instalado/portátil e pasta de downloads decididos pelo app. + public static GitHubReleaseUpdateService CreateDefault(bool isInstalled, string downloadDirectory) { var informational = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion; var version = ReleaseVersion.TryParse(informational, out var v) ? v : ReleaseVersion.Parse("0.0.0"); - var installed = File.Exists(Path.Join(AppContext.BaseDirectory, InstalledMarkerName)); - var dir = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ControlFS", "updates"); - return new GitHubReleaseUpdateService(version, installed, dir); + return new GitHubReleaseUpdateService(version, isInstalled, downloadDirectory); } public async Task CheckAsync(bool includePrereleases, CancellationToken cancellationToken)