diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578da45..8b6e6cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,11 @@ jobs: - name: Test run: dotnet test Filee.slnx --no-build -c Release --logger "trx" --results-directory TestResults + # Compiles installer/Filee.iss against a stub app without compression: catches script errors in about a minute. + - name: Check installer script + shell: pwsh + run: ./build/build-installer.ps1 -CheckOnly + - name: Upload UI screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9936dd..8843daa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,12 @@ # Builds the Windows installer and publishes it to GitHub Releases. # Trigger: push a tag like v1.0.0 (git tag v1.0.0 && git push origin v1.0.0) # -# Output (Velopack): Filee--win-Setup.exe, Filee--win-Portable.zip, and the update feed -# (releases.win.json + full/delta .nupkg) the next release builds its delta package from. +# Output (build/build-installer.ps1): Filee--win-Setup.exe (Inno Setup, installer/Filee.iss), +# Filee--win-Portable.zip and Filee--SHA256SUMS.txt. # Release notes are generated by GitHub from the pull requests merged since the previous release, grouped by label # (.github/release.yml), with their authors and new contributors. -# Large engines are not in the installer; the app offers them on first run and in Settings > Engines. +# Large engines are not in the installer; its engine page (or the app, on first run and in Settings > Engines) +# downloads them. name: Release on: @@ -18,8 +19,6 @@ permissions: jobs: windows: runs-on: windows-latest - env: - PUBLISH_DIR: publish/win-x64 steps: - uses: actions/checkout@v4 @@ -58,45 +57,11 @@ jobs: - name: Test run: dotnet test Filee.slnx -c Release - - name: Publish - run: > - dotnet publish src/Filee.App/Filee.App.csproj -c Release -r win-x64 --self-contained - -p:Version=${{ steps.version.outputs.value }} - -p:PublishReadyToRun=true - -o ${{ env.PUBLISH_DIR }} - - # Must sit next to Filee.exe: the package's external location is the install folder. - - name: Add Explorer menu extension - shell: pwsh - run: Copy-Item build/.cache/explorer-menu/FileeExplorerMenu.dll, build/.cache/explorer-menu/FileeExplorerMenu.msix "${{ env.PUBLISH_DIR }}" - - # Only the small rhwp is bundled. LibreOffice (+ H2Orestart, Java) and Pandoc are large and are downloaded by - # the app when the user picks them (first-run setup or Settings > Engines), see engines.json. - - name: Bundle conversion engines (rhwp, 7-Zip) - shell: pwsh - run: ./build/fetch-engines.ps1 -Only rhwp,7zip -Destination "${{ env.PUBLISH_DIR }}/engines" - - - name: Install Velopack CLI - run: dotnet tool install -g vpk - - - name: Download previous release (for delta updates) - run: vpk download github --repoUrl ${{ github.server_url }}/${{ github.repository }} --token ${{ secrets.GITHUB_TOKEN }} -o Releases - continue-on-error: true - - - name: Pack - run: > - vpk pack --packId Filee --packVersion ${{ steps.version.outputs.value }} - --packDir ${{ env.PUBLISH_DIR }} --mainExe Filee.exe --packTitle Filee - --icon src/Filee.App/Assets/Icons/filee.ico -o Releases - - # People download these two, so their names carry the version. The update feed keeps Velopack's names. - - name: Name downloads after the version + # Publishes the app (Release, self-contained, ReadyToRun) with the Explorer menu extension and the bundled rhwp + # and 7-Zip, then compiles the installer (portable Inno Setup, pinned and verified) and zips the portable copy. + - name: Build installer and portable zip shell: pwsh - run: | - $version = '${{ steps.version.outputs.value }}' - Get-ChildItem Releases -Filter '*-Setup.exe' | Rename-Item -NewName "Filee-$version-win-Setup.exe" - Get-ChildItem Releases -Filter '*-Portable.zip' | Rename-Item -NewName "Filee-$version-win-Portable.zip" - Get-ChildItem Releases | Format-Table Name, Length + run: ./build/build-installer.ps1 -Version ${{ steps.version.outputs.value }} -Portable - name: Publish the GitHub release shell: pwsh @@ -104,15 +69,5 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $version = '${{ steps.version.outputs.value }}' - # Releases/ also holds the previous version's full package, downloaded only to build the delta: publish this - # version's files, and trim the update feed to them so the release lists only what it carries. - $feedPath = 'Releases/releases.win.json' - $feed = Get-Content $feedPath -Raw | ConvertFrom-Json - $feed.Assets = @($feed.Assets | Where-Object Version -eq $version) - $feed | ConvertTo-Json -Depth 5 -Compress | Set-Content $feedPath -Encoding utf8 -NoNewline - $pattern = "-$([regex]::Escape($version))-" - (Get-Content Releases/RELEASES) | Where-Object { $_ -match $pattern } | Set-Content Releases/RELEASES -Encoding utf8BOM - $files = Get-ChildItem Releases -File | - Where-Object { $_.Name -in 'releases.win.json', 'RELEASES' -or $_.Name -like "Filee-$version-*" } | - ForEach-Object FullName + $files = Get-ChildItem Releases -File -Filter "Filee-$version-*" | ForEach-Object FullName gh release create '${{ github.ref_name }}' --title "Filee $version" --generate-notes --verify-tag @files diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1cece4..615952d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,5 +53,13 @@ Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) first. In short: ## Releasing (maintainers) -Push a tag `vX.Y.Z`. `.github/workflows/release.yml` builds, bundles the engines, packs with Velopack and publishes -the installer and update packages to GitHub Releases. +Bump `` in `Directory.Build.props`, merge, then push a tag `vX.Y.Z`. `.github/workflows/release.yml` runs the +tests and `build/build-installer.ps1 -Portable`: it publishes the app (`-p:FileeRelease=true`, which lets it register +Explorer and startup entries), adds the Explorer menu extension and the bundled rhwp and 7-Zip, compiles +`installer/Filee.iss` with a pinned, portable Inno Setup, and uploads `Filee-X.Y.Z-win-Setup.exe`, the portable zip and +their SHA-256 sums with release notes generated from the merged pull requests. + +To try the installer locally run `pwsh build/build-installer.ps1` (output in `Releases/`). Setup needs administrator +rights; `pwsh build/build-installer.ps1 -CheckOnly` only compiles the script. The installer's engine page and some of +its texts are generated from `EngineDownloads` and the app's translations (`build/tools/make-installer-engines.cs`); +its own texts are in the `[CustomMessages]` section of `installer/Filee.iss` (English, Korean, Simplified Chinese). diff --git a/Directory.Build.props b/Directory.Build.props index a5ddf54..69950bf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ parameters of a member is fine (CS1573). --> $(NoWarn);CS1591;CS1573 - 1.1.0 + 1.2.0 Filee Filee contributors Copyright (c) Filee contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index d2833b9..5ca2f58 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,6 @@ - diff --git a/README.ko.md b/README.ko.md index 5bd5379..130111e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -106,15 +106,18 @@ ## 시작하기 **설치:** [Releases](https://github.com/KnifeLemon/Filee/releases/latest)에서 최신 `Filee-<버전>-win-Setup.exe`를 -받으세요(Windows 10/11, 64비트). +받아 실행하세요(Windows 10/11, 64비트). 관리자 권한을 한 번 요청하고, 모든 사용자를 위해 Program Files에 설치해요. +설치 없이 쓰고 싶다면 `Filee-<버전>-win-Portable.zip`을 원하는 곳에 풀고 `Filee.exe`를 실행하면 돼요. - **자주 쓰는 형식은 설치 파일에 다 들어 있어요.** 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD는 설치하자마자 변환돼요. -- **큰 엔진은 골라서 설치해요.** 처음 실행하면 동영상·오디오용 엔진(FFmpeg, 약 100MB)과 드문 형식용 엔진(LibreOffice, - calibre, Ghostscript, Pandoc)을 크기와 함께 보여 줘요. 설정 → 변환 엔진에서 다운로드 속도와 남은 시간을 보며 언제든 - 설치하거나 지울 수 있어요. +- **설치하면서 골라요.** 탐색기 우클릭 메뉴의 “Filee로 변환”(Windows 11에서는 “추가 옵션 표시” 없이 기본 메뉴에도), + 로그인할 때 시작, 그리고 추가 엔진을 크기를 보며 고를 수 있어요: 동영상·오디오(FFmpeg, 약 100MB)와 드문 + 형식(LibreOffice, calibre, Ghostscript, Pandoc). 고른 엔진은 설치가 끝나고 Filee가 시작되면서 내려받고, 설정 → 변환 + 엔진에서 다운로드 속도와 남은 시간을 보며 언제든 설치하거나 지울 수 있어요. - **업데이트는 알려 드려요.** 새 버전이 나오면 메뉴 하단, 알림, 트레이 메뉴에 표시되고 누르면 최신 릴리스 페이지가 - 열려요. 새 설치 파일을 실행하면 설정은 그대로 유지돼요. + 열려요. 새 설치 파일을 실행하면 Filee를 닫고 업데이트하며, 설정과 엔진은 그대로 유지돼요. 사용자별로 설치된 + Filee 1.1 이하도 같은 방법으로 넘겨받아요. macOS 지원은 계획 중입니다. diff --git a/README.md b/README.md index 1383909..b92869a 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,20 @@ never uses programs installed on your PC. Optional engines Filee offers to downl ## Get started **Install:** download the latest `Filee--win-Setup.exe` from -[Releases](https://github.com/KnifeLemon/Filee/releases/latest) (Windows 10/11, 64-bit). +[Releases](https://github.com/KnifeLemon/Filee/releases/latest) (Windows 10/11, 64-bit) and run it. Setup asks for +administrator rights once and installs Filee for all users in Program Files. Prefer no installer? Unzip +`Filee--win-Portable.zip` anywhere and run `Filee.exe`. - **Everything common is in the installer.** Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and CAD work right away. -- **Large engines are optional.** On first start Filee offers the engines for video and audio (FFmpeg, ~100 MB) and for - rare formats (LibreOffice, calibre, Ghostscript, Pandoc), each with its size. Install or remove them any time in - Settings → Engines, which shows download speed and time left. +- **Choose in Setup.** Add “Convert with Filee” to the File Explorer menu (on Windows 11 also at the top of the menu, + not only under “Show more options”), start Filee when you sign in, and pick optional engines: video and audio + (FFmpeg, ~100 MB) and rare formats (LibreOffice, calibre, Ghostscript, Pandoc), each with its size. Filee downloads + the chosen engines when Setup starts it; install or remove them any time in Settings → Engines, which shows download + speed and time left. - **You hear about updates.** When a new release is out, Filee says so at the bottom of the menu, in a notice and in - the tray menu, and opens the latest release to download it. Run the new installer; your settings are kept. + the tray menu, and opens the latest release to download it. Run the new installer: it closes Filee, updates it and + keeps your settings and engines. Filee 1.1 and earlier (installed per user) are taken over the same way. macOS support is planned. @@ -135,7 +140,9 @@ dotnet run --project src/Filee.App dotnet test ``` -Settings live in `%APPDATA%\Filee`. Debug builds never touch the Explorer context menu or auto-start. +Settings live in `%APPDATA%\Filee`. Builds from source never touch the Explorer context menu or auto-start. +`pwsh build/build-installer.ps1` publishes Filee and builds the installer into `Releases/` (it downloads a pinned, +portable Inno Setup into `build/.cache`; nothing is installed). | Project | What it is | |---|---| diff --git a/README.zh-CN.md b/README.zh-CN.md index ccd605f..fa74144 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,13 +103,16 @@ ## 快速开始 **安装:** 在 [Releases](https://github.com/KnifeLemon/Filee/releases/latest) 下载最新的 -`Filee-<版本>-win-Setup.exe`(Windows 10/11,64 位)。 +`Filee-<版本>-win-Setup.exe`(Windows 10/11,64 位)并运行。安装程序会请求一次管理员权限,为所有用户安装到 +Program Files。不想安装?把 `Filee-<版本>-win-Portable.zip` 解压到任意位置,运行 `Filee.exe` 即可。 - **常用格式都已包含在安装包中。** 图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体和 CAD 装好即用。 -- **大型引擎按需下载。** 首次启动时会列出视频/音频引擎(FFmpeg,约 100 MB)和少见格式引擎(LibreOffice、calibre、 - Ghostscript、Pandoc)及其大小;之后可随时在“设置 → 转换引擎”中安装或删除,并显示下载速度和剩余时间。 +- **安装时即可选择。** 在资源管理器右键菜单中添加“用 Filee 转换”(Windows 11 上还可直接显示在主菜单,无需点 + “显示更多选项”)、登录时启动,并按大小挑选可选引擎:视频/音频(FFmpeg,约 100 MB)和少见格式(LibreOffice、 + calibre、Ghostscript、Pandoc)。所选引擎会在安装完成、Filee 启动后下载;之后可随时在“设置 → 转换引擎”中安装或 + 删除,并显示下载速度和剩余时间。 - **有更新会提醒你。** 新版本发布后,Filee 会在菜单底部、通知和托盘菜单中提示,点击即可打开最新发布页面下载。 - 运行新的安装程序即可更新,设置会保留。 + 运行新的安装程序会关闭 Filee 并完成更新,设置和引擎都会保留。按用户安装的 Filee 1.1 及更早版本也会以同样方式接管。 macOS 版本正在计划中。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 0fe7943..e4cd41a 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -10,7 +10,6 @@ Each component remains under its own license. | Avalonia UI | MIT | https://github.com/AvaloniaUI/Avalonia | | CommunityToolkit.Mvvm | MIT | https://github.com/CommunityToolkit/dotnet | | SharpHook (libuiohook) | MIT (libuiohook: GPL-3.0 with linking exception / LGPL-3.0) | https://github.com/TolikPylypchuk/SharpHook | -| Velopack | MIT | https://github.com/velopack/velopack | | Magick.NET / ImageMagick | Apache-2.0 / ImageMagick License | https://github.com/dlemstra/Magick.NET | | ImageMagick delegate libraries (libheif, libde265, libwebp, libjpeg-turbo, libpng, libtiff, zlib, …) | Various (LGPL-2.1+, BSD, zlib) | bundled inside Magick.NET | | PDFsharp | MIT | https://github.com/empira/PDFsharp | @@ -74,6 +73,15 @@ Windows headers, without a C runtime. Pieces of these projects that can end up i | Zig compiler_rt (compiler support routines) | MIT | https://github.com/ziglang/zig | | mingw-w64 Windows headers and import library definitions | Public domain / ZPL-2.1 | https://www.mingw-w64.org | +## Installer + +`Filee--win-Setup.exe` is built with Inno Setup (a build tool, not part of the app); the setup and uninstall +programs inside it are Inno Setup's. + +| Component | License | Project | +|---|---|---| +| Inno Setup (Copyright (C) 1997-2026 Jordan Russell, portions Copyright (C) 2000-2026 Martijn Laan) | Inno Setup License | https://jrsoftware.org/isinfo.php | + ## Fonts and icons | Component | License | Project | diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 new file mode 100644 index 0000000..23041c6 --- /dev/null +++ b/build/build-installer.ps1 @@ -0,0 +1,145 @@ +<# +.SYNOPSIS + Builds the Windows installer Filee--win-Setup.exe (installer/Filee.iss) and, with -Portable, the portable + zip, into -OutputDir. + +.DESCRIPTION + Without -PublishDir the app is published first (Release, self-contained, -p:FileeRelease=true) into + publish/win-x64, together with the Windows 11 Explorer menu extension (build-explorer-menu.ps1) and the bundled + small engines rhwp and 7-Zip (fetch-engines.ps1); the large engines are downloaded by the app (engines.json). + + Inno Setup 7 is downloaded once into build/.cache (pinned, SHA-256 verified) and set up there in its own portable + mode: nothing is installed or registered on the build machine. The installer's engine page is generated from + EngineDownloads by build/tools/make-installer-engines.cs. + +.PARAMETER CheckOnly + Compiles the script against a stub app without compression, to check installer/Filee.iss quickly (CI). + +.EXAMPLE + pwsh build/build-installer.ps1 + pwsh build/build-installer.ps1 -Version 1.2.0 -Portable + pwsh build/build-installer.ps1 -CheckOnly +#> +[CmdletBinding()] +param( + # Defaults to in Directory.Build.props. + [string]$Version, + # A finished publish folder (Filee.exe, FileeExplorerMenu.dll/.msix, engines\); skips publishing. + [string]$PublishDir, + [string]$OutputDir = (Join-Path $PSScriptRoot '..\Releases'), + [switch]$Portable, + [switch]$CheckOnly +) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$cache = Join-Path $PSScriptRoot '.cache' + +# Inno Setup 7.1.0, 64-bit edition. Update the version, hash and size together +# (hashes: https://github.com/jrsoftware/issrc/releases, "Verifying Inno Setup Downloads" on jrsoftware.org). +$innoVersion = '7.1.0' +$innoSha256 = '0362A383ED217D4C4239B5933866DD96D3EB2102737DA92F80F6057A4B40DF2F' +$innoSize = 14304168 +$innoUrl = "https://github.com/jrsoftware/issrc/releases/download/is-$($innoVersion.Replace('.', '_'))/innosetup-$innoVersion-x64.exe" + +function Get-Iscc { + $folder = Join-Path $cache "innosetup-$innoVersion" + $iscc = Join-Path $folder 'ISCC.exe' + if (Test-Path $iscc) { return $iscc } + + New-Item -ItemType Directory -Force -Path $cache | Out-Null + $setup = Join-Path $cache "innosetup-$innoVersion-x64.exe" + if (-not ((Test-Path $setup) -and (Get-FileHash $setup -Algorithm SHA256).Hash -eq $innoSha256)) { + Write-Host "get $innoUrl" + & curl.exe --fail --location --silent --show-error --connect-timeout 20 --retry 3 --max-time 300 --output $setup $innoUrl + if ($LASTEXITCODE -ne 0) { throw "curl exit code $LASTEXITCODE" } + $hash = (Get-FileHash $setup -Algorithm SHA256).Hash + if ($hash -ne $innoSha256) { + Remove-Item $setup + throw "Inno Setup checksum mismatch (got $hash, size $((Get-Item $setup).Length), expected $innoSize bytes)" + } + } + + # /PORTABLE=1 is Inno Setup's own portable mode: no uninstaller, no file association, no shortcuts, no registry. + # /CURRENTUSER keeps it from asking for administrator rights. + Write-Host "unpack Inno Setup $innoVersion (portable)" + $partial = "$folder.partial" + Remove-Item $partial -Recurse -Force -ErrorAction SilentlyContinue + $process = Start-Process -FilePath $setup -Wait -PassThru -ArgumentList @( + '/PORTABLE=1', '/CURRENTUSER', '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', '/NOICONS', + "/DIR=`"$partial`"", "/LOG=`"$cache\innosetup-$innoVersion.log`"") + if ($process.ExitCode -ne 0 -or -not (Test-Path (Join-Path $partial 'ISCC.exe'))) { + throw "Inno Setup setup failed with exit code $($process.ExitCode) (see $cache\innosetup-$innoVersion.log)" + } + Move-Item $partial $folder + return $iscc +} + +function Invoke-Checked([string]$what, [scriptblock]$command) { + & $command + if ($LASTEXITCODE -ne 0) { throw "$what failed with exit code $LASTEXITCODE" } +} + +if (-not $Version) { + $Version = ([xml](Get-Content (Join-Path $root 'Directory.Build.props'))).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path + +if ($CheckOnly) { + # The script only needs a folder with Filee.exe in it. + $PublishDir = Join-Path $cache 'installer-check\app' + New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null + Set-Content (Join-Path $PublishDir 'Filee.exe') 'stub' -NoNewline + $OutputDir = Join-Path $cache 'installer-check' +} +elseif (-not $PublishDir) { + $PublishDir = Join-Path $root 'publish\win-x64' + Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue + Invoke-Checked 'dotnet publish' { + dotnet publish (Join-Path $root 'src\Filee.App\Filee.App.csproj') -c Release -r win-x64 --self-contained ` + "-p:Version=$Version" -p:PublishReadyToRun=true -p:FileeRelease=true -o $PublishDir + } + # Next to Filee.exe: the Explorer menu package's external location is the install folder. + & (Join-Path $PSScriptRoot 'build-explorer-menu.ps1') -Destination $PublishDir -Version $Version + # Only the small rhwp and 7-Zip are bundled; the app downloads the others when the user picks them. + & (Join-Path $PSScriptRoot 'fetch-engines.ps1') -Only rhwp, 7zip -Destination (Join-Path $PublishDir 'engines') +} +$PublishDir = (Resolve-Path $PublishDir).Path +if (-not (Test-Path (Join-Path $PublishDir 'Filee.exe'))) { throw "Filee.exe is missing in $PublishDir" } + +$iscc = Get-Iscc +Push-Location $root +try { + Invoke-Checked 'make-installer-engines' { dotnet run build/tools/make-installer-engines.cs -- installer/obj/engines.iss } +} +finally { + Pop-Location +} + +Write-Host "compile installer $Version" +$options = @('/Q', "/DAppVersion=$Version", "/DPublishDir=$PublishDir", "/O$OutputDir") +if ($CheckOnly) { $options += '--no-compression' } +Invoke-Checked 'ISCC' { & $iscc @options (Join-Path $root 'installer\Filee.iss') } +$setupExe = Join-Path $OutputDir "Filee-$Version-win-Setup.exe" +if (-not (Test-Path $setupExe)) { throw "ISCC did not write $setupExe" } +if ($CheckOnly) { + Write-Host "installer/Filee.iss compiles" + return +} + +$files = @($setupExe) +if ($Portable) { + $zip = Join-Path $OutputDir "Filee-$Version-win-Portable.zip" + Remove-Item $zip -ErrorAction SilentlyContinue + Write-Host "zip $zip" + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory($PublishDir, $zip, [System.IO.Compression.CompressionLevel]::Optimal, $false) + $files += $zip +} + +# Checksums for people who verify their download. +$sums = Join-Path $OutputDir "Filee-$Version-SHA256SUMS.txt" +$files | ForEach-Object { "$((Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()) $(Split-Path $_ -Leaf)" } | + Set-Content $sums -Encoding ascii +Get-Item ($files + $sums) | Format-Table Name, Length diff --git a/build/tools/make-installer-engines.cs b/build/tools/make-installer-engines.cs new file mode 100644 index 0000000..2c1379c --- /dev/null +++ b/build/tools/make-installer-engines.cs @@ -0,0 +1,90 @@ +// Writes the engine list of the installer's engine page (installer/Filee.iss includes the result): package ids, their +// components, sizes and which ones are suggested come from EngineDownloads (engines.json), names and texts from the +// app's translations. So the installer offers exactly what Settings → Engines offers, in the same words. +// Run by build/build-installer.ps1: dotnet run build/tools/make-installer-engines.cs -- +#:project ../../src/Filee.Engines/Filee.Engines.csproj +#:property PublishAot=false + +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Filee.Engines.Infrastructure; + +if (args.Length != 1) +{ + Console.Error.WriteLine("usage: dotnet run build/tools/make-installer-engines.cs -- "); + return 2; +} + +var repo = Environment.CurrentDirectory; +while (!File.Exists(Path.Combine(repo, "Filee.slnx"))) + repo = Path.GetDirectoryName(repo) ?? throw new InvalidOperationException("Run this from inside the Filee repository."); + +// [Languages] names in installer/Filee.iss → the app's translation files. +(string Inno, string App)[] languages = [("en", "en"), ("ko", "ko"), ("zhcn", "zh-CN")]; +var texts = languages.ToDictionary(l => l.Inno, l => Load(Path.Combine(repo, "src", "Filee.App", "Assets", "i18n", l.App + ".json"))); + +// The file is included in the middle of [Code], where a ";" line would be Pascal: open a section first. +var iss = new StringBuilder() + .AppendLine("[CustomMessages]") + .AppendLine("; Generated by build/tools/make-installer-engines.cs (build/build-installer.ps1 runs it). Do not edit."); + +void Message(string name, string key) +{ + foreach (var (inno, _) in languages) + iss.AppendLine($"{inno}.{name}={ToInnoMessage(texts[inno][key])}"); +} + +Message("EnginesTitle", "setup.title"); +Message("EnginesDescription", "setup.description"); +Message("EnginesNote", "setup.note"); +Message("EngineSizes", "engines.package.size"); +Message("EngineInstalled", "engines.state.installed"); +Message("TaskStartup", "general.start_with_system"); +Message("TaskContextMenu", "general.context_menu"); +foreach (var package in EngineDownloads.Packages) + Message("Engine_" + package.Id, $"engines.package.{package.Id}.name"); + +iss.AppendLine() + .AppendLine("[Code]") + .AppendLine("procedure AddEngines;") + .AppendLine("begin"); +foreach (var package in EngineDownloads.Packages) +{ + // EngineInstaller marks each component's folder under the engines root; LibreOffice extensions ("oxt") sit + // inside LibreOffice and come with it, so they are not checked separately. + var markerFolders = package.Components.Where(c => EngineDownloads.Components[c].Kind != "oxt"); + iss.AppendLine(string.Create(CultureInfo.InvariantCulture, + $" AddEngine('{package.Id}', '{string.Join(',', markerFolders)}', '{FormatBytes(EngineDownloads.DownloadSize(package))}', " + + $"'{FormatBytes(package.InstalledSize)}', {(EngineDownloads.IsSuggested(package) ? "True" : "False")});")); +} +iss.AppendLine("end;"); + +var output = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(output)!); +// Inno Setup reads scripts as UTF-8 only when they start with a BOM. +File.WriteAllText(output, iss.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); +Console.WriteLine($"{EngineDownloads.Packages.Count} engine packages -> {output}"); +return 0; + +static Dictionary Load(string path) +{ + using var json = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8)); + return json.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? ""); +} + +// One line; the app's {0} placeholders become Inno Setup's %1. A literal % would need %% in formatted messages only, +// so it is simply not allowed. +static string ToInnoMessage(string text) +{ + if (text.Contains('%')) + throw new InvalidOperationException($"'%' is not supported in installer texts: {text}"); + return Regex.Replace(text, @"\{(\d)\}", m => "%" + (int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) + 1)) + .ReplaceLineEndings("%n"); +} + +// Same rounding as EnginePackageState.FormatBytes in the app ("422 MB", "1.3 GB"). +static string FormatBytes(long bytes) => bytes >= 1_000_000_000 + ? string.Create(CultureInfo.InvariantCulture, $"{bytes / 1_000_000_000.0:0.0} GB") + : string.Create(CultureInfo.InvariantCulture, $"{Math.Max(1, bytes / 1_000_000)} MB"); diff --git a/build/tools/make-installer-images.cs b/build/tools/make-installer-images.cs new file mode 100644 index 0000000..895073b --- /dev/null +++ b/build/tools/make-installer-images.cs @@ -0,0 +1,33 @@ +// Generates the installer's wizard images from the logo (src/Filee.App/Assets/Icons/filee.svg, see make-icon.cs): +// installer/wizard-light.png and installer/wizard-dark.png, shown on the left of the first and last wizard page. +// Run from the repository root: dotnet run build/tools/make-installer-images.cs +#:package Magick.NET-Q8-AnyCPU + +using System.Text; +using System.Text.RegularExpressions; +using ImageMagick; + +// Aspect ratio 164:314 (Inno Setup's image area), sized for 200 % display scaling so it stays sharp. +const int Width = 430, Height = 824; +const int LogoSize = 250; + +var logo = File.ReadAllText(Path.Combine("src", "Filee.App", "Assets", "Icons", "filee.svg")); +var logoSize = double.Parse(Regex.Match(logo, "width='(\\d+)'").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); +var shapes = Regex.Match(logo, "]*>(.*)", RegexOptions.Singleline).Groups[1].Value; + +foreach (var (file, background, text) in new[] { ("wizard-light.png", "#F1F0FD", "#3C3489"), ("wizard-dark.png", "#221E4A", "#E4E2FB") }) +{ + var scale = LogoSize / logoSize; + var left = (Width - LogoSize) / 2.0; + var top = Height * 0.38 - LogoSize / 2.0; + var svg = $"" + + $"" + + $"{shapes}" + + $"Filee"; + var settings = new MagickReadSettings { BackgroundColor = MagickColors.Transparent, Format = MagickFormat.Svg }; + using var image = new MagickImage(Encoding.UTF8.GetBytes(svg), settings); + image.Strip(); + image.Write(Path.Combine("installer", file), MagickFormat.Png); +} +Console.WriteLine("Wizard images written to installer/"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b25f3f3..d9124af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -84,13 +84,14 @@ applied by `WindowsPlatformServices.SetContextMenu` at start-up and whenever set (`src/Filee.ExplorerMenu`, plain C, no C runtime) declared by the sparse package `FileeExplorerMenu.msix` (`ExplorerMenuPackage.CreateManifest`: `desktop4:FileExplorerContextMenus`, `desktop5:ItemType Type="*"`, `com:SurrogateServer`, `uap10:AllowExternalContent`, `AppListEntry="none"`). Both files ship next to Filee.exe and the - package's external location is the install folder (`%LocalAppData%\Filee\current`, stable across Velopack updates). + package's external location is the install folder (`C:\Program Files\Filee` by default). - The package is **unsigned** (publisher contains `OID.2.25.311729368913984317654407730594956997722=1`; Filee has no code-signing certificate). Windows only registers an unsigned package with executable content **with administrator rights** (it installs it for all users); a per-user attempt fails with 0x80073D2B "an unsigned - package cannot include Executable activations". So it is opt-in: the General page shows "Add to the main menu" - on Windows 11 x64, which runs `Add-AppxPackage -Path … -ExternalLocation … -AllowUnsigned` in Windows PowerShell - through one UAC prompt (`ExplorerMenuRegistration`). A signed package would register per user without a prompt. + package cannot include Executable activations". Setup (`installer/Filee.iss`) has those rights anyway and + registers it on Windows 11 x64 while its option is ticked. Later the General page offers "Add to the main menu", + which runs `Add-AppxPackage -Path … -ExternalLocation … -AllowUnsigned` in Windows PowerShell through one UAC + prompt (`ExplorerMenuRegistration`). A signed package would register per user without a prompt. - The manifest version (`ExplorerMenuPackage.ManifestVersion`) is independent of the app version, so app updates don't need a new prompt: the DLL is loaded from the install folder and updated with the app. Bump it only when the manifest changes; the page then offers to add the entry again ("Outdated"). @@ -100,13 +101,15 @@ applied by `WindowsPlatformServices.SetContextMenu` at start-up and whenever set package is registered the classic verb is removed (Windows lists packaged commands under "Show more options" too). - Invoke starts `Filee.exe --convert ""…` next to the DLL and returns at once. Selections longer than a command line go through `%TEMP%\Filee-convert-*.txt` (`--convert-list`, one path per line), which the app deletes. - - Velopack: an update renames the `current` folder even while Explorer has the DLL loaded (Windows allows renaming - folders that contain loaded DLLs; only deleting the file fails, so an old copy may stay in Velopack's temp folder - until Explorer unloads it). Uninstall removes the package (`UninstallCleanup`, no admin needed) and the title file. + - Updates: Setup asks the running Filee to exit (`Filee.exe --quit`), then renames a `FileeExplorerMenu.dll` that + Explorer still has loaded (Windows allows renaming a loaded DLL, not replacing or deleting it) and has Windows + delete the old copy at the next restart, so updating needs no restart. An update keeps the registration as it + was and refreshes it. Uninstall runs `Filee.exe --uninstall-cleanup` (`UninstallCleanup`): it removes the package, + the title file, the classic verb, the startup entry and downloaded engines; settings stay. - Build: `pwsh build/build-explorer-menu.ps1` downloads a pinned Zig (SHA-256 checked) into `build/.cache`, compiles the DLL and packs the MSIX with `build/tools/make-explorer-package.cs` (Windows' own packaging API, no SDK). - Output: `build/.cache/explorer-menu`; CI builds it for the tests, the release workflow copies it into the publish - folder. + Output: `build/.cache/explorer-menu`; CI builds it for the tests, `build/build-installer.ps1` builds it into the + publish folder. Debugging: `Get-AppxPackage Filee.ExplorerMenu` shows the registration (`InstallLocation` is the external location); `Get-AppPackageLog -ActivityID ` explains a failed deployment. To try a local build, copy diff --git a/installer/Filee.iss b/installer/Filee.iss new file mode 100644 index 0000000..e877015 --- /dev/null +++ b/installer/Filee.iss @@ -0,0 +1,525 @@ +; Filee installer (Inno Setup 7). Build it with build/build-installer.ps1, which publishes the app, generates +; obj\engines.iss (build/tools/make-installer-engines.cs) and runs +; ISCC.exe /DAppVersion=1.2.0 /DPublishDir= /O installer\Filee.iss +; +; Besides copying files it +; - installs for all users into Program Files (administrator rights, one UAC prompt). Filee itself keeps running as +; the signed-in user without elevation: Windows blocks drag and drop from Explorer into elevated windows; +; - closes a running Filee first ("Filee.exe --quit") and moves FileeExplorerMenu.dll aside when Explorer has it +; loaded, so updates need no restart; +; - takes over an installation of Filee 1.1 or earlier (Velopack, per user in %LocalAppData%\Filee): removes it but +; keeps the settings (%APPDATA%\Filee) and downloaded engines (%LocalAppData%\Filee\engines); +; - on Windows 11 registers the top-level File Explorer menu entry (an unsigned sparse package, which needs the +; administrator rights Setup already has); +; - offers the optional conversion engines; Filee downloads the chosen ones (verified) when Setup starts it. +; The uninstaller runs "Filee.exe --uninstall-cleanup" for the per-user parts (UninstallCleanup.cs). + +#ifndef AppVersion + #error AppVersion is not defined: build with build/build-installer.ps1 +#endif +#ifndef PublishDir + #error PublishDir is not defined: build with build/build-installer.ps1 +#endif + +#define AppName "Filee" +#define AppExe "Filee.exe" +#define RepositoryUrl "https://github.com/KnifeLemon/Filee" +; Never change: Windows finds the installed Filee by this id (uninstall key "{}_is1"). +#define AppGuid "C8686E06-8327-40A2-B0F7-3DEDD61D9248" + +[Setup] +AppId={{{#AppGuid}} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher=Filee contributors +AppPublisherURL={#RepositoryUrl} +AppSupportURL={#RepositoryUrl}/issues +AppUpdatesURL={#RepositoryUrl}/releases +AppCopyright=Copyright (c) Filee contributors +VersionInfoVersion={#AppVersion} +VersionInfoProductName={#AppName} +SetupArchitecture=x64 +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.17763 +PrivilegesRequired=admin +DefaultDirName={autopf}\{#AppName} +DisableWelcomePage=no +DisableProgramGroupPage=yes +UninstallDisplayName={#AppName} +UninstallDisplayIcon={app}\{#AppExe} +SetupIconFile=..\src\Filee.App\Assets\Icons\filee.ico +WizardStyle=modern dynamic +WizardImageFile=wizard-light.png +WizardImageFileDynamicDark=wizard-dark.png +WizardSmallImageFile=..\src\Filee.App\Assets\Icons\filee.png +OutputBaseFilename=Filee-{#AppVersion}-win-Setup +Compression=lzma2/ultra64 +SolidCompression=yes +; Filee is closed by PrepareToInstall; Restart Manager is only the fallback, and only for Filee.exe: with DLLs in the +; filter it would offer to close Explorer, which loads FileeExplorerMenu.dll. +CloseApplications=yes +CloseApplicationsFilter=*.exe +RestartApplications=no +; Per-user parts (the old Velopack copy, Start menu shortcuts) belong to the account that runs Setup. +UsedUserAreasWarning=no +SetupLogging=yes + +[Languages] +Name: "en"; MessagesFile: "compiler:Default.isl" +Name: "ko"; MessagesFile: "compiler:Languages\Korean.isl" +Name: "zhcn"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" + +; Texts shared with the app (engine names, the two options of the General page) are generated into obj\engines.iss. +[CustomMessages] +en.GroupOptions=Options: +ko.GroupOptions=옵션: +zhcn.GroupOptions=选项: +en.TaskModernMenu=Also show it at the top of the Windows 11 menu, not only under “Show more options” +ko.TaskModernMenu=Windows 11 기본 메뉴에도 표시 (“추가 옵션 표시”를 누르지 않아도 보여요) +zhcn.TaskModernMenu=也显示在 Windows 11 主菜单中,而不仅是“显示更多选项”里 +en.StatusRemovingOld=Removing the previous version (settings and engines are kept)... +ko.StatusRemovingOld=이전 버전을 정리하는 중 (설정과 엔진은 그대로 둬요)... +zhcn.StatusRemovingOld=正在移除旧版本(保留设置和引擎)... +en.StatusExplorerMenu=Adding Filee to the File Explorer menu... +ko.StatusExplorerMenu=탐색기 메뉴에 Filee를 추가하는 중... +zhcn.StatusExplorerMenu=正在将 Filee 添加到资源管理器菜单... +en.ExplorerMenuFailed=Windows did not add Filee to the top of the File Explorer menu:%n%n%1%n%nFilee still appears under “Show more options”. You can try again in Filee under Settings → General. +ko.ExplorerMenuFailed=Windows가 탐색기 기본 메뉴에 Filee를 추가하지 못했어요:%n%n%1%n%nFilee는 “추가 옵션 표시” 안에는 계속 나타나요. Filee의 설정 → 일반에서 다시 시도할 수 있어요. +zhcn.ExplorerMenuFailed=Windows 未能将 Filee 添加到资源管理器主菜单:%n%n%1%n%nFilee 仍会出现在“显示更多选项”中。你可以在 Filee 的 设置 → 常规 中重试。 +en.ReadyEngines=Conversion engines (Filee downloads them when it starts): +ko.ReadyEngines=변환 엔진 (Filee가 시작하면서 내려받아요): +zhcn.ReadyEngines=转换引擎(Filee 启动后下载): +en.FinishedRunning=Filee is installed and running in the notification area of the taskbar.%n%nTo convert files, drag them while holding the trigger key, or right-click them in File Explorer. +ko.FinishedRunning=Filee가 설치되어 작업 표시줄 알림 영역에서 실행 중이에요.%n%n파일을 변환하려면 지정한 키를 누른 채 파일을 끌거나, 탐색기에서 파일을 우클릭하세요. +zhcn.FinishedRunning=Filee 已安装,正在任务栏通知区域运行。%n%n要转换文件,请按住触发键拖动文件,或在资源管理器中右键单击文件。 +en.FinishedEngines=The conversion engines you picked are downloading in the Filee window. +ko.FinishedEngines=선택한 변환 엔진은 Filee 창에서 내려받고 있어요. +zhcn.FinishedEngines=你选择的转换引擎正在 Filee 窗口中下载。 + +[Tasks] +Name: "contextmenu"; Description: "{cm:TaskContextMenu}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall +Name: "contextmenu\top"; Description: "{cm:TaskModernMenu}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall and IsExplorerMenuSupported +Name: "startup"; Description: "{cm:TaskStartup}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon + +[UninstallRun] +; Quits Filee, then removes the Explorer menu entries, the startup entry and downloaded engines. Settings stay. +Filename: "{app}\{#AppExe}"; Parameters: "--uninstall-cleanup"; Flags: runhidden waituntilterminated; RunOnceId: "FileeCleanup" + +[Code] +const + // ExplorerMenuPackage in src/Filee.Platform.Windows (InstallerScriptTests keeps these in sync). + ExplorerMenuName = 'Filee.ExplorerMenu'; + ExplorerMenuFamily = 'Filee.ExplorerMenu_c73vxh346rtay'; + ExplorerMenuDll = 'FileeExplorerMenu.dll'; + ExplorerMenuPackageFile = 'FileeExplorerMenu.msix'; + ExplorerMenuMinimumBuild = 22000; + // EngineInstaller marks every installed engine folder with this file. + EngineMarker = '.filee-component'; + // Filee 1.1 and earlier: Velopack installed it per user and registered it here. + VelopackUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\Filee'; + ERROR_INSUFFICIENT_BUFFER = 122; + +var + // Filee 1.2 or later is installed already: this run is an update, which keeps the user's choices. + WasInstalled: Boolean; + HadVelopackInstall: Boolean; + ExplorerMenuWasRegistered: Boolean; + EnginePage: TInputOptionWizardPage; + // Package id per entry of EnginePage; empty for packages that are installed already. + EngineIds: TArrayOfString; + +function GetPackagesByPackageFamily(PackageFamilyName: String; var Count: Cardinal; PackageFullNames: NativeInt; + var BufferLength: Cardinal; Buffer: NativeInt): Integer; + external 'GetPackagesByPackageFamily@kernel32.dll stdcall delayload'; + +function IsFreshInstall: Boolean; +begin + Result := not WasInstalled; +end; + +function IsExplorerMenuSupported: Boolean; +var + Version: TWindowsVersion; +begin + // Windows 11 has the new menu; Explorer must be x64 to load the DLL. + GetWindowsVersionEx(Version); + Result := (Version.Build >= ExplorerMenuMinimumBuild) and (ProcessorArchitecture = paX64); +end; + +function IsExplorerMenuRegistered: Boolean; +var + Count, Length: Cardinal; +begin + Count := 0; + Length := 0; + try + Result := (GetPackagesByPackageFamily(ExplorerMenuFamily, Count, 0, Length, 0) = ERROR_INSUFFICIENT_BUFFER) and (Count > 0); + except + Result := False; + end; +end; + +// A PowerShell single-quoted string. PowerShell also takes the typographic quotes as single quotes. +function PsQuote(const Value: String): String; +begin + Result := Value; + StringChangeEx(Result, '''', '''''', True); + StringChangeEx(Result, #$2018, #$2018#$2018, True); + StringChangeEx(Result, #$2019, #$2019#$2019, True); + StringChangeEx(Result, #$201A, #$201A#$201A, True); + StringChangeEx(Result, #$201B, #$201B#$201B, True); + Result := '''' + Result + ''''; +end; + +// Runs Windows PowerShell (the Appx cmdlets live there) with the rights of Setup. The command must not contain +// double quotes; on failure Error gets the exception message without the "NOTE: For additional information" tail. +function RunPowerShell(const Command: String; var Error: String): Boolean; +var + ErrorFile: String; + Lines: TArrayOfString; + I, ResultCode, Note: Integer; +begin + ErrorFile := ExpandConstant('{tmp}\powershell-error.txt'); + DeleteFile(ErrorFile); + Result := Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoLogo -NoProfile -NonInteractive -Command "$ErrorActionPreference = ''Stop''; try { ' + Command + '; exit 0 } ' + + 'catch { [IO.File]::WriteAllText(' + PsQuote(ErrorFile) + ', $_.Exception.Message, [Text.Encoding]::UTF8); exit 1 }"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0); + Error := ''; + if Result then + Exit; + if LoadStringsFromFile(ErrorFile, Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do + if Trim(Lines[I]) <> '' then + Error := Error + Trim(Lines[I]) + ' '; + Note := Pos('NOTE: For additional information', Error); + if Note > 0 then + Error := Copy(Error, 1, Note - 1); + Error := Trim(Error); + if Error = '' then + Error := Format('PowerShell exit code %d', [ResultCode]); + Log('PowerShell failed: ' + Error); +end; + +procedure UnregisterExplorerMenu; +var + Error: String; +begin + RunPowerShell('Get-AppxPackage -Name ' + PsQuote(ExplorerMenuName) + ' | Remove-AppxPackage', Error); +end; + +// Registers the package next to Filee.exe with the install folder as its external location. A fresh install first +// removes any older registration, which may point to another folder. +procedure RegisterExplorerMenu(RemoveFirst: Boolean); +var + Command, Error: String; +begin + WizardForm.StatusLabel.Caption := CustomMessage('StatusExplorerMenu'); + Command := 'Add-AppxPackage -Path ' + PsQuote(ExpandConstant('{app}\') + ExplorerMenuPackageFile) + + ' -ExternalLocation ' + PsQuote(ExpandConstant('{app}')) + ' -AllowUnsigned -ForceUpdateFromAnyVersion'; + if RemoveFirst then + Command := 'Get-AppxPackage -Name ' + PsQuote(ExplorerMenuName) + ' | Remove-AppxPackage; ' + Command; + if not RunPowerShell(Command, Error) and not WizardSilent then + MsgBox(FmtMessage(CustomMessage('ExplorerMenuFailed'), [Error]), mbError, MB_OK); +end; + +procedure UpdateExplorerMenu; +begin + if not IsExplorerMenuSupported then + Exit; + if WasInstalled then + begin + // An update keeps what was chosen in Filee (Settings > General) and refreshes the registration. + if ExplorerMenuWasRegistered then + RegisterExplorerMenu(False); + end + else if WizardIsTaskSelected('contextmenu\top') then + RegisterExplorerMenu(True) + else if IsExplorerMenuRegistered then + UnregisterExplorerMenu; +end; + +// Explorer keeps FileeExplorerMenu.dll loaded after showing the menu. A loaded DLL cannot be replaced or deleted, but +// it can be renamed: move it into Folder (same drive) and let Windows delete it at the next restart. +procedure MoveLoadedDllAside(const Folder: String); +var + Dll, Aside: String; +begin + Dll := ExpandConstant('{app}\') + ExplorerMenuDll; + if not FileExists(Dll) or DeleteFile(Dll) then + Exit; + Aside := AddBackslash(Folder) + ExplorerMenuDll + '.' + GetDateTimeString('yyyymmddhhnnss', #0, #0) + '.old'; + if RenameFile(Dll, Aside) then + RestartReplace(Aside, '') + else + Log('Could not move the loaded ' + Dll + ' aside'); +end; + +procedure DeleteFileOrSchedule(const Path: String); +begin + if FileExists(Path) and not DeleteFile(Path) then + RestartReplace(Path, ''); +end; + +// Deletes a folder tree; what Windows keeps open (a DLL loaded by Explorer) goes at the next restart. +procedure DeleteTreeOrSchedule(const Dir: String); +var + Find: TFindRec; + Path: String; +begin + if not DirExists(Dir) then + Exit; + if FindFirst(AddBackslash(Dir) + '*', Find) then + try + repeat + if (Find.Name <> '.') and (Find.Name <> '..') then + begin + Path := AddBackslash(Dir) + Find.Name; + // Never follow junctions or symbolic links out of the folder. + if (Find.Attributes and FILE_ATTRIBUTE_REPARSE_POINT) <> 0 then + RemoveDir(Path) + else if (Find.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0 then + DeleteTreeOrSchedule(Path) + else + DeleteFileOrSchedule(Path); + end; + until not FindNext(Find); + finally + FindClose(Find); + end; + if not RemoveDir(Dir) then + RestartReplace(Dir, ''); +end; + +// Removes the Velopack installation of Filee 1.1 and earlier without running its uninstaller, which would also +// delete the downloaded engines. Its registry entries (context menu, startup) are rewritten by the new Filee. +procedure RemoveVelopackInstall; +var + Root: String; +begin + WizardForm.StatusLabel.Caption := CustomMessage('StatusRemovingOld'); + // Its Explorer menu entry points into the folder that is deleted next. + if ExplorerMenuWasRegistered then + UnregisterExplorerMenu; + Root := ExpandConstant('{localappdata}\Filee'); + DeleteFileOrSchedule(Root + '\Update.exe'); + DeleteFileOrSchedule(Root + '\Filee.exe'); + DeleteTreeOrSchedule(Root + '\current'); + DeleteTreeOrSchedule(Root + '\packages'); + DeleteFile(ExpandConstant('{userprograms}\Filee.lnk')); + DeleteFile(ExpandConstant('{userdesktop}\Filee.lnk')); + RegDeleteKeyIncludingSubkeys(HKCU, VelopackUninstallKey); +end; + +// True when every listed component has been downloaded already (EngineInstaller writes the marker last). +function IsEngineInstalled(const MarkerFolders: String): Boolean; +var + Rest, Folder: String; + Comma: Integer; +begin + Result := True; + Rest := MarkerFolders; + while Rest <> '' do + begin + Comma := Pos(',', Rest); + if Comma = 0 then + begin + Folder := Rest; + Rest := ''; + end + else + begin + Folder := Copy(Rest, 1, Comma - 1); + Rest := Copy(Rest, Comma + 1, MaxInt); + end; + if not FileExists(ExpandConstant('{localappdata}\Filee\engines\') + Folder + '\' + EngineMarker) then + begin + Result := False; + Exit; + end; + end; +end; + +// Called by AddEngines (generated) for every package of Settings > Engines. +procedure AddEngine(const Id, MarkerFolders, DownloadSize, InstalledSize: String; Suggested: Boolean); +var + Index: Integer; + Installed: Boolean; +begin + Installed := IsEngineInstalled(MarkerFolders); + Index := EnginePage.Add(CustomMessage('Engine_' + Id)); + SetArrayLength(EngineIds, Index + 1); + if Installed then + begin + EngineIds[Index] := ''; + EnginePage.CheckListBox.ItemSubItem[Index] := CustomMessage('EngineInstalled'); + EnginePage.CheckListBox.ItemEnabled[Index] := False; + end + else + begin + EngineIds[Index] := Id; + EnginePage.CheckListBox.ItemSubItem[Index] := FmtMessage(CustomMessage('EngineSizes'), [DownloadSize, InstalledSize]); + EnginePage.Values[Index] := Suggested; + end; +end; + +#include "obj\engines.iss" + +function SelectedEngines: String; +var + I: Integer; +begin + Result := ''; + for I := 0 to GetArrayLength(EngineIds) - 1 do + if (EngineIds[I] <> '') and EnginePage.Values[I] then + begin + if Result <> '' then + Result := Result + ','; + Result := Result + EngineIds[I]; + end; +end; + +function OnOff(Value: Boolean): String; +begin + if Value then + Result := 'on' + else + Result := 'off'; +end; + +// Starts Filee as the signed-in user (not elevated) with the choices made in the wizard. An update starts it in +// the tray, as it was running before. +procedure StartFilee; +var + Parameters: String; + ResultCode: Integer; +begin + if WasInstalled then + Parameters := '--background' + else + Parameters := '--install-engines=' + SelectedEngines + + ' --start-with-windows=' + OnOff(WizardIsTaskSelected('startup')) + + ' --context-menu=' + OnOff(WizardIsTaskSelected('contextmenu')); + if not ExecAsOriginalUser(ExpandConstant('{app}\{#AppExe}'), Parameters, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then + Log('Could not start Filee: ' + SysErrorMessage(ResultCode)); +end; + +function InitializeSetup: Boolean; +begin + WasInstalled := RegKeyExists(HKA, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{' + '{#AppGuid}' + '}_is1'); + HadVelopackInstall := FileExists(ExpandConstant('{localappdata}\Filee\Update.exe')) + and DirExists(ExpandConstant('{localappdata}\Filee\current')); + ExplorerMenuWasRegistered := IsExplorerMenuSupported and IsExplorerMenuRegistered; + // (A line must not start with "[" in [Code]: the compiler would take it for a section.) + Log(Format('Update: %d, Velopack install: %d, Explorer menu registered: %d', [Ord(WasInstalled), + Ord(HadVelopackInstall), Ord(ExplorerMenuWasRegistered)])); + Result := True; +end; + +procedure InitializeWizard; +begin + EnginePage := CreateInputOptionPage(wpSelectTasks, CustomMessage('EnginesTitle'), CustomMessage('EnginesNote'), + CustomMessage('EnginesDescription'), False, False); + AddEngines; +end; + +// True when at least one engine package is not installed yet. +function AnyEngineOffered: Boolean; +var + I: Integer; +begin + Result := False; + for I := 0 to GetArrayLength(EngineIds) - 1 do + if EngineIds[I] <> '' then + Result := True; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + // Updates keep the engines; Settings > Engines manages them from then on. + Result := (PageID = EnginePage.ID) and (WasInstalled or not AnyEngineOffered); +end; + +function UpdateReadyMemo(Space, NewLine, MemoUserInfoInfo, MemoDirInfo, MemoTypeInfo, MemoComponentsInfo, + MemoGroupInfo, MemoTasksInfo: String): String; +var + Engines: String; + I: Integer; +begin + Result := MemoDirInfo; + if MemoTasksInfo <> '' then + Result := Result + NewLine + NewLine + MemoTasksInfo; + if WasInstalled then + Exit; + Engines := ''; + for I := 0 to GetArrayLength(EngineIds) - 1 do + if (EngineIds[I] <> '') and EnginePage.Values[I] then + Engines := Engines + Space + CustomMessage('Engine_' + EngineIds[I]) + NewLine; + if Engines <> '' then + Result := Result + NewLine + NewLine + CustomMessage('ReadyEngines') + NewLine + Engines; +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + Result := ''; + // Filee 1.2 and later exit when asked (after saving); this waits until they have. + ResultCode := 0; + if WasInstalled and FileExists(ExpandConstant('{app}\{#AppExe}')) then + Exec(ExpandConstant('{app}\{#AppExe}'), '--quit', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + // Filee 1.1 and earlier, or a Filee that did not answer: end it. + if HadVelopackInstall or (ResultCode <> 0) then + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExe} /FI "USERNAME eq ' + GetUserNameString + '"', '', + SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + case CurStep of + ssInstall: + begin + MoveLoadedDllAside(ExpandConstant('{app}')); + if HadVelopackInstall then + RemoveVelopackInstall; + end; + ssPostInstall: + begin + UpdateExplorerMenu; + StartFilee; + end; + end; +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + if CurPageID = wpFinished then + begin + WizardForm.FinishedLabel.Caption := CustomMessage('FinishedRunning'); + if not WasInstalled and (SelectedEngines <> '') then + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + CustomMessage('FinishedEngines'); + WizardForm.AdjustLabelHeight(WizardForm.FinishedLabel); + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + case CurUninstallStep of + // Before files are removed: out of the install folder, so that it can be deleted. + usUninstall: + MoveLoadedDllAside(ExpandConstant('{tmp}')); + usPostUninstall: + if DirExists(ExpandConstant('{app}')) and not RemoveDir(ExpandConstant('{app}')) then + RestartReplace(ExpandConstant('{app}'), ''); + end; +end; diff --git a/installer/wizard-dark.png b/installer/wizard-dark.png new file mode 100644 index 0000000..6c9f122 Binary files /dev/null and b/installer/wizard-dark.png differ diff --git a/installer/wizard-light.png b/installer/wizard-light.png new file mode 100644 index 0000000..5aafddf Binary files /dev/null and b/installer/wizard-light.png differ diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 5e9ca6c..4582476 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -119,7 +119,7 @@ private static void ApplySystemIntegration(AppSettings settings, ILocalizer loc) if (exe is null || !OperatingSystem.IsWindows()) return; // When running from the IDE (bin\Debug) don't touch the user's Explorer / startup settings. - if (!AppHost.Get().IsInstalled) + if (!UpdateService.IsReleaseBuild) return; try { @@ -135,6 +135,12 @@ private static void ApplySystemIntegration(AppSettings settings, ILocalizer loc) private void HandleCommandLine(CommandLine options, bool firstLaunch = false) { + if (options.Quit) + { + // The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this). + (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); + return; + } // The Windows 11 Explorer menu hands over huge selections in a temp file; its paths are in ConvertFiles now. options.DeleteListFile(); if (options.ConvertFiles.Count > 0) @@ -147,19 +153,45 @@ private void HandleCommandLine(CommandLine options, bool firstLaunch = false) } var store = AppHost.Get(); + ApplyInstallerChoices(options, store); if (!options.Background || options.ShowSettings || !firstLaunch) AppHost.Get().ShowMain(store.Settings.FirstRunCompleted ? null : "home"); - if (!store.Settings.FirstRunCompleted) + if (options.InstallEngines is { } engines) { - // The installer only contains the small engines: offer the large ones right after installing. + // Picked on the installer's engine page: download them now. Picking none answers the first-run question. + if (engines.Count > 0) + AppHost.Get().ShowEngineSetup(engines); + } + else if (!store.Settings.FirstRunCompleted) + { + // The installer only contains the small engines: offer the large ones on first run (portable zip, or an + // installation that skipped the engine page). if (AppHost.Get().Packages.Any(p => !p.IsInstalled)) AppHost.Get().ShowEngineSetup(); + } + if (!store.Settings.FirstRunCompleted) + { store.Settings.FirstRunCompleted = true; store.SaveSettings(); } } + /// + /// Options ticked on the installer's task page. Saving applies them (registry entries, see ApplySystemIntegration) + /// as if they had been switched on the General page. + /// + private static void ApplyInstallerChoices(CommandLine options, UserDataStore store) + { + if (options.StartWithWindows is null && options.ContextMenu is null) + return; + if (options.StartWithWindows is { } start) + store.Settings.StartWithSystem = start; + if (options.ContextMenu is { } menu) + store.Settings.ContextMenuEnabled = menu; + store.SaveSettings(); + } + private void OpenPendingFiles() { var files = _pendingConvertFiles.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); diff --git a/src/Filee.App/Filee.App.csproj b/src/Filee.App/Filee.App.csproj index f0d8952..d7dd4f3 100644 --- a/src/Filee.App/Filee.App.csproj +++ b/src/Filee.App/Filee.App.csproj @@ -30,7 +30,6 @@ - @@ -57,5 +56,11 @@ <_Parameter1>RepositoryUrl <_Parameter2>$(FileeRepositoryUrl) + + + <_Parameter1>FileeRelease + <_Parameter2>true + diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 73d7c38..2972ae2 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -1,14 +1,14 @@ // Entry point. Order matters: -// 1. Velopack hooks (install/update/uninstall) must run before anything else. -// 2. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments +// 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments // to the running instance and exits. -// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command. +// 2. Installer commands (--quit, --uninstall-cleanup) run without any window. +// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit). +using System.Diagnostics; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Filee.App.Services; -using Velopack; namespace Filee.App; @@ -20,16 +20,16 @@ internal static class Program /// Owned by the first instance for its whole lifetime. public static SingleInstance? Instance { get; private set; } + /// How long the installer waits for a running Filee to save and exit. + private static readonly TimeSpan QuitTimeout = TimeSpan.FromSeconds(20); + [STAThread] public static int Main(string[] args) { - var velopack = VelopackApp.Build(); - if (OperatingSystem.IsWindows()) - velopack = velopack.OnBeforeUninstallFastCallback(_ => UninstallCleanup.Run()); - velopack.Run(); - Options = CommandLine.Parse(args); Instance = SingleInstance.Acquire(); + if (Options.Quit || Options.UninstallCleanup) + return RunInstallerCommand(Instance); if (!Instance.IsFirst) { Instance.ForwardToFirst(args); @@ -47,6 +47,57 @@ public static int Main(string[] args) } } + /// + /// --quit and --uninstall-cleanup from the installer: ask the running Filee (if any) to exit and wait + /// for it, so its files can be replaced or removed; then clean up for the uninstaller. Exit code 1 when Filee is + /// still running. + /// + private static int RunInstallerCommand(SingleInstance instance) + { + using (instance) + { + var deadline = DateTime.UtcNow + QuitTimeout; + if (!instance.IsFirst) + { + instance.ForwardToFirst(["--quit"]); + if (!instance.WaitForFirstToExit(QuitTimeout)) + return 1; + } + // The mutex is released just before the process ends: wait for the end, so Filee.exe is no longer in use. + if (!WaitForOtherProcessesOfThisExe(deadline)) + return 1; + if (Options.UninstallCleanup) + UninstallCleanup.Run(); + return 0; + } + } + + private static bool WaitForOtherProcessesOfThisExe(DateTime deadline) + { + if (Environment.ProcessPath is not { } exe) + return true; + foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(exe))) + { + using (process) + { + try + { + if (process.Id == Environment.ProcessId + || !string.Equals(process.MainModule?.FileName, exe, StringComparison.OrdinalIgnoreCase)) + continue; + var left = deadline - DateTime.UtcNow; + if (left <= TimeSpan.Zero || !process.WaitForExit(left)) + return false; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + // Exited meanwhile, or another user's process we may not inspect. + } + } + } + return true; + } + /// Also used by the XAML previewer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() diff --git a/src/Filee.App/Services/CommandLine.cs b/src/Filee.App/Services/CommandLine.cs index a912067..4bd7776 100644 --- a/src/Filee.App/Services/CommandLine.cs +++ b/src/Filee.App/Services/CommandLine.cs @@ -5,6 +5,13 @@ // Filee.exe --convert a b ... open the donut for these files (Explorer context menu / Send To) // Filee.exe --convert-list same, with one path per line in a UTF-8 file (Windows 11 Explorer menu, for // selections too long for a command line); the app deletes the file afterwards +// Used by the installer (installer/Filee.iss): +// Filee.exe --install-engines=a,b download these engine packages (EngineDownloads ids) in the engine window; +// an empty list means the user chose none, so the first-run choice is skipped +// Filee.exe --start-with-windows=on|off, --context-menu=on|off +// set the matching options on the General page +// Filee.exe --quit ask a running Filee to exit, and wait until it has (no window otherwise) +// Filee.exe --uninstall-cleanup quit Filee, then remove what it registered for this user (UninstallCleanup) using System.Text; @@ -12,7 +19,19 @@ namespace Filee.App.Services; /// Parsed command line. /// The --convert-list file, if any (see ). -public sealed record CommandLine(bool Background, bool ShowSettings, IReadOnlyList ConvertFiles, string? ListFile = null) +/// Engine packages chosen in the installer; null when the option is absent. +/// Value of --start-with-windows; null when absent. +/// Value of --context-menu; null when absent. +public sealed record CommandLine( + bool Background, + bool ShowSettings, + IReadOnlyList ConvertFiles, + string? ListFile = null, + bool Quit = false, + bool UninstallCleanup = false, + IReadOnlyList? InstallEngines = null, + bool? StartWithWindows = null, + bool? ContextMenu = null) { /// File name prefix of list files written by FileeExplorerMenu.dll (LIST_PREFIX there) into %TEMP%. public const string ListFilePrefix = "Filee-convert-"; @@ -25,6 +44,11 @@ public static CommandLine Parse(IReadOnlyList args) string? listFile = null; var collecting = false; var expectList = false; + var quit = false; + var cleanup = false; + List? engines = null; + bool? startWithWindows = null; + bool? contextMenu = null; foreach (var arg in args) { @@ -35,8 +59,25 @@ public static CommandLine Parse(IReadOnlyList args) files.AddRange(ReadListFile(arg).Where(Exists).Select(Path.GetFullPath)); continue; } - switch (arg.ToLowerInvariant()) + var (name, value) = SplitOption(arg); + switch (name) { + case "--install-engines" when value is not null: + engines = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(id => id.ToLowerInvariant()).Distinct().ToList(); + break; + case "--start-with-windows" when OnOff(value) is { } on: + startWithWindows = on; + break; + case "--context-menu" when OnOff(value) is { } on: + contextMenu = on; + break; + case "--quit": + quit = true; + break; + case "--uninstall-cleanup": + cleanup = true; + break; case "--background": background = true; break; @@ -50,15 +91,29 @@ public static CommandLine Parse(IReadOnlyList args) expectList = true; break; default: - // Velopack passes its own --veloapp-* arguments; ignore anything unknown that isn't a file. + // Ignore anything unknown that isn't a file (e.g. options of a newer version). if (collecting && !arg.StartsWith("--", StringComparison.Ordinal) && Exists(arg)) files.Add(Path.GetFullPath(arg)); break; } } - return new CommandLine(background, settings, files, listFile); + return new CommandLine(background, settings, files, listFile, quit, cleanup, engines, startWithWindows, contextMenu); } + /// "--Name=Value" → ("--name", "Value"); "--name" → ("--name", null). + private static (string Name, string? Value) SplitOption(string arg) + { + var equals = arg.StartsWith("--", StringComparison.Ordinal) ? arg.IndexOf('=') : -1; + return equals < 0 ? (arg.ToLowerInvariant(), null) : (arg[..equals].ToLowerInvariant(), arg[(equals + 1)..]); + } + + private static bool? OnOff(string? value) => value?.Trim().ToLowerInvariant() switch + { + "on" or "1" or "true" or "yes" => true, + "off" or "0" or "false" or "no" => false, + _ => null, + }; + /// /// Deletes the list file once its paths were taken over. Only files the Explorer menu wrote (prefix, temp /// folder) are deleted, so a crafted command line cannot make Filee delete anything else. diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index e3f11a8..4c887ab 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -1,8 +1,9 @@ // Opens, fills and closes the live donut toolbar. // // Drag mode (modifier + drag in Explorer): -// gesture detected → empty ring appears at the cursor (not activated) → DragEnter brings the file list → -// slices pop out → drop on a slice converts / drop in the hole or release elsewhere closes. +// gesture detected → an invisible window waits at the cursor (not activated) → DragEnter brings the file list → +// the donut pops out → drop on a slice converts / drop in the hole or release elsewhere closes. Without files +// (text, a rubber-band selection) nothing ever shows. // Click mode (keyboard shortcut, context menu, drop zone): // files are known up front → donut appears activated → click a slice to convert, right-click to edit, // Esc / click outside closes. @@ -45,7 +46,11 @@ public sealed class RadialController( public bool IsVisible => _visible; - /// Drag gesture started at the given physical screen point. + /// + /// Drag gesture started at the given physical screen point. The window is placed under the cursor but stays + /// invisible until files are dragged into it (FilesEntered); a gesture without files (text, a rubber-band + /// selection, nothing at all) never shows anything and closes when the button is released. + /// public void ShowForDrag(int x, int y) { ReleaseProgressToToast(); @@ -54,7 +59,6 @@ public void ShowForDrag(int x, int y) viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); - window.DonutControl.PlayOpenAnimation(); } /// Opens the donut for known files (click mode). diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs index 46e287e..e98f34a 100644 --- a/src/Filee.App/Services/SingleInstance.cs +++ b/src/Filee.App/Services/SingleInstance.cs @@ -77,6 +77,27 @@ public void ForwardToFirst(string[] args) } } + /// + /// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit. + /// Returns false on timeout. + /// + public bool WaitForFirstToExit(TimeSpan timeout) + { + if (IsFirst) + return true; + try + { + if (!_mutex.WaitOne(timeout)) + return false; + } + catch (AbandonedMutexException) + { + // The first instance ended without releasing the mutex (killed): it is gone all the same. + } + _mutex.ReleaseMutex(); + return true; + } + public void Dispose() { _cts.Cancel(); diff --git a/src/Filee.App/Services/UninstallCleanup.cs b/src/Filee.App/Services/UninstallCleanup.cs index 5bc5e42..e2b98ac 100644 --- a/src/Filee.App/Services/UninstallCleanup.cs +++ b/src/Filee.App/Services/UninstallCleanup.cs @@ -1,5 +1,6 @@ -// Runs from the Velopack uninstall hook: removes registry entries, the Windows 11 Explorer menu package and other -// integration Filee created for the current user. +// Runs as "Filee.exe --uninstall-cleanup" from the uninstaller (installer/Filee.iss), after the running Filee exited: +// removes registry entries, the Windows 11 Explorer menu package, downloaded engines and other integration Filee +// created for the current user. Settings in %APPDATA%\Filee stay, so reinstalling brings them back. using Filee.Platform.Windows; @@ -17,8 +18,7 @@ public static void Run() // Deletes the classic verb, Send To and the title file (which already hides the top-level entry). platform.SetContextMenu(false, "", ""); platform.SetStartWithSystem(false, ""); - // Removing the package needs no administrator rights; there is no UI for a UAC prompt here, and Velopack - // gives the hook about a minute. + // The uninstaller runs with administrator rights already, so no UAC prompt can appear here. if (ExplorerMenuRegistration.IsSupportedOs) ExplorerMenuRegistration.RemoveAsync(allowElevation: false).Wait(TimeSpan.FromSeconds(40)); // Engines downloaded later live outside the app folder (see EngineEnvironment.DownloadRoot). diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 2e1da19..bf930b7 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -9,8 +9,6 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Logging; -using Velopack; -using Velopack.Sources; namespace Filee.App.Services; @@ -21,7 +19,6 @@ public sealed partial class UpdateService : ObservableObject private readonly ILogger _log; private readonly HttpClient _http; - private UpdateManager? _manager; private DispatcherTimer? _timer; public UpdateService(ILogger log) @@ -54,23 +51,13 @@ public UpdateService(ILogger log) /// Raised on the UI thread when a check finds a newer version than before. public event EventHandler? UpdateFound; - /// False when running from source (no Velopack installation). Keeps dev builds out of the registry. - public bool IsInstalled - { - get - { - try - { - _manager ??= new UpdateManager(new GithubSource(RepositoryUrl, null, false)); - return _manager.IsInstalled; - } - catch (Exception ex) - { - _log.LogWarning(ex, "Velopack locator unavailable"); - return false; - } - } - } + /// + /// True for published builds (the installer and the portable zip are published with -p:FileeRelease=true), + /// false when running from source. Keeps development builds out of the registry. + /// + public static bool IsReleaseBuild => + typeof(UpdateService).Assembly.GetCustomAttributes() + .Any(a => a.Key == "FileeRelease" && a.Value == "true"); /// Checks the latest release now. Returns the newer version, or null when up to date or offline. public async Task CheckAsync(CancellationToken cancellationToken = default) diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 48de127..eb7efc6 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -41,14 +41,23 @@ public void ShowMain(string? page = null) } /// Opens the optional-engine choice (first run); a second call brings the open window to front. - public void ShowEngineSetup() + /// + /// Shows the engine choice. With (packages picked in the installer) the downloads start + /// right away, and nothing opens when all of them are installed already. + /// + public void ShowEngineSetup(IReadOnlyCollection? install = null) { if (_engineSetup is not null) { + if (install is not null && _engineSetup.DataContext is EngineSetupViewModel open) + open.InstallNow(install); _engineSetup.Activate(); return; } - _engineSetup = new EngineSetupWindow { DataContext = ActivatorUtilities.CreateInstance(services) }; + var vm = ActivatorUtilities.CreateInstance(services); + if (install is not null && !vm.InstallNow(install)) + return; + _engineSetup = new EngineSetupWindow { DataContext = vm }; _engineSetup.Closed += (_, _) => _engineSetup = null; _engineSetup.Show(); } diff --git a/src/Filee.App/ViewModels/EngineSetupViewModel.cs b/src/Filee.App/ViewModels/EngineSetupViewModel.cs index 453792b..da4fcec 100644 --- a/src/Filee.App/ViewModels/EngineSetupViewModel.cs +++ b/src/Filee.App/ViewModels/EngineSetupViewModel.cs @@ -1,5 +1,5 @@ -// First-run engine choice: the installer only contains the small engines, so right after installing Filee asks -// which large ones to download (like optional components in a classic setup wizard). +// Engine choice: the installer only contains the small engines. The installer's engine page hands its choice over +// (InstallNow, downloads start at once); otherwise Filee asks on first run which large ones to download. using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; @@ -20,19 +20,11 @@ public EngineSetupViewModel(EngineDownloadService downloads, ILocalizer loc) _loc = loc; foreach (var package in Packages) { - // Large downloads are offered, not pre-selected: LibreOffice is only needed for older formats (DOC, XLS, - // PPT, OpenDocument), FFmpeg (~100 MB) only for video and audio, calibre (~230 MB) only for rare e-book - // formats and Kindle output. Ghostscript is small but only needed for EPS / PostScript. - package.Selected = !package.IsInstalled - && Filee.Engines.Infrastructure.EngineDownloads.DownloadSize(package.Package) < PreselectLimit - && package.Package.Id != "ghostscript"; + package.Selected = !package.IsInstalled && Filee.Engines.Infrastructure.EngineDownloads.IsSuggested(package.Package); package.PropertyChanged += OnPackageChanged; } } - /// Packages whose download is smaller than this (bytes) are ticked on first run. - public const long PreselectLimit = 60_000_000; - public IReadOnlyList Packages => _downloads.Packages; /// Installation was started from this window (checkboxes lock, progress shows). @@ -63,11 +55,28 @@ public string SelectionText private bool CanInstall() => !Started && Packages.Any(p => p.Selected && p.CanInstall); [RelayCommand(CanExecute = nameof(CanInstall))] - private void Install() + private void Install() => Start(Packages.Where(p => p.Selected && p.CanInstall).ToList()); + + /// + /// Ticks the packages with these ids (chosen in the installer) and starts installing them. Unknown and installed + /// ids are skipped; returns false when nothing is left to install. + /// + public bool InstallNow(IReadOnlyCollection ids) + { + var wanted = Packages.Where(p => p.CanInstall && ids.Contains(p.Package.Id, StringComparer.OrdinalIgnoreCase)).ToList(); + if (wanted.Count == 0) + return false; + foreach (var package in Packages.Where(p => p.CanInstall)) + package.Selected = wanted.Contains(package) || (Started && package.Selected); + Start(wanted); + return true; + } + + private void Start(IReadOnlyList packages) { Started = true; // The service installs one package at a time; the others wait in its queue. - foreach (var package in Packages.Where(p => p.Selected && p.CanInstall)) + foreach (var package in packages) _ = _downloads.InstallAsync(package); } diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index d5ee544..445e611 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -85,7 +85,16 @@ private async Task ChangeModernMenu(bool enabled) return; ModernMenuBusy = true; ModernMenuStatus = _loc["general.modern_menu_busy"]; - var result = await _platform.SetModernContextMenuAsync(enabled, exe); + ModernContextMenuResult result; + try + { + result = await _platform.SetModernContextMenuAsync(enabled, exe); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // A command that throws would end the app (async void path): report it on the page instead. + result = new ModernContextMenuResult(false, Error: ex.Message); + } ModernMenuBusy = false; RefreshModernMenu(); if (result.Cancelled) diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 2526a20..01cb703 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -20,6 +20,18 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, [ObservableProperty] private double _holeRatio = 0.42; [ObservableProperty] private double _sliceOpacity = 0.96; + /// + /// 0 while a drag gesture waits for files, 1 otherwise. The gesture starts before anyone knows what is being + /// dragged (a file, text, or nothing: Ctrl + a rubber-band selection), so the donut only appears when files enter. + /// + [ObservableProperty] private double _donutOpacity = 1; + + /// + /// Background of the window while the donut is hidden: practically invisible, but not fully transparent, so + /// Windows still delivers the drag to this window and the files can arrive. + /// + [ObservableProperty] private string _hitBackground = "Transparent"; + /// Files the donut will convert (empty while waiting for a drag to enter). public IReadOnlyList Files { get; private set; } = []; @@ -41,6 +53,8 @@ public void ResetForDrag() Items = []; CenterTitle = loc["donut.center_drag"]; CenterSubtitle = null; + DonutOpacity = 0; + HitBackground = "#01000000"; } /// Fills the donut for the given files. @@ -48,6 +62,8 @@ public void Load(IReadOnlyList files, bool clickMode) { IsClickMode = clickMode; Files = files; + DonutOpacity = 1; + HitBackground = "Transparent"; ApplyAppearance(); var profile = ProfileSelector.Select(store.Profiles, files); diff --git a/src/Filee.App/Views/RadialWindow.axaml b/src/Filee.App/Views/RadialWindow.axaml index 5b1d9d7..6ddde95 100644 --- a/src/Filee.App/Views/RadialWindow.axaml +++ b/src/Filee.App/Views/RadialWindow.axaml @@ -18,8 +18,9 @@ ShowActivated="False" CanResize="False" SizeToContent="WidthAndHeight"> - + Total download size of a package in bytes. public static long DownloadSize(EnginePackage package) => package.Components.Sum(c => Components[c].Size); + /// Packages whose download is smaller than this (bytes) are suggested, see . + public const long SuggestLimit = 60_000_000; + + /// + /// Ticked by default in the installer and in the first-run choice. Large downloads are offered, not suggested: + /// LibreOffice is only needed for older formats (DOC, XLS, PPT, OpenDocument), FFmpeg (~100 MB) only for video and + /// audio, calibre (~230 MB) only for rare e-book formats and Kindle output. Ghostscript is small but only needed for + /// EPS / PostScript. + /// + public static bool IsSuggested(EnginePackage package) => DownloadSize(package) < SuggestLimit && package.Id != "ghostscript"; + private static Dictionary Load() { using var stream = typeof(EngineDownloads).Assembly.GetManifestResourceStream("Filee.Engines.engines.json") diff --git a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs index e3f8136..b11060e 100644 --- a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs +++ b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs @@ -282,7 +282,20 @@ public static string ShortenDeploymentError(string message) return string.Join(' ', message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } - private static unsafe string? ExternalLocationOf(string fullName) + /// The install folder the package points to, or null when Windows can't tell. + public static unsafe string? ExternalLocationOf(string fullName) + { + try + { + return ExternalLocationOfCore(fullName); + } + catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException) + { + return null; // older Windows without the API: the folder is then not compared + } + } + + private static unsafe string? ExternalLocationOfCore(string fullName) { uint length = 0; if (NativeMethods.GetPackagePathByFullName2(fullName, NativeMethods.PackagePathType_EffectiveExternal, ref length, null) diff --git a/src/Filee.Platform.Windows/NativeMethods.cs b/src/Filee.Platform.Windows/NativeMethods.cs index 592de99..1e360bb 100644 --- a/src/Filee.Platform.Windows/NativeMethods.cs +++ b/src/Filee.Platform.Windows/NativeMethods.cs @@ -58,7 +58,8 @@ public unsafe struct PACKAGE_ID [LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)] public static unsafe partial int GetPackagesByPackageFamily(string packageFamilyName, ref uint count, char** packageFullNames, ref uint bufferLength, char* buffer); - [LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)] + // Not exported by kernel32.dll (only by kernelbase.dll): import it from its API set, Windows 10 1903 and later. + [LibraryImport("api-ms-win-appmodel-runtime-l1-1-3.dll", StringMarshalling = StringMarshalling.Utf16)] public static unsafe partial int GetPackagePathByFullName2(string packageFullName, int packagePathType, ref uint pathLength, char* path); [LibraryImport("kernel32.dll")] diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index ce4a28b..5efb423 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -139,10 +139,21 @@ public void SetContextMenu(bool enabled, string executablePath, string label) CreateShortcut(SendToShortcutPath, executablePath, "--convert", label); } - public ModernContextMenuState GetModernContextMenuState(string executablePath) => - Path.GetDirectoryName(executablePath) is { Length: > 0 } folder - ? ExplorerMenuRegistration.GetState(folder) - : ModernContextMenuState.Unsupported; + public ModernContextMenuState GetModernContextMenuState(string executablePath) + { + try + { + return Path.GetDirectoryName(executablePath) is { Length: > 0 } folder + ? ExplorerMenuRegistration.GetState(folder) + : ModernContextMenuState.Unsupported; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Asking Windows about packages must never take the app or the settings page down. + Trace.TraceWarning($"Reading the Explorer menu state failed: {ex}"); + return ModernContextMenuState.Unsupported; + } + } public Task SetModernContextMenuAsync(bool enabled, string executablePath) => Task.Run(async () => { @@ -150,9 +161,17 @@ public Task SetModernContextMenuAsync(bool enabled, str if (string.IsNullOrEmpty(folder)) return new ModernContextMenuResult(false, Error: "Unknown install folder."); - var result = enabled - ? await ExplorerMenuRegistration.AddAsync(folder) - : await ExplorerMenuRegistration.RemoveAsync(allowElevation: true); + ModernContextMenuResult result; + try + { + result = enabled + ? await ExplorerMenuRegistration.AddAsync(folder) + : await ExplorerMenuRegistration.RemoveAsync(allowElevation: true); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + result = new ModernContextMenuResult(false, Error: ex.Message); + } // Keep exactly one entry: the title file exists while the Explorer menu setting is on. if (ExplorerMenuRegistration.ReadTitle() is { Length: > 0 } title) diff --git a/tests/Filee.App.Tests/ExplorerMenuTests.cs b/tests/Filee.App.Tests/ExplorerMenuTests.cs index 8daf4eb..c6ed47d 100644 --- a/tests/Filee.App.Tests/ExplorerMenuTests.cs +++ b/tests/Filee.App.Tests/ExplorerMenuTests.cs @@ -90,6 +90,35 @@ public void Family_name_matches_what_Windows_computes_from_the_publisher() Assert.Equal(ExplorerMenuPackage.FamilyName, ExplorerMenuRegistration.ComputeFamilyName()); } + [Fact] + public void Every_native_import_exists_in_the_dll_it_names() + { + // GetPackagePathByFullName2 was imported from kernel32.dll, which doesn't export it: the call only failed + // once a package was registered and crashed the app right after "Add to the main menu" (1.1.0). + var nativeMethods = typeof(ExplorerMenuRegistration).Assembly.GetType("Filee.Platform.Windows.NativeMethods", throwOnError: true)!; + var imports = nativeMethods.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic) + .Select(m => (Method: m, Import: m.GetCustomAttributes(typeof(LibraryImportAttribute), false).OfType().FirstOrDefault())) + .Where(m => m.Import is not null) + .ToList(); + Assert.NotEmpty(imports); + Assert.All(imports, m => + { + var entryPoint = m.Import!.EntryPoint ?? m.Method.Name; + Assert.True(NativeLibrary.TryLoad(m.Import.LibraryName, out var library), $"{m.Import.LibraryName} can't be loaded"); + Assert.True(NativeLibrary.TryGetExport(library, entryPoint, out _), $"{m.Import.LibraryName} has no {entryPoint}"); + }); + } + + [Fact] + public void Reading_the_registered_package_never_throws() + { + Assert.Null(ExplorerMenuRegistration.ExternalLocationOf("Filee.NotInstalled_1.0.0.0_x64__0000000000000")); + // Whatever is registered on this machine (nothing on CI): a registered sparse package has an external location. + if (ExplorerMenuRegistration.Find() is { } package) + Assert.False(string.IsNullOrEmpty(package.ExternalLocation), package.FullName); + _ = ExplorerMenuRegistration.GetState(BuiltFolder); + } + [Fact] public void PowerShell_literals_escape_straight_and_typographic_single_quotes() { diff --git a/tests/Filee.App.Tests/InstallerTests.cs b/tests/Filee.App.Tests/InstallerTests.cs new file mode 100644 index 0000000..591e7e0 --- /dev/null +++ b/tests/Filee.App.Tests/InstallerTests.cs @@ -0,0 +1,94 @@ +// Keeps the installer script (installer/Filee.iss) and the app in step: the options Setup starts Filee with, the +// Explorer menu package it registers and the engine folders it looks into. + +using System.Reflection; +using System.Text.RegularExpressions; +using Avalonia.Headless.XUnit; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.Core.Localization; +using Filee.Engines.Infrastructure; +using Filee.Platform.Windows; + +namespace Filee.App.Tests; + +public class InstallerTests +{ + private static string Script + { + get + { + var dir = AppContext.BaseDirectory; + while (!File.Exists(Path.Combine(dir, "Filee.slnx"))) + dir = Path.GetDirectoryName(dir) ?? throw new InvalidOperationException("Repository root not found."); + return File.ReadAllText(Path.Combine(dir, "installer", "Filee.iss")); + } + } + + [Fact] + public void Script_passes_only_options_the_app_knows() + { + // Each option below is checked by a parse test; a new one in the script must be added here. + var used = Regex.Matches(Script, @"--[a-z][a-z-]+=?").Select(m => m.Value).ToHashSet(); + Assert.Equal( + new HashSet { "--background", "--quit", "--uninstall-cleanup", "--install-engines=", "--start-with-windows=", "--context-menu=" }, + used); + } + + [Fact] + public void Installer_choices_are_parsed() + { + var options = CommandLine.Parse(["--install-engines=pandoc, FFmpeg,pandoc", "--start-with-windows=off", "--context-menu=on"]); + Assert.Equal(["pandoc", "ffmpeg"], options.InstallEngines); + Assert.False(options.StartWithWindows); + Assert.True(options.ContextMenu); + Assert.False(options.Background); + Assert.Empty(options.ConvertFiles); + + // Picking no engine is an answer too (the first-run question is skipped), unlike not passing the option. + Assert.Empty(CommandLine.Parse(["--install-engines="]).InstallEngines!); + Assert.Null(CommandLine.Parse(["--background"]).InstallEngines); + Assert.Null(CommandLine.Parse(["--start-with-windows=maybe"]).StartWithWindows); + Assert.Null(CommandLine.Parse([]).ContextMenu); + } + + [Fact] + public void Quit_and_cleanup_are_recognised() + { + Assert.True(CommandLine.Parse(["--quit"]).Quit); + Assert.True(CommandLine.Parse(["--Uninstall-Cleanup"]).UninstallCleanup); + Assert.False(CommandLine.Parse(["--background"]).Quit); + } + + [AvaloniaFact] + public void Engines_from_the_installer_start_only_when_something_is_left_to_install() + { + TestServices.EnsureInitialized("en"); + var vm = new EngineSetupViewModel(AppHost.Get(), AppHost.Get()); + Assert.False(vm.InstallNow(["no-such-engine"])); + Assert.False(vm.InstallNow([])); + Assert.False(vm.Started); + } + + [Fact] + public void Script_uses_the_explorer_menu_package_identity() + { + var script = Script; + Assert.Contains($"ExplorerMenuName = '{ExplorerMenuPackage.PackageName}';", script); + Assert.Contains($"ExplorerMenuFamily = '{ExplorerMenuPackage.FamilyName}';", script); + Assert.Contains($"ExplorerMenuDll = '{ExplorerMenuPackage.DllFileName}';", script); + Assert.Contains($"ExplorerMenuPackageFile = '{ExplorerMenuPackage.PackageFileName}';", script); + Assert.Contains($"ExplorerMenuMinimumBuild = {ExplorerMenuPackage.MinimumBuild};", script); + } + + [Fact] + public void Script_finds_downloaded_engines_where_the_app_puts_them() + { + var marker = typeof(EngineInstaller).GetField("MarkerName", BindingFlags.NonPublic | BindingFlags.Static)?.GetRawConstantValue(); + Assert.Contains($"EngineMarker = '{marker}';", Script); + Assert.Equal( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Filee", "engines"), + EngineEnvironment.DownloadRoot); + Assert.Contains(@"ExpandConstant('{localappdata}\Filee\engines\')", Script); + } +} diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index ed6d66c..7e3d8d3 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -106,7 +106,7 @@ public void First_run_engine_setup_renders(string language) // Everything not installed is offered; small downloads are pre-selected, the large LibreOffice, FFmpeg and calibre and the // EPS-only Ghostscript not. Assert.All(vm.Packages.Where(p => !p.IsInstalled), p => Assert.Equal( - Filee.Engines.Infrastructure.EngineDownloads.DownloadSize(p.Package) < EngineSetupViewModel.PreselectLimit + Filee.Engines.Infrastructure.EngineDownloads.DownloadSize(p.Package) < Filee.Engines.Infrastructure.EngineDownloads.SuggestLimit && p.Package.Id != "ghostscript", p.Selected)); Assert.DoesNotContain(vm.Packages, p => p.Package.Id is "libreoffice" or "ffmpeg" or "calibre" or "ghostscript" && p.Selected); Assert.Equal(vm.Packages.Any(p => p.Selected && p.CanInstall), vm.InstallCommand.CanExecute(null)); @@ -125,7 +125,11 @@ public void Donut_toolbar_waits_for_files_in_drag_mode() Pump(); Assert.Empty(vm.Items); + // Nothing shows until files are dragged in (a Ctrl + drag without files must not open anything). + Assert.Equal(0, vm.DonutOpacity); Save(window, "donut-drag-waiting.png"); + vm.Load([@"C:\photos\a.jpg"], clickMode: false); + Assert.Equal(1, vm.DonutOpacity); window.Close(); }