From 5ac47eafb2b566deba57ac1f515b45452640bc3c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 1 Oct 2026 23:50:30 +0900 Subject: [PATCH 1/3] Fix the crash after adding the Windows 11 menu entry GetPackagePathByFullName2 was imported from kernel32.dll, which doesn't export it. The call only happens once the package is registered, so adding the entry crashed Filee right after Windows accepted it, and the General page threw on every later visit. It is now imported from its API set, the package state and menu commands never take the app down, and a test checks that every native import exists in the DLL it names. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/Pages/GeneralPageViewModel.cs | 11 ++++++- .../ExplorerMenuRegistration.cs | 15 ++++++++- src/Filee.Platform.Windows/NativeMethods.cs | 3 +- .../WindowsPlatformServices.cs | 33 +++++++++++++++---- tests/Filee.App.Tests/ExplorerMenuTests.cs | 29 ++++++++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) 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.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() { From fbc679d4bdfe5c6c7bffa3fbdba2d6e8af5baf9b Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 1 Oct 2026 23:50:31 +0900 Subject: [PATCH 2/3] Don't show the donut for a drag gesture without files The gesture is detected before anyone knows what is dragged, so a modifier + drag of text or a rubber-band selection opened an empty ring. The window now waits invisibly at the cursor and the donut appears only when files enter; otherwise it closes quietly when the button is released. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Services/RadialController.cs | 12 ++++++++---- src/Filee.App/ViewModels/RadialViewModel.cs | 16 ++++++++++++++++ src/Filee.App/Views/RadialWindow.axaml | 3 ++- tests/Filee.App.Tests/RenderTests.cs | 4 ++++ 4 files changed, 30 insertions(+), 5 deletions(-) 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/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"> - + Date: Fri, 2 Oct 2026 00:39:38 +0900 Subject: [PATCH 3/3] Replace Velopack with an administrator installer (Inno Setup) Filee--win-Setup.exe is now built from installer/Filee.iss with a pinned, portable Inno Setup 7 (build/build-installer.ps1; nothing is installed on the build machine). It installs for all users into Program Files with one UAC prompt, while Filee itself keeps running unelevated as the signed-in user. - Options page: "Convert with Filee" in the Explorer menu, on Windows 11 also at the top of the menu (Setup registers the sparse package with the rights it already has), start at sign-in, desktop icon. - Engine page: the optional engines with download and installed sizes, generated from EngineDownloads and the app's translations. Filee downloads the picked ones in its engine window when Setup starts it (--install-engines), so verification and progress stay in one place. - Updates close Filee gracefully (--quit) and move a FileeExplorerMenu.dll that Explorer has loaded aside, so no restart is needed; the uninstaller runs --uninstall-cleanup. - An installation of Filee 1.1 or earlier (Velopack, per user) is taken over: its files, shortcuts and uninstall entry are removed, settings and downloaded engines are kept. - The portable zip is now a plain folder; SHA-256 sums are published with the release. - Release builds are marked with -p:FileeRelease=true instead of asking Velopack whether they are installed. - CI compiles the installer script against a stub app. Version 1.2.0. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 63 +-- CONTRIBUTING.md | 12 +- Directory.Build.props | 2 +- Directory.Packages.props | 1 - README.ko.md | 13 +- README.md | 19 +- README.zh-CN.md | 11 +- THIRD-PARTY-NOTICES.md | 10 +- build/build-installer.ps1 | 145 +++++ build/tools/make-installer-engines.cs | 90 +++ build/tools/make-installer-images.cs | 33 ++ docs/ARCHITECTURE.md | 21 +- installer/Filee.iss | 525 ++++++++++++++++++ installer/wizard-dark.png | Bin 0 -> 15475 bytes installer/wizard-light.png | Bin 0 -> 15130 bytes src/Filee.App/App.axaml.cs | 38 +- src/Filee.App/Filee.App.csproj | 7 +- src/Filee.App/Program.cs | 69 ++- src/Filee.App/Services/CommandLine.cs | 63 ++- src/Filee.App/Services/SingleInstance.cs | 21 + src/Filee.App/Services/UninstallCleanup.cs | 8 +- src/Filee.App/Services/UpdateService.cs | 27 +- src/Filee.App/Services/WindowService.cs | 13 +- .../ViewModels/EngineSetupViewModel.cs | 35 +- .../Infrastructure/EngineDownloads.cs | 11 + tests/Filee.App.Tests/InstallerTests.cs | 94 ++++ tests/Filee.App.Tests/RenderTests.cs | 2 +- 28 files changed, 1198 insertions(+), 140 deletions(-) create mode 100644 build/build-installer.ps1 create mode 100644 build/tools/make-installer-engines.cs create mode 100644 build/tools/make-installer-images.cs create mode 100644 installer/Filee.iss create mode 100644 installer/wizard-dark.png create mode 100644 installer/wizard-light.png create mode 100644 tests/Filee.App.Tests/InstallerTests.cs 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 0000000000000000000000000000000000000000..6c9f122a6642c0c72edaeb485305d8a114939679 GIT binary patch literal 15475 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 U3rD8F%ft*CDmuy-#V2q72fy3}OaK4? literal 0 HcmV?d00001 diff --git a/installer/wizard-light.png b/installer/wizard-light.png new file mode 100644 index 0000000000000000000000000000000000000000..5aafddf11b59439695b115e155f668dbc9cb9637 GIT binary patch literal 15130 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 literal 0 HcmV?d00001 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/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.Engines/Infrastructure/EngineDownloads.cs b/src/Filee.Engines/Infrastructure/EngineDownloads.cs index 45fbd3a..35bdca9 100644 --- a/src/Filee.Engines/Infrastructure/EngineDownloads.cs +++ b/src/Filee.Engines/Infrastructure/EngineDownloads.cs @@ -40,6 +40,17 @@ public static class EngineDownloads /// 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/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 619435f..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));