From 98093657a9b8bd884737a70daa27c9dd1d248198 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Tue, 6 Oct 2026 14:06:27 +0900
Subject: [PATCH 01/16] WIP: Linux and macOS support (backup, not finished)
Work in progress kept on its own branch to continue later: Linux and macOS platform projects, portable builds
and their workflow, install scripts, system integration service and tests.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/ci.yml | 3 +
.github/workflows/portable.yml | 174 ++++++++++++++
.github/workflows/release.yml | 13 +
CONTRIBUTING.md | 42 +++-
Directory.Packages.props | 2 +
Filee.slnx | 3 +
README.ko.md | 3 +-
README.md | 3 +-
README.zh-CN.md | 3 +-
build/build-portable.ps1 | 120 ++++++++++
build/fetch-portable-engines.ps1 | 73 ++++++
installer/linux/INSTALL.txt | 29 +++
installer/linux/install.sh | 52 ++++
installer/linux/uninstall.sh | 26 ++
installer/macos/INSTALL.txt | 25 ++
installer/macos/Info.plist | 19 ++
installer/macos/entitlements.plist | 8 +
installer/macos/install.sh | 20 ++
installer/macos/uninstall.sh | 25 ++
src/Filee.App/App.axaml.cs | 54 ++---
src/Filee.App/Assets/i18n/en.json | 15 ++
src/Filee.App/Assets/i18n/ko.json | 15 ++
src/Filee.App/Assets/i18n/zh-CN.json | 15 ++
src/Filee.App/Filee.App.csproj | 2 +
src/Filee.App/Program.cs | 4 +-
src/Filee.App/Services/AppHost.cs | 8 +-
src/Filee.App/Services/CommandLine.cs | 4 +-
.../Services/EngineDownloadService.cs | 13 +-
src/Filee.App/Services/RadialController.cs | 4 +-
src/Filee.App/Services/SingleInstance.cs | 10 +-
.../Services/SystemIntegrationService.cs | 64 +++++
.../Services/Triggers/TriggerService.cs | 20 +-
src/Filee.App/Services/UninstallCleanup.cs | 59 +++--
.../ViewModels/EngineSetupViewModel.cs | 2 +-
.../ViewModels/Pages/GeneralPageViewModel.cs | 11 +-
.../ViewModels/Pages/TriggersPageViewModel.cs | 66 ++++-
src/Filee.App/Views/EngineSetupWindow.axaml | 2 +
src/Filee.App/Views/Pages/EnginesPage.axaml | 2 +
.../Views/Pages/EnginesPage.axaml.cs | 26 +-
src/Filee.App/Views/Pages/GeneralPage.axaml | 4 +-
src/Filee.App/Views/Pages/TriggersPage.axaml | 13 +
src/Filee.Cli/Cli.cs | 3 +-
src/Filee.Core/Platform/FileSystemPaths.cs | 11 +
src/Filee.Core/Platform/IPlatformServices.cs | 18 +-
src/Filee.Core/Presets/OutputPathResolver.cs | 5 +-
src/Filee.Core/Watching/FolderWatcher.cs | 11 +-
src/Filee.Engines/Ebooks/CalibreConverter.cs | 4 +-
src/Filee.Engines/Filee.Engines.csproj | 2 +
.../Infrastructure/CopyVersions.cs | 6 +-
.../Infrastructure/EngineDownloads.cs | 55 ++++-
.../Infrastructure/EngineEnvironment.cs | 85 +++++--
.../Infrastructure/EngineInstaller.cs | 95 +++++++-
src/Filee.Engines/Infrastructure/engines.json | 158 ++++++++++++
src/Filee.Engines/Media/FfmpegConverter.cs | 21 +-
.../Office/LibreOfficeConverter.cs | 15 +-
src/Filee.Engines/Office/PandocConverter.cs | 6 +-
.../Vector/GhostscriptConverter.cs | 4 +-
.../Filee.Platform.Linux.csproj | 9 +
.../LinuxDesktopIntegration.cs | 226 ++++++++++++++++++
.../LinuxPlatformServices.cs | 111 +++++++++
src/Filee.Platform.Linux/X11WindowSystem.cs | 179 ++++++++++++++
.../Filee.Platform.MacOS.csproj | 9 +
.../MacOSIntegrationFiles.cs | 89 +++++++
.../MacOSPlatformServices.cs | 149 ++++++++++++
src/Filee.Platform.MacOS/NativeMethods.cs | 158 ++++++++++++
tests/Filee.App.Tests/InstallerTests.cs | 1 +
.../Filee.App.Tests/SystemIntegrationTests.cs | 94 ++++++++
tests/Filee.Cli.Tests/CliTests.cs | 46 ++++
tests/Filee.Core.Tests/FolderWatcherTests.cs | 60 +++++
.../OutputPathResolverTests.cs | 17 ++
.../EngineInstallerTests.cs | 61 ++++-
.../LibreOfficeFormatTests.cs | 4 +-
tests/Filee.Engines.Tests/OwnCopyTests.cs | 28 +--
.../PortableEngineTests.cs | 136 +++++++++++
.../Filee.Platform.Tests.csproj | 15 ++
.../LinuxDesktopIntegrationTests.cs | 122 ++++++++++
.../LinuxNativeIntegrationTests.cs | 121 ++++++++++
.../MacOSIntegrationTests.cs | 73 ++++++
.../Filee.Platform.Tests/MacOSNativeTests.cs | 22 ++
79 files changed, 3103 insertions(+), 182 deletions(-)
create mode 100644 .github/workflows/portable.yml
create mode 100644 build/build-portable.ps1
create mode 100644 build/fetch-portable-engines.ps1
create mode 100644 installer/linux/INSTALL.txt
create mode 100644 installer/linux/install.sh
create mode 100644 installer/linux/uninstall.sh
create mode 100644 installer/macos/INSTALL.txt
create mode 100644 installer/macos/Info.plist
create mode 100644 installer/macos/entitlements.plist
create mode 100644 installer/macos/install.sh
create mode 100644 installer/macos/uninstall.sh
create mode 100644 src/Filee.App/Services/SystemIntegrationService.cs
create mode 100644 src/Filee.Core/Platform/FileSystemPaths.cs
create mode 100644 src/Filee.Platform.Linux/Filee.Platform.Linux.csproj
create mode 100644 src/Filee.Platform.Linux/LinuxDesktopIntegration.cs
create mode 100644 src/Filee.Platform.Linux/LinuxPlatformServices.cs
create mode 100644 src/Filee.Platform.Linux/X11WindowSystem.cs
create mode 100644 src/Filee.Platform.MacOS/Filee.Platform.MacOS.csproj
create mode 100644 src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
create mode 100644 src/Filee.Platform.MacOS/MacOSPlatformServices.cs
create mode 100644 src/Filee.Platform.MacOS/NativeMethods.cs
create mode 100644 tests/Filee.App.Tests/SystemIntegrationTests.cs
create mode 100644 tests/Filee.Engines.Tests/PortableEngineTests.cs
create mode 100644 tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj
create mode 100644 tests/Filee.Platform.Tests/LinuxDesktopIntegrationTests.cs
create mode 100644 tests/Filee.Platform.Tests/LinuxNativeIntegrationTests.cs
create mode 100644 tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
create mode 100644 tests/Filee.Platform.Tests/MacOSNativeTests.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2da2c4e..338e864 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,9 @@ concurrency:
cancel-in-progress: true
jobs:
+ portable:
+ uses: ./.github/workflows/portable.yml
+
build:
runs-on: windows-latest
steps:
diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml
new file mode 100644
index 0000000..7056a72
--- /dev/null
+++ b/.github/workflows/portable.yml
@@ -0,0 +1,174 @@
+name: Linux and macOS packages
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ type: string
+ default: ''
+ required: false
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Package version (empty uses Directory.Build.props)'
+ type: string
+ required: false
+
+permissions:
+ contents: read
+
+jobs:
+ portable:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ rid: linux-x64
+ - runner: ubuntu-24.04-arm
+ rid: linux-arm64
+ - runner: macos-15
+ rid: osx-arm64
+ - runner: macos-15-intel
+ rid: osx-x64
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 40
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Linux desktop dependencies
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g \
+ libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 xvfb fonts-noto-cjk
+
+ - name: Fonts
+ run: ./build/fetch-fonts.ps1
+
+ - name: Restore engine downloads
+ uses: actions/cache@v4
+ with:
+ path: build/.cache/portable-downloads
+ key: portable-engines-${{ matrix.rid }}-${{ hashFiles('src/Filee.Engines/Infrastructure/engines.json') }}
+
+ - name: Build and test native projects
+ run: |
+ ./build/fetch-portable-engines.ps1 -Runtime '${{ matrix.rid }}' -Destination engines
+ dotnet build Filee.slnx -c Release
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ $projects = @('Filee.Core.Tests', 'Filee.Platform.Tests', 'Filee.Engines.Tests', 'Filee.Cli.Tests', 'Filee.App.Tests')
+ foreach ($project in $projects) {
+ $testArguments = @('test', "tests/$project/$project.csproj", '--no-build', '-c', 'Release', '--logger', 'trx', '--results-directory', "TestResults/${{ matrix.rid }}",
+ '--filter', 'FullyQualifiedName!=Filee.App.Tests.ExplorerMenuTests.Every_native_import_exists_in_the_dll_it_names&FullyQualifiedName!=Filee.App.Tests.ExplorerMenuTests.Reading_the_registered_package_never_throws')
+ if ($IsLinux) {
+ xvfb-run -a env -u WAYLAND_DISPLAY XDG_SESSION_TYPE=x11 dotnet @testArguments
+ } else {
+ dotnet @testArguments
+ }
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
+
+ - name: Build portable package
+ env:
+ PACKAGE_VERSION: ${{ inputs.version }}
+ run: ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts
+
+ - name: Check archive and run native converters
+ run: |
+ $archives = @(Get-ChildItem Releases -Filter '*-${{ matrix.rid }}.tar.gz')
+ if ($archives.Count -ne 1) { throw 'Expected one portable archive.' }
+ $archive = $archives[0]
+ $expected = (Get-Content (Join-Path Releases ($archive.Name.Replace('.tar.gz', '-SHA256SUMS.txt')))).Split(' ')[0]
+ if ((Get-FileHash $archive.FullName -Algorithm SHA256).Hash -ne $expected) { throw 'Archive checksum mismatch.' }
+ $smoke = Join-Path $env:RUNNER_TEMP 'filee-smoke'
+ New-Item -ItemType Directory -Path $smoke | Out-Null
+ tar -xzf $archive.FullName -C $smoke
+ if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed.' }
+ $package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', ''))
+ if ($IsMacOS) {
+ codesign --verify --deep --strict (Join-Path $package 'Filee.app')
+ if ($LASTEXITCODE -ne 0) { throw 'Extracted app signature did not verify.' }
+ }
+ $bin = if ($IsMacOS) { Join-Path $package 'Filee.app/Contents/MacOS' } else { $package }
+ $cli = Join-Path $bin 'filee-cli'
+ & $cli version
+ if ($LASTEXITCODE -ne 0) { throw 'CLI did not start.' }
+ & (Join-Path $bin 'engines/rhwp/rhwp') --version
+ if ($LASTEXITCODE -ne 0) { throw 'rhwp did not start.' }
+ & (Join-Path $bin 'engines/7zip/7zz') i | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw '7-Zip did not start.' }
+ $inputFile = Join-Path $smoke 'sample.txt'
+ 'Filee 한글 conversion smoke test' | Set-Content $inputFile -Encoding utf8NoBOM
+ & $cli convert $inputFile --to hwpx -o (Join-Path $smoke 'converted') --json
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path (Join-Path $smoke 'converted/sample.hwpx'))) { throw 'Published CLI conversion failed.' }
+ "FILEE_SMOKE_APP=$(Join-Path $bin 'Filee')" >> $env:GITHUB_ENV
+
+ - name: Launch native desktop app
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 - <<'PY'
+ import os
+ import pathlib
+ import signal
+ import subprocess
+ import sys
+ import time
+
+ app = os.environ['FILEE_SMOKE_APP']
+ command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off']
+ if os.environ['RUNNER_OS'] == 'Linux':
+ command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command]
+ log_path = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-startup.log'
+ with log_path.open('w') as log:
+ process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
+ try:
+ time.sleep(8)
+ if process.poll() is not None:
+ raise RuntimeError('The desktop app exited before the startup check.')
+ quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])'
+ subprocess.run([sys.executable, '-c', quit_code, app], check=True, timeout=30)
+ if process.wait(timeout=15) != 0:
+ raise RuntimeError('The desktop app did not exit cleanly.')
+ except BaseException:
+ log.flush()
+ print(log_path.read_text(errors='replace'), file=sys.stderr)
+ raise
+ finally:
+ if process.poll() is None:
+ os.killpg(process.pid, signal.SIGTERM)
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ PY
+
+ - name: Upload packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: portable-${{ matrix.rid }}
+ path: |
+ Releases/*-${{ matrix.rid }}.tar.gz
+ Releases/*-${{ matrix.rid }}-SHA256SUMS.txt
+ if-no-files-found: error
+
+ - name: Upload test diagnostics
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: unix-test-results-${{ matrix.rid }}
+ path: |
+ TestResults/${{ matrix.rid }}/
+ tests/Filee.App.Tests/bin/Release/net10.0/screenshots/
+ ${{ runner.temp }}/filee-startup.log
+ if-no-files-found: ignore
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aff3eb6..cdff756 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,13 @@ permissions:
contents: write
jobs:
+ portable:
+ uses: ./.github/workflows/portable.yml
+ with:
+ version: ${{ github.ref_name }}
+
windows:
+ needs: portable
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
@@ -64,6 +70,13 @@ jobs:
shell: pwsh
run: ./build/build-installer.ps1 -Version ${{ steps.version.outputs.value }} -Portable
+ - name: Download Linux and macOS packages
+ uses: actions/download-artifact@v4
+ with:
+ pattern: portable-*
+ merge-multiple: true
+ path: Releases
+
- name: Publish the GitHub release
shell: pwsh
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 615952d..de9d21c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,11 +11,51 @@ Thanks for helping! Filee is built to be easy to extend: most contributions touc
DOCX. XLSX ↔ CSV and anything → HWPX work without either). Without `-Only` it also downloads LibreOffice with
H2Orestart and Java (~420 MB), which only older formats such as DOC, XLS and PPT need. Engines in the
repository's `engines/` folder are picked up by debug builds; users download the large ones from the app.
- Filee never uses programs installed on the system (Microsoft Office, a system LibreOffice, tools on `PATH`).
+ Filee can also discover supported engines on `PATH` and in their standard installation folders. Settings >
+ Engines lets you choose a specific copy. Microsoft Office is not used.
4. `dotnet run --project src/Filee.App`, `dotnet test`.
Any IDE works (Visual Studio 2022+, Rider, VS Code with C# Dev Kit).
+## Linux and macOS development
+
+These ports are development builds until native desktop tests are complete. Install .NET 10 and PowerShell 7,
+then build on the target OS. Supported package targets are `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`.
+
+```sh
+pwsh build/fetch-fonts.ps1
+pwsh build/fetch-portable-engines.ps1 -Runtime linux-x64 -Destination engines
+dotnet test Filee.slnx -c Release
+pwsh build/build-portable.ps1 -Runtime linux-x64
+```
+
+Use the matching runtime on a Mac or ARM64 Linux machine. Packaging runs on Linux/macOS so archives retain
+executable permissions. Each archive contains the app, CLI, rhwp, 7-Zip, installation instructions and per-user
+install/uninstall scripts. `Filee---SHA256SUMS.txt` verifies the archive. macOS bundles use
+`com.filee.app` and a development ad-hoc signature by default; public distribution still needs Developer ID signing
+and notarization. `-SigningIdentity` can supply a signing identity, but does not perform notarization.
+
+- **Linux:** drag gestures require X11. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11,
+ Xrandr, Xi, Xtst, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for
+ Thunar, Nautilus, Nemo and Dolphin. The first enabled keyboard shortcut uses Thunar's native custom action;
+ close Thunar before changing or pausing that shortcut and reopen it afterwards. Global selected-file queries
+ and hold gestures are not supported. Wayland can use file actions, the drop zone, watch folders and CLI.
+- **macOS:** grant Filee Accessibility permission for gestures and Finder Automation permission for selection.
+ Keep the development `.app` at a stable path while testing these permissions. Finder integration is a Quick
+ Action under Services; auto-start uses a per-user LaunchAgent.
+- **Engines:** packages are selected by OS and architecture. Linux LibreOffice/Ghostscript and macOS
+ FFmpeg/Ghostscript currently use system or user-selected installations. Linux ARM64 has no Unhwp native binary.
+ The macOS LibreOffice package does not include H2Orestart; native rhwp/Unhwp provide the supported HWP routes.
+ Use Settings > Engines to inspect actual availability before comparing formats.
+
+The reusable `portable.yml` workflow builds and tests all four targets. Native platform tests skip on other OSes.
+Before calling a port ready, verify the installed package, CLI conversion, Finder/Thunar selection with Unicode
+and spaced paths, drag and keyboard gestures, permissions denied/granted, Retina/scaling, tray, login startup,
+engine downloads, watch folders, and uninstall. CI GUI startup alone does not validate these interactions.
+
+The website lives in the adjacent `../filee-web` repository. Its downloads must match real release assets and
+keep development status visible until native validation is complete.
+
## Where things live
Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) first. In short:
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5ca2f58..5ba27f9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -35,6 +35,8 @@
+
+
diff --git a/Filee.slnx b/Filee.slnx
index 8cd8f7f..01b8940 100644
--- a/Filee.slnx
+++ b/Filee.slnx
@@ -3,6 +3,8 @@
+
+
@@ -11,6 +13,7 @@
+
diff --git a/README.ko.md b/README.ko.md
index 5f2ece1..64088dd 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -57,7 +57,8 @@
업데이트한 다음 다시 켜 주고, 설정과 엔진은 그대로 남아요. 휴대용 버전은 대신 릴리스 페이지가 열려요. 사용자별로
설치된 Filee 1.1 이하도 새 설치 파일이 넘겨받아요.
-macOS 지원을 계획 중이고, Linux 지원도 검토하고 있어요.
+Linux와 macOS 버전을 개발 중이며 실제 데스크톱 검증을 진행하고 있어요. 현재 안정 버전 다운로드는 Windows용이에요.
+개발 빌드는 [Unix 빌드 안내](CONTRIBUTING.md#linux-and-macos-development)를 참고해 주세요.
## 기능
diff --git a/README.md b/README.md
index 585241a..08c1b23 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,8 @@ administrator rights once and installs Filee for all users in Program Files. Pre
starts it again. The portable copy opens the release page instead. Filee 1.1 and earlier (installed per user) are
taken over by the new installer too.
-macOS support is planned, and Linux is being considered.
+Linux and macOS ports are in development. Native desktop validation is still in progress; the Windows release
+remains the stable download. See [the Unix build instructions](CONTRIBUTING.md#linux-and-macos-development).
## Features
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a85368b..3743c86 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -55,7 +55,8 @@ Program Files。不想安装?把 `Filee-<版本>-win-Portable.zip` 解压到
用发布页的 SHA-256 校验文件核对,再经过一次管理员确认后运行。安装程序会关闭 Filee、完成更新并重新启动它,设置和引擎都会保留。
便携版则会打开发布页面。按用户安装的 Filee 1.1 及更早版本也会由新的安装程序接管。
-macOS 版本正在计划中,也在考虑支持 Linux。
+Linux 和 macOS 版本正在开发,实际桌面验证尚未完成。当前稳定版下载仍为 Windows 版。
+开发构建请参阅 [Unix 构建说明](CONTRIBUTING.md#linux-and-macos-development)。
## 功能
diff --git a/build/build-portable.ps1 b/build/build-portable.ps1
new file mode 100644
index 0000000..f82317a
--- /dev/null
+++ b/build/build-portable.ps1
@@ -0,0 +1,120 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime,
+ [string]$Version,
+ [string]$OutputDir = (Join-Path $PSScriptRoot '../Releases'),
+ [string]$SigningIdentity = '-',
+ [switch]$SkipFonts
+)
+
+$ErrorActionPreference = 'Stop'
+$root = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
+if ($IsWindows) { throw 'Create Unix packages on Linux or macOS to preserve executable permissions and sign macOS binaries.' }
+if (-not $Runtime) {
+ $cpu = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant()
+ $Runtime = $(if ($IsMacOS) { 'osx' } else { 'linux' }) + "-$cpu"
+}
+if (($Runtime.StartsWith('osx-') -and -not $IsMacOS) -or ($Runtime.StartsWith('linux-') -and -not $IsLinux)) {
+ throw "Build $Runtime on its native operating system."
+}
+if (-not $Version) {
+ $Version = ([xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version |
+ Where-Object { $_ } | Select-Object -First 1
+}
+$Version = $Version.TrimStart('v')
+if ($Version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$') {
+ throw 'Version must be a three-part version with an optional prerelease suffix.'
+}
+$bundleVersion = ($Version -split '-')[0]
+$packageName = "Filee-$Version-$Runtime"
+New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
+$OutputDir = (Resolve-Path -LiteralPath $OutputDir).Path
+$archive = Join-Path $OutputDir "$packageName.tar.gz"
+$sums = Join-Path $OutputDir "$packageName-SHA256SUMS.txt"
+if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $sums)) {
+ throw "Package $packageName already exists. Use another output folder or version."
+}
+$stage = Join-Path $root ('build/.cache/portable/' + [Guid]::NewGuid().ToString('N'))
+$package = Join-Path $stage $packageName
+$appBundle = Join-Path $package 'Filee.app'
+$publish = if ($Runtime.StartsWith('osx-')) { Join-Path $appBundle 'Contents/MacOS' } else { $package }
+New-Item -ItemType Directory -Force -Path $publish | Out-Null
+
+function Invoke-Checked([string]$name, [scriptblock]$command) {
+ & $command
+ if ($LASTEXITCODE -ne 0) { throw "$name failed with exit code $LASTEXITCODE" }
+}
+
+if (-not $SkipFonts) { & (Join-Path $PSScriptRoot 'fetch-fonts.ps1') }
+foreach ($project in @('Filee.App', 'Filee.Cli')) {
+ Invoke-Checked "Publish $project" {
+ dotnet publish (Join-Path $root "src/$project/$project.csproj") -c Release -r $Runtime --self-contained true `
+ "-p:Version=$Version" -p:FileeRelease=true -p:PublishReadyToRun=false -o $publish
+ }
+}
+& (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination (Join-Path $publish 'engines')
+foreach ($name in @('Filee', 'filee-cli')) {
+ $executable = Join-Path $publish $name
+ if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw "Publish did not produce $name" }
+ Invoke-Checked 'Executable permissions' { chmod +x $executable }
+}
+foreach ($notice in @('LICENSE', 'THIRD-PARTY-NOTICES.md')) {
+ if (Test-Path -LiteralPath (Join-Path $root $notice)) {
+ Copy-Item -LiteralPath (Join-Path $root $notice) -Destination $package
+ }
+}
+
+if ($Runtime.StartsWith('osx-')) {
+ $contents = Join-Path $appBundle 'Contents'
+ $resources = Join-Path $contents 'Resources'
+ New-Item -ItemType Directory -Path $resources | Out-Null
+ $info = Get-Content -LiteralPath (Join-Path $root 'installer/macos/Info.plist') -Raw
+ $info.Replace('__VERSION__', $bundleVersion).Replace('__BUNDLE_VERSION__', $bundleVersion) |
+ Set-Content -LiteralPath (Join-Path $contents 'Info.plist') -Encoding utf8NoBOM
+ Invoke-Checked 'Validate Info.plist' { plutil -lint (Join-Path $contents 'Info.plist') }
+ $iconset = Join-Path $stage 'filee.iconset'
+ New-Item -ItemType Directory -Path $iconset | Out-Null
+ $sourceIcon = Join-Path $root 'src/Filee.App/Assets/Icons/filee.png'
+ foreach ($size in @(16, 32, 128, 256, 512)) {
+ foreach ($scale in @(1, 2)) {
+ $pixels = $size * $scale
+ $suffix = if ($scale -eq 2) { '@2x' } else { '' }
+ $target = Join-Path $iconset "icon_${size}x${size}$suffix.png"
+ Invoke-Checked 'Render app icon' { sips -z $pixels $pixels $sourceIcon --out $target | Out-Null }
+ }
+ }
+ Invoke-Checked 'Create app icon' { iconutil -c icns $iconset -o (Join-Path $resources 'filee.icns') }
+ $entitlements = Join-Path $root 'installer/macos/entitlements.plist'
+ $signOptions = @('--force', '--sign', $SigningIdentity)
+ if ($SigningIdentity -ne '-') { $signOptions += @('--timestamp', '--options', 'runtime') }
+ foreach ($file in Get-ChildItem -LiteralPath $publish -Recurse -File | Sort-Object { $_.FullName.Length } -Descending) {
+ $format = & /usr/bin/file --brief $file.FullName
+ if ($LASTEXITCODE -ne 0) { throw "Cannot inspect $($file.FullName) before signing." }
+ if ($format -match 'Mach-O') {
+ Invoke-Checked 'Sign native binary' { codesign @signOptions --entitlements $entitlements $file.FullName }
+ }
+ }
+ Invoke-Checked 'Sign app bundle' { codesign @signOptions --entitlements $entitlements $appBundle }
+ Invoke-Checked 'Verify app signature' { codesign --verify --deep --strict $appBundle }
+ Copy-Item -LiteralPath (Join-Path $root 'installer/macos/install.sh') -Destination $package
+ Copy-Item -LiteralPath (Join-Path $root 'installer/macos/uninstall.sh') -Destination $package
+ Copy-Item -LiteralPath (Join-Path $root 'installer/macos/INSTALL.txt') -Destination $package
+}
+else {
+ Copy-Item -LiteralPath (Join-Path $root 'src/Filee.App/Assets/Icons/filee.png') -Destination $package
+ Copy-Item -LiteralPath (Join-Path $root 'installer/linux/install.sh') -Destination $package
+ Copy-Item -LiteralPath (Join-Path $root 'installer/linux/uninstall.sh') -Destination $package
+ Copy-Item -LiteralPath (Join-Path $root 'installer/linux/INSTALL.txt') -Destination $package
+}
+Invoke-Checked 'Installer permissions' { chmod +x (Join-Path $package 'install.sh') (Join-Path $package 'uninstall.sh') }
+$temporaryArchive = "$archive.$([Guid]::NewGuid().ToString('N')).partial"
+Invoke-Checked 'Create tar.gz' { tar -czf $temporaryArchive -C $stage $packageName }
+Move-Item -LiteralPath $temporaryArchive -Destination $archive
+"$((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()) $packageName.tar.gz" |
+ Set-Content -LiteralPath $sums -Encoding ascii
+Write-Host "Package: $archive"
+Write-Host "Checksums: $sums"
+Write-Host "Unpacked build retained for testing: $package"
+if ($Runtime.StartsWith('osx-') -and $SigningIdentity -eq '-') {
+ Write-Host 'This macOS build is ad-hoc signed for testing; it is not notarized for public distribution.'
+}
diff --git a/build/fetch-portable-engines.ps1 b/build/fetch-portable-engines.ps1
new file mode 100644
index 0000000..479f385
--- /dev/null
+++ b/build/fetch-portable-engines.ps1
@@ -0,0 +1,73 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime,
+ [Parameter(Mandatory)][string]$Destination,
+ [ValidateSet('rhwp', '7zip')][string[]]$Only = @('rhwp', '7zip')
+)
+
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$manifestPath = Join-Path $PSScriptRoot '../src/Filee.Engines/Infrastructure/engines.json'
+$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -AsHashtable
+$components = $manifest.platforms[$Runtime].components
+$cache = Join-Path $PSScriptRoot '.cache/portable-downloads'
+New-Item -ItemType Directory -Force -Path $cache, $Destination | Out-Null
+$Destination = (Resolve-Path -LiteralPath $Destination).Path
+
+foreach ($name in $Only) {
+ $component = $components[$name]
+ if (-not $component -or $component.sha256 -notmatch '^[0-9a-fA-F]{64}$') {
+ throw "A pinned $name download is missing for $Runtime."
+ }
+ $target = Join-Path $Destination $name
+ if (Test-Path -LiteralPath $target) {
+ throw "Engine destination already exists: $target. Use a fresh publish folder."
+ }
+ $archive = Join-Path $cache "$($component.sha256).$($component.kind)"
+ if (-not (Test-Path -LiteralPath $archive)) {
+ $download = "$archive.$([Guid]::NewGuid().ToString('N')).partial"
+ Invoke-WebRequest -Uri $component.url -OutFile $download
+ if ((Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash -ne $component.sha256) {
+ Remove-Item -LiteralPath $download
+ throw "Checksum mismatch for $name ($Runtime)."
+ }
+ Move-Item -LiteralPath $download -Destination $archive
+ }
+ if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $component.sha256) {
+ throw "Cached engine checksum mismatch: $archive"
+ }
+ $members = @(& tar -tf $archive)
+ if ($LASTEXITCODE -ne 0) { throw "Cannot read $archive" }
+ foreach ($member in $members) {
+ if ($member -match '^[/\\]' -or $member -match '(^|[/\\])\.\.([/\\]|$)' -or $member.Contains(':')) {
+ throw "Unsafe archive member: $member"
+ }
+ }
+ # These two bundles contain only files and directories; reject links before tar can create them.
+ $details = @(& tar -tvf $archive)
+ if ($LASTEXITCODE -ne 0) { throw "Cannot inspect entry types in $archive" }
+ foreach ($entry in $details) {
+ if ($entry -notmatch '^[-d]') {
+ throw "Unsupported archive entry (links and special files are not allowed): $entry"
+ }
+ }
+ $stage = Join-Path $cache ('extract-' + [Guid]::NewGuid().ToString('N'))
+ New-Item -ItemType Directory -Path $stage | Out-Null
+ & tar -xf $archive -C $stage
+ if ($LASTEXITCODE -ne 0) { throw "Cannot extract $archive" }
+ $children = @(Get-ChildItem -LiteralPath $stage -Force)
+ $payload = if ($children.Count -eq 1 -and $children[0].PSIsContainer) { $children[0].FullName } else { $stage }
+ $executable = if ($name -eq 'rhwp') { 'rhwp' } else { '7zz' }
+ if (-not (Test-Path -LiteralPath (Join-Path $payload $executable) -PathType Leaf)) {
+ throw "$executable was not found at the expected location in $archive"
+ }
+ Move-Item -LiteralPath $payload -Destination $target
+ if (-not $IsWindows) {
+ & chmod +x (Join-Path $target $executable)
+ if ($LASTEXITCODE -ne 0) { throw "Could not make $executable executable." }
+ }
+ if (Test-Path -LiteralPath $stage) {
+ Remove-Item -LiteralPath $stage
+ }
+ Write-Host "$name ready for $Runtime"
+}
diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt
new file mode 100644
index 0000000..6f854bf
--- /dev/null
+++ b/installer/linux/INSTALL.txt
@@ -0,0 +1,29 @@
+Run ./Filee to test the extracted app, or ./filee-cli help for the command line.
+
+Run ./install.sh to install for the current user. It requires Python 3, creates
+an application launcher and ~/.local/bin/filee, and does not require root.
+Run ./uninstall.sh to remove this installation and Filee-downloaded engines.
+Your settings and converted files are kept. If integration cleanup fails, the
+installation is kept so you can fix the error and run the uninstaller again.
+An existing installation is never overwritten; uninstall it before replacing it.
+
+The .NET runtime is bundled; its native system libraries are still required.
+On Ubuntu 24.04, install the desktop and runtime dependencies with:
+
+sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 fonts-noto-cjk python3
+
+Other distributions use different package names. The desktop needs an X11
+server (or XWayland), fontconfig, FreeType and the X11 libraries above.
+X11 supports the global gesture toolbar. A native Wayland session has additional
+input restrictions; use the drop window and file-manager actions if needed.
+Ubuntu 24.04 is the automated build and smoke-test baseline.
+
+rhwp and 7-Zip are bundled. Other engines are installed from Filee settings or
+discovered in the system. Format support depends on available native engines.
+Optional engines can require additional system libraries. In particular, calibre
+may require libxcb-cursor0, libxcb-xinerama0 and other Qt/XCB dependencies.
+
+Dependency references:
+https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install#ubuntu-2404
+https://docs.avaloniaui.net/docs/deployment/linux
+https://calibre-ebook.com/download_linux
diff --git a/installer/linux/install.sh b/installer/linux/install.sh
new file mode 100644
index 0000000..2a3cec0
--- /dev/null
+++ b/installer/linux/install.sh
@@ -0,0 +1,52 @@
+#!/bin/sh
+set -eu
+
+source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+data_dir=${XDG_DATA_HOME:-"$HOME/.local/share"}
+install_dir="$data_dir/filee/application"
+bin_dir="$HOME/.local/bin"
+desktop_file="$data_dir/applications/com.filee.app.desktop"
+if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then
+ printf '%s\n' "An installation already exists at $install_dir. Run its uninstall.sh first."
+ exit 1
+fi
+if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then
+ printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee."
+ exit 1
+fi
+if [ -e "$desktop_file" ] || [ -L "$desktop_file" ]; then
+ printf '%s\n' "$desktop_file already exists. Remove or rename it before installing Filee."
+ exit 1
+fi
+command -v python3 >/dev/null
+test -x "$source_dir/Filee"
+test -x "$source_dir/filee-cli"
+mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications"
+cp -R "$source_dir" "$install_dir"
+printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package"
+ln -s "$install_dir/filee-cli" "$bin_dir/filee"
+python3 - "$install_dir" "$desktop_file" <<'PY'
+import pathlib
+import sys
+
+app, desktop = map(pathlib.Path, sys.argv[1:])
+def quoted_exec(value):
+ value = value.replace('%', '%%')
+ for character in ('\\', '"', '`', '$'):
+ value = value.replace(character, '\\' + character)
+ return '"' + value.replace('\\', '\\\\').replace('\n', '\\n').replace('\r', '\\r') + '"'
+
+def desktop_value(value):
+ return str(value).replace('\\', '\\\\').replace('\n', '\\n').replace('\r', '\\r')
+
+desktop.write_text(
+ '[Desktop Entry]\nType=Application\nName=Filee\n'
+ 'Comment=Convert files with Filee\n'
+ f'Exec={quoted_exec(str(app / "Filee"))} --convert %F\n'
+ f'Icon={desktop_value(app / "filee.png")}\n'
+ 'Terminal=false\nCategories=Utility;\nX-Filee-Managed=true\n', encoding='utf-8')
+PY
+if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database "$data_dir/applications"
+fi
+printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)."
diff --git a/installer/linux/uninstall.sh b/installer/linux/uninstall.sh
new file mode 100644
index 0000000..bccb559
--- /dev/null
+++ b/installer/linux/uninstall.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+set -eu
+
+data_dir=${XDG_DATA_HOME:-"$HOME/.local/share"}
+install_dir="$data_dir/filee/application"
+desktop_file="$data_dir/applications/com.filee.app.desktop"
+python3 - "$data_dir" "$install_dir" <<'PY'
+import pathlib
+import sys
+
+root, target = map(pathlib.Path, sys.argv[1:])
+expected = root.resolve() / 'filee' / 'application'
+if target.is_symlink() or target.resolve() != expected:
+ raise SystemExit('Refusing to remove an installation outside its expected directory.')
+if (target / '.filee-package').read_text(encoding='utf-8').strip() != 'com.filee.app':
+ raise SystemExit('The directory is not an installation created by Filee.')
+PY
+"$install_dir/Filee" --uninstall-cleanup
+if [ -L "$HOME/.local/bin/filee" ] && [ "$(readlink "$HOME/.local/bin/filee")" = "$install_dir/filee-cli" ]; then
+ rm -- "$HOME/.local/bin/filee"
+fi
+if [ -f "$desktop_file" ] && grep -q '^X-Filee-Managed=true$' "$desktop_file"; then
+ rm -- "$desktop_file"
+fi
+rm -rf -- "$install_dir"
+printf '%s\n' 'Filee was removed. Your settings and conversion results were kept.'
diff --git a/installer/macos/INSTALL.txt b/installer/macos/INSTALL.txt
new file mode 100644
index 0000000..90352be
--- /dev/null
+++ b/installer/macos/INSTALL.txt
@@ -0,0 +1,25 @@
+This package requires macOS 14 or later and contains a separate build for
+Apple Silicon or Intel Macs. The .NET runtime is included.
+Run ./install.sh to copy Filee.app to ~/Applications and add ~/.local/bin/filee.
+You can also move Filee.app into Applications manually. Keep its location stable
+so Accessibility and Finder Automation permissions continue to identify it.
+
+Test packages are ad-hoc signed and are not notarized. For a downloaded test
+build, use System Settings > Privacy & Security > Open Anyway when macOS blocks
+the first launch. Only open a build whose source and checksum you trust.
+
+Allow Filee in System Settings > Privacy & Security > Accessibility for global
+gestures. Allow Finder under Automation when using a selection shortcut.
+Enable the Finder Quick Action in Filee settings. Services can also be managed
+under System Settings > Keyboard > Keyboard Shortcuts > Services.
+
+Run ./uninstall.sh to remove the ~/Applications installation, integration and
+Filee-downloaded engines. Your settings and conversion results are kept.
+If integration cleanup fails, the installation is kept so you can fix the error
+and run the uninstaller again. A manually placed app in
+/Applications can be removed after running its Filee executable with
+--uninstall-cleanup.
+
+rhwp and 7-Zip are bundled. Other engines can be installed in Filee settings or
+discovered in the system. Public distribution requires Developer ID signing
+and notarization; neither is supplied by this test package.
diff --git a/installer/macos/Info.plist b/installer/macos/Info.plist
new file mode 100644
index 0000000..49eca65
--- /dev/null
+++ b/installer/macos/Info.plist
@@ -0,0 +1,19 @@
+
+
+
+
+ CFBundleDevelopmentRegionen
+ CFBundleIdentifiercom.filee.app
+ CFBundleNameFilee
+ CFBundleDisplayNameFilee
+ CFBundleExecutableFilee
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString__VERSION__
+ CFBundleVersion__BUNDLE_VERSION__
+ CFBundleIconFilefilee.icns
+ LSMinimumSystemVersion14.0
+ NSHighResolutionCapable
+ NSAppleEventsUsageDescriptionFilee reads selected Finder files when you use a conversion shortcut.
+ NSInputMonitoringUsageDescriptionFilee detects your conversion shortcut and drag gesture to show the conversion menu.
+
+
diff --git a/installer/macos/entitlements.plist b/installer/macos/entitlements.plist
new file mode 100644
index 0000000..e7df6a9
--- /dev/null
+++ b/installer/macos/entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+ com.apple.security.automation.apple-events
+
+
diff --git a/installer/macos/install.sh b/installer/macos/install.sh
new file mode 100644
index 0000000..e7c7fda
--- /dev/null
+++ b/installer/macos/install.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+set -eu
+
+source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+install_dir="$HOME/Applications/Filee.app"
+bin_dir="$HOME/.local/bin"
+if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then
+ printf '%s\n' "An installation already exists at $install_dir. Run uninstall.sh first."
+ exit 1
+fi
+if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then
+ printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee."
+ exit 1
+fi
+test -x "$source_dir/Filee.app/Contents/MacOS/Filee"
+mkdir -p "$HOME/Applications" "$bin_dir"
+/usr/bin/ditto "$source_dir/Filee.app" "$install_dir"
+ln -s "$install_dir/Contents/MacOS/filee-cli" "$bin_dir/filee"
+printf '%s\n' "Installed $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)."
+printf '%s\n' 'Open Filee from Applications. Grant Accessibility and Finder Automation access when requested.'
diff --git a/installer/macos/uninstall.sh b/installer/macos/uninstall.sh
new file mode 100644
index 0000000..7998bba
--- /dev/null
+++ b/installer/macos/uninstall.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+set -eu
+
+install_dir="$HOME/Applications/Filee.app"
+if [ -L "$install_dir" ] || [ ! -d "$install_dir" ]; then
+ printf '%s\n' "No ordinary Filee app bundle was found at $install_dir."
+ exit 1
+fi
+resolved_parent=$(CDPATH= cd -- "$HOME/Applications" && pwd -P)
+resolved_app=$(CDPATH= cd -- "$install_dir" && pwd -P)
+if [ "$resolved_app" != "$resolved_parent/Filee.app" ]; then
+ printf '%s\n' 'Refusing to remove an app outside the expected Applications directory.'
+ exit 1
+fi
+bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$install_dir/Contents/Info.plist")
+if [ "$bundle_id" != 'com.filee.app' ]; then
+ printf '%s\n' 'The bundle is not Filee.'
+ exit 1
+fi
+"$install_dir/Contents/MacOS/Filee" --uninstall-cleanup
+if [ -L "$HOME/.local/bin/filee" ] && [ "$(readlink "$HOME/.local/bin/filee")" = "$install_dir/Contents/MacOS/filee-cli" ]; then
+ rm -- "$HOME/.local/bin/filee"
+fi
+rm -rf -- "$install_dir"
+printf '%s\n' 'Filee was removed. Your settings and conversion results were kept.'
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index 70387e4..8d29003 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -20,6 +20,7 @@ public partial class App : Application
{
private readonly List _pendingConvertFiles = [];
private IDisposable? _convertDebounce;
+ private bool _selectionPending;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -49,11 +50,29 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
var platform = AppHost.Get();
triggers.DragStarted += (_, e) => radial.ShowForDrag(e.X, e.Y);
triggers.DragEnded += (_, _) => radial.OnDragGestureEnded();
- triggers.SelectionGesture += (_, e) =>
+ triggers.SelectionGesture += async (_, e) =>
{
- var files = platform.GetFileManagerSelection();
- if (files.Count > 0)
- radial.ShowForFiles(e.X, e.Y, files);
+ if (_selectionPending || store.Settings.Paused)
+ return;
+ _selectionPending = true;
+ try
+ {
+ var files = OperatingSystem.IsMacOS()
+ ? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection();
+ if (files.Count > 0 && !store.Settings.Paused)
+ radial.ShowForFiles(e.X, e.Y, files);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ log.LogWarning(ex, "Could not read the file manager selection");
+ AppHost.Get().ShowMain("triggers");
+ if (AppHost.Get().Main is { } window)
+ _ = AppHost.Get().MessageAsync(window, ex.Message);
+ }
+ finally
+ {
+ _selectionPending = false;
+ }
};
triggers.Start();
log.LogInformation("Filee {Version} started", UpdateService.CurrentVersion);
@@ -124,28 +143,7 @@ private static void ApplySettings(AppSettings settings)
AppHost.Get().Refresh();
AppHost.Get().RefreshStatus();
}
- ApplySystemIntegration(settings, loc);
- }
-
- /// Keeps registry entries in sync with settings (idempotent; paths follow updates).
- private static void ApplySystemIntegration(AppSettings settings, ILocalizer loc)
- {
- var exe = Environment.ProcessPath;
- if (exe is null || !OperatingSystem.IsWindows())
- return;
- // When running from the IDE (bin\Debug) don't touch the user's Explorer / startup settings.
- if (!UpdateService.IsReleaseBuild)
- return;
- try
- {
- var platform = AppHost.Get();
- platform.SetStartWithSystem(settings.StartWithSystem, exe);
- platform.SetContextMenu(settings.ContextMenuEnabled, exe, loc["general.context_menu_label"]);
- }
- catch (Exception ex)
- {
- AppHost.Get>().LogWarning(ex, "Could not update system integration");
- }
+ AppHost.Get().Apply(settings);
}
private void HandleCommandLine(CommandLine options, bool firstLaunch = false)
@@ -193,7 +191,7 @@ private void HandleCommandLine(CommandLine options, bool firstLaunch = false)
}
///
- /// Options ticked on the installer's task page. Saving applies them (registry entries, see ApplySystemIntegration)
+ /// Options ticked on the installer's task page. Saving applies them through SystemIntegrationService
/// as if they had been switched on the General page.
///
private static void ApplyInstallerChoices(CommandLine options, UserDataStore store)
@@ -209,7 +207,7 @@ private static void ApplyInstallerChoices(CommandLine options, UserDataStore sto
private void OpenPendingFiles()
{
- var files = _pendingConvertFiles.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ var files = _pendingConvertFiles.Distinct(FileSystemPaths.Comparer).ToList();
_pendingConvertFiles.Clear();
var (x, y) = AppHost.Get().LastCursor;
if (x == 0 && y == 0 && AppHost.Get().Main is { } main)
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index bec646a..d3120c0 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -125,6 +125,11 @@
"general.start_with_system": "Start Filee when I sign in",
"general.context_menu": "Show “Convert with Filee” in the Explorer context menu",
"general.context_menu_label": "Convert with Filee",
+ "general.context_menu_macos": "Add “Convert with Filee” to Finder Quick Actions",
+ "general.context_menu_linux": "Add “Convert with Filee” to the file manager",
+ "general.integration_macos": "Finder shows the action under Quick Actions or Services. Enable it in System Settings > Keyboard > Keyboard Shortcuts > Services if it is hidden.",
+ "general.integration_linux": "File actions are installed for Thunar, Nautilus, Nemo and Dolphin. Close Thunar before changing or pausing its shortcut, then reopen it to load the change.",
+ "general.integration_failed": "Could not apply system integration: {0}",
"general.modern_menu_hint": "On Windows 11 this entry is under “Show more options”. Filee can also add it to the main menu; Windows asks for administrator permission once.",
"general.modern_menu_on": "“Convert with Filee” is in the main Windows 11 menu.",
"general.modern_menu_outdated": "The main-menu entry belongs to an older or moved copy of Filee. Add it again to update it.",
@@ -171,6 +176,14 @@
"triggers.title": "Shortcuts",
"triggers.description": "Choose how the donut toolbar opens. You can add as many gestures as you like.",
+ "triggers.macos_permissions": "Allow Filee in System Settings > Privacy & Security > Accessibility, then retry gestures. Finder also asks for Automation permission when you first use a selection shortcut.",
+ "triggers.linux_x11": "Drag gestures use X11. The first enabled keyboard shortcut is assigned to the Thunar Filee action when file manager integration is on. Close Thunar before changing it, then reopen Thunar. Hold gestures and global selection shortcuts in other file managers are unavailable; use their Filee menu action or the drop zone.",
+ "triggers.linux_wayland": "Cursor-following gestures are unavailable in this Wayland session. Use an X11 session for drag gestures, or convert through file manager actions and the drop zone. Thunar can use the first enabled keyboard shortcut after it restarts.",
+ "triggers.open_accessibility": "Open Accessibility settings",
+ "triggers.retry_hook": "Retry gestures",
+ "triggers.hook_running": "Global input detection is running.",
+ "triggers.hook_stopped": "Global input detection is stopped. File manager actions and the drop zone remain available.",
+ "triggers.hook_failed": "Global input detection failed: {0}",
"triggers.add": "Add gesture",
"triggers.remove": "Remove",
"triggers.enabled": "On",
@@ -359,6 +372,8 @@
"engines.package.calibre.name": "Calibre (Kindle and rare e-book formats)",
"engines.package.calibre.description": "Saves as MOBI and AZW3 for Kindle, and converts rare e-book formats: LIT, LRF, PDB, PML, RB, SNB and TCR, plus CHM and OEB input. EPUB, MOBI, AZW3, FB2 and comics (CBZ, CBR) are read without it.",
"engines.package.size": "Download {0} · {1} on disk",
+ "engines.package.system_required": "Use a copy installed on this computer.",
+ "engines.use_own_app_folder": "Choose the folder containing {0}.app",
"engines.progress_detail": "{0} of {1} · {2}/s · {3}",
"engines.eta.estimating": "estimating time left…",
"engines.eta.seconds": "{0} s left",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 4fdf561..35a4d58 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -125,6 +125,11 @@
"general.start_with_system": "로그인할 때 Filee 시작",
"general.context_menu": "탐색기 우클릭 메뉴에 “Filee로 변환” 표시",
"general.context_menu_label": "Filee로 변환",
+ "general.context_menu_macos": "Finder 빠른 동작에 “Filee로 변환” 추가",
+ "general.context_menu_linux": "파일 관리자 메뉴에 “Filee로 변환” 추가",
+ "general.integration_macos": "Finder의 빠른 동작 또는 서비스에서 사용할 수 있어요. 보이지 않으면 시스템 설정 > 키보드 > 키보드 단축키 > 서비스에서 켜 주세요.",
+ "general.integration_linux": "Thunar, Nautilus, Nemo, Dolphin에 파일 동작을 추가해요. Thunar 단축키를 변경하거나 일시정지할 때는 Thunar를 닫은 상태에서 적용한 뒤 다시 열어 주세요.",
+ "general.integration_failed": "시스템 연동을 적용하지 못했어요: {0}",
"general.modern_menu_hint": "Windows 11에서는 이 항목이 “추가 옵션 표시” 안에 있어요. 기본 메뉴에도 추가할 수 있고, 이때 Windows가 관리자 권한을 한 번 요청해요.",
"general.modern_menu_on": "“Filee로 변환”이 Windows 11 기본 메뉴에 있어요.",
"general.modern_menu_outdated": "기본 메뉴 항목이 이전 버전이나 다른 위치의 Filee를 가리키고 있어요. 다시 추가하면 업데이트돼요.",
@@ -171,6 +176,14 @@
"triggers.title": "단축키",
"triggers.description": "원형 툴바를 여는 방법을 정하세요. 제스처는 원하는 만큼 추가할 수 있어요.",
+ "triggers.macos_permissions": "시스템 설정 > 개인정보 보호 및 보안 > 손쉬운 사용에서 Filee를 허용한 뒤 제스처를 다시 시도해 주세요. 선택 파일 단축키를 처음 사용하면 Finder 자동화 권한도 요청해요.",
+ "triggers.linux_x11": "드래그 제스처는 X11에서 작동해요. 파일 관리자 연동을 켜면 첫 번째 활성 키보드 단축키를 Thunar의 Filee 동작에 연결해요. 변경할 때는 Thunar를 닫고 변경 후 다시 열어 주세요. 길게 누르기와 다른 파일 관리자의 전역 선택 단축키는 사용할 수 없어요. Filee 메뉴나 드롭 영역을 사용해 주세요.",
+ "triggers.linux_wayland": "이 Wayland 세션에서는 커서를 따라가는 제스처를 사용할 수 없어요. 드래그 제스처는 X11 세션에서 사용하거나, 파일 관리자 메뉴와 드롭 영역으로 변환해 주세요. Thunar는 다시 시작한 뒤 첫 번째 활성 키보드 단축키를 사용할 수 있어요.",
+ "triggers.open_accessibility": "손쉬운 사용 설정 열기",
+ "triggers.retry_hook": "제스처 다시 시도",
+ "triggers.hook_running": "전역 입력을 감지하고 있어요.",
+ "triggers.hook_stopped": "전역 입력 감지가 멈춰 있어요. 파일 관리자 메뉴와 드롭 영역은 사용할 수 있어요.",
+ "triggers.hook_failed": "전역 입력을 감지하지 못했어요: {0}",
"triggers.add": "제스처 추가",
"triggers.remove": "삭제",
"triggers.enabled": "사용",
@@ -359,6 +372,8 @@
"engines.package.calibre.name": "Calibre (킨들과 드문 전자책 형식)",
"engines.package.calibre.description": "킨들용 MOBI·AZW3로 저장하고, 드문 전자책 형식(LIT·LRF·PDB·PML·RB·SNB·TCR, CHM·OEB 읽기)을 변환해요. EPUB·MOBI·AZW3·FB2와 만화책(CBZ·CBR)은 없어도 읽을 수 있어요.",
"engines.package.size": "다운로드 {0} · 설치 후 {1}",
+ "engines.package.system_required": "이 컴퓨터에 설치된 엔진을 연결해 주세요.",
+ "engines.use_own_app_folder": "{0}.app이 들어 있는 폴더 선택",
"engines.progress_detail": "{0} / {1} · {2}/s · {3}",
"engines.eta.estimating": "남은 시간 계산 중…",
"engines.eta.seconds": "{0}초 남음",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 02a768c..91edad8 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -125,6 +125,11 @@
"general.start_with_system": "登录时启动 Filee",
"general.context_menu": "在资源管理器右键菜单中显示“用 Filee 转换”",
"general.context_menu_label": "用 Filee 转换",
+ "general.context_menu_macos": "在 Finder 快速操作中添加“用 Filee 转换”",
+ "general.context_menu_linux": "在文件管理器菜单中添加“用 Filee 转换”",
+ "general.integration_macos": "可在 Finder 的快速操作或服务中找到此操作。若未显示,请在系统设置 > 键盘 > 键盘快捷键 > 服务中启用。",
+ "general.integration_linux": "为 Thunar、Nautilus、Nemo 和 Dolphin 安装文件操作。修改或暂停 Thunar 快捷键前请关闭 Thunar,然后重新打开以加载更改。",
+ "general.integration_failed": "无法应用系统集成:{0}",
"general.modern_menu_hint": "在 Windows 11 中,此项位于“显示更多选项”里。Filee 也可以把它添加到主菜单,Windows 会请求一次管理员权限。",
"general.modern_menu_on": "“用 Filee 转换”已在 Windows 11 主菜单中。",
"general.modern_menu_outdated": "主菜单中的此项属于旧版本或其他位置的 Filee。重新添加即可更新。",
@@ -171,6 +176,14 @@
"triggers.title": "快捷操作",
"triggers.description": "选择打开环形工具栏的方式。可以添加任意多个手势。",
+ "triggers.macos_permissions": "请在系统设置 > 隐私与安全性 > 辅助功能中允许 Filee,然后重试手势。首次使用选择文件的快捷键时,Finder 还会请求自动化权限。",
+ "triggers.linux_x11": "拖动手势需要 X11。启用文件管理器集成后,第一个已启用的键盘快捷键会分配给 Thunar 的 Filee 操作。修改前请关闭 Thunar,修改后重新打开。不支持长按手势及其他文件管理器的全局选择快捷键,请使用 Filee 菜单或拖放区域。",
+ "triggers.linux_wayland": "当前 Wayland 会话不支持跟随光标的手势。请使用 X11 会话进行拖动,或通过文件管理器操作和拖放区域转换。重启 Thunar 后可使用第一个已启用的键盘快捷键。",
+ "triggers.open_accessibility": "打开辅助功能设置",
+ "triggers.retry_hook": "重试手势",
+ "triggers.hook_running": "正在检测全局输入。",
+ "triggers.hook_stopped": "全局输入检测已停止。仍可使用文件管理器操作和拖放区域。",
+ "triggers.hook_failed": "全局输入检测失败:{0}",
"triggers.add": "添加手势",
"triggers.remove": "删除",
"triggers.enabled": "启用",
@@ -359,6 +372,8 @@
"engines.package.calibre.name": "Calibre(Kindle 与少见电子书格式)",
"engines.package.calibre.description": "可保存为 Kindle 使用的 MOBI 和 AZW3,并转换少见的电子书格式:LIT、LRF、PDB、PML、RB、SNB、TCR,以及读取 CHM 和 OEB。EPUB、MOBI、AZW3、FB2 和漫画(CBZ、CBR)无需它即可读取。",
"engines.package.size": "下载 {0} · 占用 {1}",
+ "engines.package.system_required": "请使用此电脑上已安装的引擎。",
+ "engines.use_own_app_folder": "选择包含 {0}.app 的文件夹",
"engines.progress_detail": "{0} / {1} · {2}/s · {3}",
"engines.eta.estimating": "正在估算剩余时间…",
"engines.eta.seconds": "剩余 {0} 秒",
diff --git a/src/Filee.App/Filee.App.csproj b/src/Filee.App/Filee.App.csproj
index d7dd4f3..b08ead4 100644
--- a/src/Filee.App/Filee.App.csproj
+++ b/src/Filee.App/Filee.App.csproj
@@ -16,6 +16,8 @@
+
+
diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs
index 2972ae2..0687200 100644
--- a/src/Filee.App/Program.cs
+++ b/src/Filee.App/Program.cs
@@ -67,7 +67,7 @@ private static int RunInstallerCommand(SingleInstance instance)
if (!WaitForOtherProcessesOfThisExe(deadline))
return 1;
if (Options.UninstallCleanup)
- UninstallCleanup.Run();
+ return UninstallCleanup.Run() ? 0 : 1;
return 0;
}
}
@@ -83,7 +83,7 @@ private static bool WaitForOtherProcessesOfThisExe(DateTime deadline)
try
{
if (process.Id == Environment.ProcessId
- || !string.Equals(process.MainModule?.FileName, exe, StringComparison.OrdinalIgnoreCase))
+ || !string.Equals(process.MainModule?.FileName, exe, Filee.Core.Platform.FileSystemPaths.Comparison))
continue;
var left = deadline - DateTime.UtcNow;
if (left <= TimeSpan.Zero || !process.WaitForExit(left))
diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs
index 1349291..5b03168 100644
--- a/src/Filee.App/Services/AppHost.cs
+++ b/src/Filee.App/Services/AppHost.cs
@@ -8,6 +8,8 @@
using Filee.Core.Settings;
using Filee.Engines;
using Filee.Engines.Infrastructure;
+using Filee.Platform.Linux;
+using Filee.Platform.MacOS;
using Filee.Platform.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -33,11 +35,15 @@ public static IServiceProvider Build(string? dataDirectory = null)
services.AddSingleton(sp => new UserDataStore(dataDir, sp.GetRequiredService>()));
services.AddSingleton(_ =>
- OperatingSystem.IsWindows() ? new WindowsPlatformServices() : new NullPlatformServices());
+ OperatingSystem.IsWindows() ? new WindowsPlatformServices()
+ : OperatingSystem.IsMacOS() ? new MacOSPlatformServices()
+ : OperatingSystem.IsLinux() ? new LinuxPlatformServices()
+ : new NullPlatformServices());
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
+ services.AddSingleton();
// Conversion engines
services.AddSingleton(_ => new EngineEnvironment(dataDir));
diff --git a/src/Filee.App/Services/CommandLine.cs b/src/Filee.App/Services/CommandLine.cs
index 4bd7776..d9a9c6f 100644
--- a/src/Filee.App/Services/CommandLine.cs
+++ b/src/Filee.App/Services/CommandLine.cs
@@ -66,7 +66,7 @@ public static CommandLine Parse(IReadOnlyList args)
engines = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(id => id.ToLowerInvariant()).Distinct().ToList();
break;
- case "--start-with-windows" when OnOff(value) is { } on:
+ case "--start-with-windows" or "--start-with-system" when OnOff(value) is { } on:
startWithWindows = on;
break;
case "--context-menu" when OnOff(value) is { } on:
@@ -139,7 +139,7 @@ internal static bool IsOwnListFile(string path)
return Path.GetFileName(full).StartsWith(ListFilePrefix, StringComparison.OrdinalIgnoreCase)
&& full.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.TrimEndingDirectorySeparator(Path.GetDirectoryName(full) ?? ""),
- Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), StringComparison.OrdinalIgnoreCase);
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), Filee.Core.Platform.FileSystemPaths.Comparison);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs
index a2b85e0..c094abd 100644
--- a/src/Filee.App/Services/EngineDownloadService.cs
+++ b/src/Filee.App/Services/EngineDownloadService.cs
@@ -39,8 +39,11 @@ public EnginePackageState(EnginePackage package, ILocalizer loc)
public string Name => _loc[$"engines.package.{Package.Id}.name"];
public string Description => _loc[$"engines.package.{Package.Id}.description"];
- public string Sizes => _loc.Format("engines.package.size",
- FormatBytes(EngineDownloads.DownloadSize(Package)), FormatBytes(Package.InstalledSize));
+ public string Sizes => EngineDownloads.CanDownload(Package)
+ ? _loc.Format("engines.package.size", FormatBytes(EngineDownloads.DownloadSize(Package)), FormatBytes(Package.InstalledSize))
+ : _loc["engines.package.system_required"];
+
+ public string? InstallGuidance => EngineDownloads.UnavailableReason(Package);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText), nameof(IsInstalled), nameof(IsBusy), nameof(CanInstall), nameof(CanRemove),
@@ -87,8 +90,8 @@ public EnginePackageState(EnginePackage package, ILocalizer loc)
|| (Status == EnginePackageStatus.OwnCopy && FromSystem);
public bool IsBusy => Status is EnginePackageStatus.Queued or EnginePackageStatus.Downloading or EnginePackageStatus.Unpacking;
/// Also when a copy on the PC is used: Filee's download is the tested version and wins once installed.
- public bool CanInstall => Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed
- || (Status == EnginePackageStatus.OwnCopy && FromSystem);
+ public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed
+ || (Status == EnginePackageStatus.OwnCopy && FromSystem));
public string StatusText => Status switch
{
@@ -204,7 +207,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
/// Downloads and installs a package; packages queue up and install one after another.
public async Task InstallAsync(EnginePackageState state)
{
- if (state.IsBusy || state.IsInstalled)
+ if (!state.CanInstall)
return;
using var cancellation = new CancellationTokenSource();
state.Cancellation = cancellation;
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index 8a51c98..25dbcdd 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -279,7 +279,7 @@ private void PlaceAndShow(RadialWindow window, int x, int y, bool activate)
{
var cursor = new PixelPoint(x, y);
var screen = window.Screens.ScreenFromPoint(cursor) ?? window.Screens.Primary;
- var scaling = screen?.Scaling ?? 1;
+ var scaling = OperatingSystem.IsMacOS() ? 1 : screen?.Scaling ?? 1;
// Window size follows the donut size (SizeToContent); compute it up front to centre on the cursor.
var side = 2 * (viewModel.OuterRadius + 7 + 14);
@@ -308,5 +308,5 @@ private void PlaceAndShow(RadialWindow window, int x, int y, bool activate)
}
private static bool SameFiles(IReadOnlyList a, IReadOnlyList b) =>
- a.Count == b.Count && a.SequenceEqual(b, StringComparer.OrdinalIgnoreCase);
+ a.Count == b.Count && a.SequenceEqual(b, FileSystemPaths.Comparer);
}
diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs
index e98f34a..c3e45f2 100644
--- a/src/Filee.App/Services/SingleInstance.cs
+++ b/src/Filee.App/Services/SingleInstance.cs
@@ -1,5 +1,5 @@
-// Makes sure only one Filee runs per user session. Later launches send their command line to the first
-// instance through a named pipe (e.g. Explorer starts one process per selected file for the context menu).
+// Keeps one Filee per Windows session, or per user across Unix launcher/terminal sessions.
+// Later launches forward arguments through a named pipe.
using System.IO.Pipes;
using System.Text.Json;
@@ -28,7 +28,11 @@ private SingleInstance(Mutex mutex, bool isFirst)
public static SingleInstance Acquire()
{
- var mutex = new Mutex(initiallyOwned: true, @"Local\" + Name, out var createdNew);
+ bool createdNew;
+ var mutex = OperatingSystem.IsWindows()
+ ? new Mutex(initiallyOwned: true, @"Local\" + Name, out createdNew)
+ : new Mutex(initiallyOwned: true, Name,
+ new NamedWaitHandleOptions { CurrentUserOnly = true, CurrentSessionOnly = false }, out createdNew);
return new SingleInstance(mutex, createdNew);
}
diff --git a/src/Filee.App/Services/SystemIntegrationService.cs b/src/Filee.App/Services/SystemIntegrationService.cs
new file mode 100644
index 0000000..569da46
--- /dev/null
+++ b/src/Filee.App/Services/SystemIntegrationService.cs
@@ -0,0 +1,64 @@
+using Filee.Core.Localization;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+using Filee.Platform.Linux;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+public sealed class SystemIntegrationService(IPlatformServices platform, ILocalizer loc, ILogger log)
+{
+ private (bool Enabled, string Executable)? _startup;
+ private (bool Enabled, string Executable, string Label)? _menu;
+ private bool _shortcutApplied;
+ private string? _shortcut;
+
+ public string? Error { get; private set; }
+ internal bool RegistrationEnabled { get; init; } = UpdateService.IsReleaseBuild;
+ internal string? ExecutablePath { get; init; } = Environment.ProcessPath;
+
+ public void Apply(AppSettings settings)
+ {
+ Error = null;
+ if (!RegistrationEnabled || ExecutablePath is not { } executable)
+ return;
+ var errors = new List();
+ var startup = (settings.StartWithSystem, executable);
+ if (_startup != startup && ApplyOne(() => platform.SetStartWithSystem(startup.StartWithSystem, executable)))
+ _startup = startup;
+ var menu = (settings.ContextMenuEnabled, executable, Label: loc["general.context_menu_label"]);
+ if (_menu != menu && ApplyOne(() => platform.SetContextMenu(menu.ContextMenuEnabled, executable, menu.Label)))
+ {
+ _menu = menu;
+ _shortcutApplied = false;
+ }
+ if (OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux)
+ {
+ ApplyOne(() =>
+ {
+ var shortcut = settings.ContextMenuEnabled && !settings.Paused
+ ? settings.Triggers.FirstOrDefault(g => g.Enabled && g.Kind == TriggerKind.KeyChord) : null;
+ var accelerator = shortcut is null ? null : LinuxPlatformServices.ToThunarAccelerator(shortcut);
+ if (!_shortcutApplied || _shortcut != accelerator)
+ {
+ linux.SetThunarShortcut(accelerator);
+ _shortcut = accelerator;
+ _shortcutApplied = true;
+ }
+ });
+ }
+ if (errors.Count > 0)
+ Error = loc.Format("general.integration_failed", string.Join(Environment.NewLine, errors));
+
+ bool ApplyOne(Action action)
+ {
+ try { action(); return true; }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ log.LogWarning(ex, "Could not update system integration");
+ errors.Add(ex.Message);
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs
index da632db..24eb0bf 100644
--- a/src/Filee.App/Services/Triggers/TriggerService.cs
+++ b/src/Filee.App/Services/Triggers/TriggerService.cs
@@ -50,10 +50,14 @@ public TriggerService(IPlatformServices platform, ILogger log)
public (int X, int Y) LastCursor { get; private set; }
public bool IsRunning => _hook?.IsRunning == true;
+ public string? Error { get; private set; }
+ public event EventHandler? StatusChanged;
+
+ private void NotifyStatus() => Post(() => StatusChanged?.Invoke(this, EventArgs.Empty));
public void Apply(AppSettings settings)
{
- _detector.Configure(settings.Triggers);
+ _detector.Configure(settings.Triggers.Where(g => _platform.SupportsSelectionShortcut || g.Kind == TriggerKind.Drag).ToList());
_detector.Paused = settings.Paused;
_excluded = new HashSet(settings.ExcludedProcesses.Select(p => p.Trim().Replace(".exe", "", StringComparison.OrdinalIgnoreCase)),
StringComparer.OrdinalIgnoreCase);
@@ -63,11 +67,14 @@ public void Start()
{
if (_hook is not null)
return;
+ if (!_platform.SupportsGlobalPointerGestures)
+ return;
+ Error = null;
try
{
_hook = new EventLoopGlobalHook();
- _hook.HookEnabled += (_, _) => _log.LogInformation("Global input hook started");
- _hook.HookDisabled += (_, _) => _log.LogInformation("Global input hook stopped");
+ _hook.HookEnabled += (_, _) => { _log.LogInformation("Global input hook started"); NotifyStatus(); };
+ _hook.HookDisabled += (_, _) => { _log.LogInformation("Global input hook stopped"); NotifyStatus(); };
_hook.MousePressed += (_, e) => _detector.MouseDown(Map(e.Data.Button), e.Data.X, e.Data.Y, Map(e.RawEvent.Mask));
_hook.MouseReleased += (_, e) => _detector.MouseUp(Map(e.Data.Button), e.Data.X, e.Data.Y);
_hook.MouseDragged += (_, e) => OnMove(e);
@@ -77,14 +84,21 @@ public void Start()
{
// e.g. macOS without Accessibility permission. Drop zone and context menu keep working.
if (t.Exception is not null)
+ {
+ Error = t.Exception.GetBaseException().Message;
_log.LogError(t.Exception, "Global input hook failed; gestures are unavailable");
+ NotifyStatus();
+ }
}, TaskScheduler.Default);
}
catch (Exception ex)
{
// e.g. macOS without Accessibility permission. The app keeps working via the drop zone / context menu.
_log.LogError(ex, "Could not start the global input hook");
+ Error = ex.Message;
+ _hook?.Dispose();
_hook = null;
+ NotifyStatus();
}
}
diff --git a/src/Filee.App/Services/UninstallCleanup.cs b/src/Filee.App/Services/UninstallCleanup.cs
index e2b98ac..4d5cef3 100644
--- a/src/Filee.App/Services/UninstallCleanup.cs
+++ b/src/Filee.App/Services/UninstallCleanup.cs
@@ -1,33 +1,54 @@
-// 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.
+// Removes per-user integration and downloaded engines after Filee exits. User settings remain for reinstalling.
+using Filee.Core.Platform;
+using Filee.Platform.Linux;
+using Filee.Platform.MacOS;
using Filee.Platform.Windows;
namespace Filee.App.Services;
internal static class UninstallCleanup
{
- public static void Run()
+ public static bool Run()
{
- if (!OperatingSystem.IsWindows())
- return;
- try
+ IPlatformServices platform = OperatingSystem.IsWindows() ? new WindowsPlatformServices()
+ : OperatingSystem.IsMacOS() ? new MacOSPlatformServices()
+ : OperatingSystem.IsLinux() ? new LinuxPlatformServices() : new NullPlatformServices();
+ var succeeded = true;
+ Clean(() => platform.SetContextMenu(false, "", ""));
+ Clean(() => platform.SetStartWithSystem(false, ""));
+ if (OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux)
+ Clean(() => linux.SetThunarShortcut(null));
+ if (OperatingSystem.IsWindows() && ExplorerMenuRegistration.IsSupportedOs)
{
- var platform = new WindowsPlatformServices();
- // Deletes the classic verb, Send To and the title file (which already hides the top-level entry).
- platform.SetContextMenu(false, "", "");
- platform.SetStartWithSystem(false, "");
- // 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).
- if (Directory.Exists(Filee.Engines.Infrastructure.EngineEnvironment.DownloadRoot))
- Directory.Delete(Filee.Engines.Infrastructure.EngineEnvironment.DownloadRoot, recursive: true);
+ Clean(() =>
+ {
+ var task = ExplorerMenuRegistration.RemoveAsync(allowElevation: false);
+ if (!task.Wait(TimeSpan.FromSeconds(40)))
+ throw new TimeoutException("Timed out removing the Explorer menu.");
+ if (!task.Result.Succeeded)
+ throw new IOException(task.Result.Error ?? "Could not remove the Explorer menu.");
+ });
}
- catch (Exception)
+ if (succeeded)
{
- // Uninstall must never fail because of cleanup.
+ Clean(() =>
+ {
+ var engines = Filee.Engines.Infrastructure.EngineEnvironment.DownloadRoot;
+ if (Directory.Exists(engines))
+ Directory.Delete(engines, recursive: true);
+ });
+ }
+ return succeeded;
+
+ void Clean(Action action)
+ {
+ try { action(); }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ succeeded = false;
+ Console.Error.WriteLine(ex.Message);
+ }
}
}
}
diff --git a/src/Filee.App/ViewModels/EngineSetupViewModel.cs b/src/Filee.App/ViewModels/EngineSetupViewModel.cs
index da4fcec..bb8c9e6 100644
--- a/src/Filee.App/ViewModels/EngineSetupViewModel.cs
+++ b/src/Filee.App/ViewModels/EngineSetupViewModel.cs
@@ -20,7 +20,7 @@ public EngineSetupViewModel(EngineDownloadService downloads, ILocalizer loc)
_loc = loc;
foreach (var package in Packages)
{
- package.Selected = !package.IsInstalled && Filee.Engines.Infrastructure.EngineDownloads.IsSuggested(package.Package);
+ package.Selected = package.CanInstall && Filee.Engines.Infrastructure.EngineDownloads.IsSuggested(package.Package);
package.PropertyChanged += OnPackageChanged;
}
}
diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
index 10a720b..60592c0 100644
--- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
@@ -16,13 +16,16 @@ public sealed partial class GeneralPageViewModel : ObservableObject, IDisposable
private readonly ILocalizer _loc;
private readonly IPlatformServices _platform;
private readonly UpdateService _updates;
+ private readonly SystemIntegrationService? _integration;
- public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, UpdateService updates)
+ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, UpdateService updates,
+ SystemIntegrationService? integration = null)
{
_store = store;
_loc = loc;
_platform = platform;
_updates = updates;
+ _integration = integration;
Languages =
[
@@ -62,6 +65,11 @@ private void OnUpdatesChanged(object? sender, System.ComponentModel.PropertyChan
public IReadOnlyList> Languages { get; }
public string DataFolder => _store.Directory;
public bool IsWindows => OperatingSystem.IsWindows();
+ public bool HasSystemIntegration => _platform is not NullPlatformServices;
+ public string ContextMenuText => _loc[OperatingSystem.IsMacOS() ? "general.context_menu_macos"
+ : OperatingSystem.IsLinux() ? "general.context_menu_linux" : "general.context_menu"];
+ public string? IntegrationMessage => _integration?.Error ?? (OperatingSystem.IsMacOS()
+ ? _loc["general.integration_macos"] : OperatingSystem.IsLinux() ? _loc["general.integration_linux"] : null);
/// Choices for the default save location (everything but "Default" itself).
public IReadOnlyList> OutputLocations { get; }
@@ -193,6 +201,7 @@ private void Save(Action change)
{
change(_store.Settings);
_store.SaveSettings();
+ OnPropertyChanged(nameof(IntegrationMessage));
}
[RelayCommand]
diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
index 0e31243..1d5d459 100644
--- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
@@ -6,8 +6,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Filee.App.Services;
+using Filee.App.Services.Triggers;
using Filee.Core.Localization;
+using Filee.Core.Platform;
using Filee.Core.Settings;
+using Filee.Platform.Linux;
+using Filee.Platform.MacOS;
using ModifierKeys = Filee.Core.Settings.ModifierKeys;
namespace Filee.App.ViewModels.Pages;
@@ -127,15 +131,26 @@ or Avalonia.Input.Key.RightAlt or Avalonia.Input.Key.LeftShift or Avalonia.Input
}
}
-public sealed partial class TriggersPageViewModel : ObservableObject
+public sealed partial class TriggersPageViewModel : ObservableObject, IDisposable
{
private readonly UserDataStore _store;
private readonly ILocalizer _loc;
+ private readonly IPlatformServices? _platform;
+ private readonly TriggerService? _triggers;
+ private readonly SystemIntegrationService? _integration;
+ private string? _accessibilityError;
- public TriggersPageViewModel(UserDataStore store, ILocalizer loc)
+ public TriggersPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices? platform = null, TriggerService? triggers = null,
+ SystemIntegrationService? integration = null)
{
_store = store;
_loc = loc;
+ _platform = platform;
+ _triggers = triggers;
+ _integration = integration;
+ store.SettingsChanged += OnTriggerSettingsChanged;
+ if (triggers is not null)
+ triggers.StatusChanged += OnTriggerStatusChanged;
foreach (var gesture in store.Settings.Triggers)
Gestures.Add(new TriggerGestureViewModel(gesture, loc, Save));
_excluded = string.Join(", ", store.Settings.ExcludedProcesses);
@@ -143,6 +158,53 @@ public TriggersPageViewModel(UserDataStore store, ILocalizer loc)
public ObservableCollection Gestures { get; } = [];
+ public bool IsMacOS => _platform is MacOSPlatformServices;
+ public bool CanRetryHook => _triggers is not null && (_platform?.SupportsGlobalPointerGestures == true || IsMacOS);
+ public string? IntegrationError => _accessibilityError ?? _integration?.Error;
+ public string? PlatformNotice => _platform switch
+ {
+ MacOSPlatformServices => _loc["triggers.macos_permissions"],
+ LinuxPlatformServices when OperatingSystem.IsLinux() && LinuxPlatformServices.IsWaylandSession => _loc["triggers.linux_wayland"],
+ LinuxPlatformServices => _loc["triggers.linux_x11"],
+ _ => null,
+ };
+ public string? HookStatus => _triggers?.Error is { } error ? _loc.Format("triggers.hook_failed", error)
+ : _triggers is null ? null : _loc[_triggers.IsRunning ? "triggers.hook_running" : "triggers.hook_stopped"];
+
+ private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(HookStatus));
+ private void OnTriggerSettingsChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(IntegrationError));
+
+ [RelayCommand]
+ private void OpenAccessibility()
+ {
+ try
+ {
+ _accessibilityError = null;
+ if (OperatingSystem.IsMacOS() && _platform is MacOSPlatformServices mac)
+ mac.OpenAccessibilitySettings();
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
+ {
+ _accessibilityError = ex.Message;
+ }
+ OnPropertyChanged(nameof(IntegrationError));
+ }
+
+ [RelayCommand]
+ private void RetryHook()
+ {
+ _triggers?.Dispose();
+ _triggers?.Start();
+ OnPropertyChanged(nameof(HookStatus));
+ }
+
+ public void Dispose()
+ {
+ _store.SettingsChanged -= OnTriggerSettingsChanged;
+ if (_triggers is not null)
+ _triggers.StatusChanged -= OnTriggerStatusChanged;
+ }
+
[ObservableProperty] private string _excluded;
partial void OnExcludedChanged(string value)
diff --git a/src/Filee.App/Views/EngineSetupWindow.axaml b/src/Filee.App/Views/EngineSetupWindow.axaml
index 1e83132..8c2e107 100644
--- a/src/Filee.App/Views/EngineSetupWindow.axaml
+++ b/src/Filee.App/Views/EngineSetupWindow.axaml
@@ -39,6 +39,8 @@
+
diff --git a/src/Filee.App/Views/Pages/EnginesPage.axaml b/src/Filee.App/Views/Pages/EnginesPage.axaml
index 461bca9..d17e9d7 100644
--- a/src/Filee.App/Views/Pages/EnginesPage.axaml
+++ b/src/Filee.App/Views/Pages/EnginesPage.axaml
@@ -53,6 +53,8 @@
+
diff --git a/src/Filee.App/Views/Pages/EnginesPage.axaml.cs b/src/Filee.App/Views/Pages/EnginesPage.axaml.cs
index da6d247..91510ba 100644
--- a/src/Filee.App/Views/Pages/EnginesPage.axaml.cs
+++ b/src/Filee.App/Views/Pages/EnginesPage.axaml.cs
@@ -19,13 +19,27 @@ private async void OnUseOwnCopy(object? sender, RoutedEventArgs e)
return;
var loc = AppHost.Get();
var programs = Filee.Engines.Infrastructure.EngineEnvironment.OwnCopyPrograms[package.Package.Id];
- var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ string? path;
+ if (OperatingSystem.IsMacOS() && package.Package.Id is "libreoffice" or "calibre")
{
- Title = loc.Format("engines.use_own_title", package.Name),
- AllowMultiple = false,
- FileTypeFilter = [new FilePickerFileType(programs[0]) { Patterns = [programs[0]] }, FilePickerFileTypes.All],
- });
- if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
+ var folders = await window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = loc.Format("engines.use_own_app_folder", package.Name),
+ AllowMultiple = false,
+ });
+ path = folders.FirstOrDefault()?.TryGetLocalPath();
+ }
+ else
+ {
+ var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = loc.Format("engines.use_own_title", package.Name),
+ AllowMultiple = false,
+ FileTypeFilter = [new FilePickerFileType(programs[0]) { Patterns = [programs[0]] }, FilePickerFileTypes.All],
+ });
+ path = files.FirstOrDefault()?.TryGetLocalPath();
+ }
+ if (path is null)
return;
if (!vm.UseOwnCopy(package, path))
await AppHost.Get().MessageAsync(window, loc.Format("engines.use_own_missing", string.Join(", ", programs)));
diff --git a/src/Filee.App/Views/Pages/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml
index 737dfa4..6317b9a 100644
--- a/src/Filee.App/Views/Pages/GeneralPage.axaml
+++ b/src/Filee.App/Views/Pages/GeneralPage.axaml
@@ -13,7 +13,9 @@
-
+
+
diff --git a/src/Filee.App/Views/Pages/TriggersPage.axaml b/src/Filee.App/Views/Pages/TriggersPage.axaml
index feb9316..70001b8 100644
--- a/src/Filee.App/Views/Pages/TriggersPage.axaml
+++ b/src/Filee.App/Views/Pages/TriggersPage.axaml
@@ -17,6 +17,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs
index 58543c9..45727f3 100644
--- a/src/Filee.Cli/Cli.cs
+++ b/src/Filee.Cli/Cli.cs
@@ -6,6 +6,7 @@
using Filee.Core.Conversion;
using Filee.Core.Formats;
using Filee.Core.Localization;
+using Filee.Core.Platform;
using Filee.Core.Presets;
using Filee.Core.Watching;
@@ -281,7 +282,7 @@ internal static List ExpandInputs(IEnumerable inputs, bool recur
else
missing.Add(input);
}
- return files.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ return files.Select(Path.GetFullPath).Distinct(FileSystemPaths.Comparer).ToList();
}
private static int ListFormats(CliOptions options, CliHost host, TextWriter output, TextWriter errors)
diff --git a/src/Filee.Core/Platform/FileSystemPaths.cs b/src/Filee.Core/Platform/FileSystemPaths.cs
new file mode 100644
index 0000000..93ef4e9
--- /dev/null
+++ b/src/Filee.Core/Platform/FileSystemPaths.cs
@@ -0,0 +1,11 @@
+namespace Filee.Core.Platform;
+
+public static class FileSystemPaths
+{
+ // Unix volumes can be case-sensitive, including APFS formatted with that option.
+ public static StringComparer Comparer { get; } = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
+ public static StringComparison Comparison { get; } = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+}
diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs
index c68b531..21bd05f 100644
--- a/src/Filee.Core/Platform/IPlatformServices.cs
+++ b/src/Filee.Core/Platform/IPlatformServices.cs
@@ -1,12 +1,12 @@
-// OS integration points. Windows implementation: Filee.Platform.Windows. macOS: planned.
-// Code that needs the OS goes through this interface so the rest of the app stays portable.
-
namespace Filee.Core.Platform;
/// Operating-system specific services.
public interface IPlatformServices
{
- /// True if the window under the given screen point (physical pixels) belongs to the file manager or desktop.
+ bool SupportsGlobalPointerGestures => true;
+ bool SupportsSelectionShortcut => true;
+
+ /// True if the window at the screen point belongs to the file manager or desktop. Coordinates are pixels on Windows/Linux and desktop points on macOS.
bool IsFileManagerAt(int x, int y);
/// Process name (without extension) owning the window under the point, or null.
@@ -15,7 +15,7 @@ public interface IPlatformServices
/// Files currently selected in the foreground file manager window (empty if none).
IReadOnlyList GetFileManagerSelection();
- /// System drag threshold in physical pixels.
+ /// System drag threshold in the same units as screen coordinates.
int SystemDragThreshold { get; }
/// True if the user asked the OS to reduce animations.
@@ -71,7 +71,9 @@ public sealed record ModernContextMenuResult(bool Succeeded, bool Cancelled = fa
/// Fallback used on platforms without an implementation yet. Everything is a no-op.
public sealed class NullPlatformServices : IPlatformServices
{
- public bool IsFileManagerAt(int x, int y) => true;
+ public bool SupportsGlobalPointerGestures => false;
+ public bool SupportsSelectionShortcut => false;
+ public bool IsFileManagerAt(int x, int y) => false;
public string? ProcessNameAt(int x, int y) => null;
public IReadOnlyList GetFileManagerSelection() => [];
public int SystemDragThreshold => 4;
@@ -91,6 +93,8 @@ public void RevealInFileManager(string path)
if (target is null)
return;
var opener = OperatingSystem.IsMacOS() ? "open" : "xdg-open";
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(opener, $"\"{target}\"") { UseShellExecute = false });
+ var start = new System.Diagnostics.ProcessStartInfo(opener) { UseShellExecute = false };
+ start.ArgumentList.Add(target);
+ System.Diagnostics.Process.Start(start)?.Dispose();
}
}
diff --git a/src/Filee.Core/Presets/OutputPathResolver.cs b/src/Filee.Core/Presets/OutputPathResolver.cs
index dc74a49..6741efd 100644
--- a/src/Filee.Core/Presets/OutputPathResolver.cs
+++ b/src/Filee.Core/Presets/OutputPathResolver.cs
@@ -84,8 +84,7 @@ public static string Sanitize(string name, string fallback)
}
private static bool PathsEqual(string a, string b) =>
+ // A Unix mount may still be case-insensitive. Prefer a numbered output to risking the source.
string.Equals(Path.GetFullPath(a), Path.GetFullPath(b),
- OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
- ? StringComparison.OrdinalIgnoreCase
- : StringComparison.Ordinal);
+ StringComparison.OrdinalIgnoreCase);
}
diff --git a/src/Filee.Core/Watching/FolderWatcher.cs b/src/Filee.Core/Watching/FolderWatcher.cs
index 5d3143b..6d6c420 100644
--- a/src/Filee.Core/Watching/FolderWatcher.cs
+++ b/src/Filee.Core/Watching/FolderWatcher.cs
@@ -6,6 +6,7 @@
using System.Collections.Concurrent;
using Filee.Core.Conversion;
using Filee.Core.Formats;
+using Filee.Core.Platform;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -31,8 +32,8 @@ public sealed class FolderWatcher : IAsyncDisposable
private readonly ILogger _log;
private readonly TimeSpan _settleTime;
private readonly TimeProvider _time;
- private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase);
- private readonly HashSet _handled = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _pending = new(FileSystemPaths.Comparer);
+ private readonly HashSet _handled = new(FileSystemPaths.Comparer);
private readonly SemaphoreSlim _busy = new(1, 1);
private readonly CancellationTokenSource _stop = new();
private FileSystemWatcher? _watcher;
@@ -143,7 +144,7 @@ public async Task ProcessPendingAsync()
if (ready.Count == 0 || _stop.IsCancellationRequested)
return 0;
- ready.Sort(StringComparer.OrdinalIgnoreCase);
+ ready.Sort(FileSystemPaths.Comparer);
_log.LogInformation("Watch folder {Folder}: converting {Count} file(s)", Rule.Folder, ready.Count);
var job = await _convert(ready, _stop.Token);
if (job is not null)
@@ -242,12 +243,12 @@ private static bool IsInside(string path, string folder)
if (string.IsNullOrWhiteSpace(folder))
return false;
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(folder)) + Path.DirectorySeparatorChar;
- return Path.GetFullPath(path).StartsWith(root, StringComparison.OrdinalIgnoreCase);
+ return Path.GetFullPath(path).StartsWith(root, FileSystemPaths.Comparison);
}
private static bool SameFolder(string? a, string b) =>
a is not null && string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(a)),
- Path.TrimEndingDirectorySeparator(Path.GetFullPath(b)), StringComparison.OrdinalIgnoreCase);
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(b)), FileSystemPaths.Comparison);
public async ValueTask DisposeAsync()
{
diff --git a/src/Filee.Engines/Ebooks/CalibreConverter.cs b/src/Filee.Engines/Ebooks/CalibreConverter.cs
index 2afb5e9..7e20460 100644
--- a/src/Filee.Engines/Ebooks/CalibreConverter.cs
+++ b/src/Filee.Engines/Ebooks/CalibreConverter.cs
@@ -108,7 +108,5 @@ private static string LastLines(string output)
?? EngineEnvironment.SystemProgram("calibre", "ebook-convert.exe");
private static string? LocateBundled() =>
- OperatingSystem.IsWindows() && EngineEnvironment.FindBundled("calibre") is { } folder
- ? EngineEnvironment.FirstExisting(Path.Combine(folder, "ebook-convert.exe"))
- : null;
+ EngineEnvironment.BundledProgram("calibre", "ebook-convert.exe");
}
diff --git a/src/Filee.Engines/Filee.Engines.csproj b/src/Filee.Engines/Filee.Engines.csproj
index 16dd1af..9dea9c5 100644
--- a/src/Filee.Engines/Filee.Engines.csproj
+++ b/src/Filee.Engines/Filee.Engines.csproj
@@ -19,6 +19,8 @@
+
+
diff --git a/src/Filee.Engines/Infrastructure/CopyVersions.cs b/src/Filee.Engines/Infrastructure/CopyVersions.cs
index 0b211c6..dc4e661 100644
--- a/src/Filee.Engines/Infrastructure/CopyVersions.cs
+++ b/src/Filee.Engines/Infrastructure/CopyVersions.cs
@@ -23,7 +23,7 @@ public static partial class CopyVersions
{
var program = id switch
{
- "libreoffice" => Path.Combine(folder, "version.ini"),
+ "libreoffice" when OperatingSystem.IsWindows() => Path.Combine(folder, "version.ini"),
_ when EngineEnvironment.OwnCopyPrograms.TryGetValue(id, out var programs) => Path.Combine(folder, programs[0]),
_ => null,
};
@@ -58,7 +58,7 @@ public static bool IsOutdated(string id, string folder) =>
private static string Read(string id, string program)
{
- if (id == "libreoffice")
+ if (id == "libreoffice" && OperatingSystem.IsWindows())
return File.ReadAllText(program);
var arguments = id == "ffmpeg" ? "-hide_banner -version" : "--version";
try
@@ -96,7 +96,7 @@ private static string Read(string id, string program)
[GeneratedRegex(@"calibre (\d+)\.(\d+)")]
private static partial Regex CalibreVersion();
- [GeneratedRegex(@"MsiProductVersion=(\d+)\.(\d+)")]
+ [GeneratedRegex(@"(?:MsiProductVersion=|LibreOffice\s+)(\d+)\.(\d+)")]
private static partial Regex LibreOfficeVersion();
[GeneratedRegex(@"^\s*(\d+)\.(\d+)", RegexOptions.Multiline)]
diff --git a/src/Filee.Engines/Infrastructure/EngineDownloads.cs b/src/Filee.Engines/Infrastructure/EngineDownloads.cs
index 35bdca9..7dc3916 100644
--- a/src/Filee.Engines/Infrastructure/EngineDownloads.cs
+++ b/src/Filee.Engines/Infrastructure/EngineDownloads.cs
@@ -1,14 +1,14 @@
// Engines that are downloaded on demand instead of being bundled with the installer (they are large), and the
// pinned download list they come from (engines.json, shared with build/fetch-engines.ps1).
+using System.Runtime.InteropServices;
using System.Text.Json;
namespace Filee.Engines.Infrastructure;
/// One download from engines.json.
///
-/// "zip" (extracted), "msi" (administrative install, no system changes; LibreOffice, calibre), "oxt" (LibreOffice
-/// extension) or "conda" (conda-forge package: its Windows binaries are extracted).
+/// Archive format: zip, tar.gz, tar.xz, dmg, msi, oxt or conda.
///
/// Download size in bytes.
public sealed record EngineComponent(string Id, string Version, string Url, string Sha256, long Size, string Kind);
@@ -21,24 +21,51 @@ public sealed record EnginePackage(string Id, IReadOnlyList Components,
public static class EngineDownloads
{
- /// Every component of engines.json by id.
- public static IReadOnlyDictionary Components { get; } = Load();
+ /// The operating system and architecture of the running process.
+ public static string RuntimeIdentifier { get; } =
+ (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : OperatingSystem.IsLinux() ? "linux" : "unknown")
+ + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
+
+ public static IReadOnlyDictionary Components { get; } = ComponentsForRuntime(RuntimeIdentifier);
///
/// Packages offered in Settings → Engines and on first run. rhwp and 7-Zip are not listed: they are small and bundled.
///
public static IReadOnlyList Packages { get; } =
[
- new("libreoffice", ["libreoffice", "h2orestart", "jre"], 1_265_000_000, ["libreoffice"]),
+ new("libreoffice", OperatingSystem.IsWindows() ? ["libreoffice", "h2orestart", "jre"] : ["libreoffice"], 1_265_000_000, ["libreoffice"]),
new("pandoc", ["pandoc"], 236_000_000, ["pandoc"]),
// Ghostscript from conda-forge plus the Microsoft C++ runtime it was built against (copied next to it).
- new("ghostscript", ["ghostscript", "vcruntime"], 31_000_000, ["ghostscript"]),
+ new("ghostscript", OperatingSystem.IsWindows() ? ["ghostscript", "vcruntime"] : ["ghostscript"], 31_000_000, ["ghostscript"]),
new("ffmpeg", ["ffmpeg"], 272_000_000, ["ffmpeg"]),
new("calibre", ["calibre"], 663_000_000, ["calibre"]),
];
/// Total download size of a package in bytes.
- public static long DownloadSize(EnginePackage package) => package.Components.Sum(c => Components[c].Size);
+ public static long DownloadSize(EnginePackage package) => package.Components.Sum(c => Components.GetValueOrDefault(c)?.Size ?? 0);
+
+ public static bool CanDownload(EnginePackage package) => package.Components.Count > 0 && package.Components.All(Components.ContainsKey);
+
+ public static string? UnavailableReason(EnginePackage package)
+ {
+ if (CanDownload(package))
+ return null;
+ if (OperatingSystem.IsMacOS())
+ return package.Id switch
+ {
+ "ffmpeg" => "Install FFmpeg with Homebrew (brew install ffmpeg), then check again or select its bin folder.",
+ "ghostscript" => "Install Ghostscript with Homebrew (brew install ghostscript), then check again or select its bin folder.",
+ _ => $"No Filee download is available for {RuntimeIdentifier}. Install {package.Id} for this Mac and select its program or .app folder.",
+ };
+ if (OperatingSystem.IsLinux())
+ return package.Id switch
+ {
+ "libreoffice" => "Install LibreOffice using your distribution's package manager (Ubuntu/Debian: sudo apt install libreoffice), then check again. HWP import through LibreOffice also needs Java and the H2Orestart extension.",
+ "ghostscript" => "Install Ghostscript using your distribution's package manager (Ubuntu/Debian: sudo apt install ghostscript), then check again.",
+ _ => $"No Filee download is available for {RuntimeIdentifier}. Install {package.Id} using your distribution's package manager and select its program folder.",
+ };
+ return $"No Filee download is available for {RuntimeIdentifier}. Select a compatible installed copy.";
+ }
/// Packages whose download is smaller than this (bytes) are suggested, see .
public const long SuggestLimit = 60_000_000;
@@ -49,14 +76,22 @@ public static class EngineDownloads
/// 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";
+ public static bool IsSuggested(EnginePackage package) => CanDownload(package) && DownloadSize(package) < SuggestLimit && package.Id != "ghostscript";
- private static Dictionary Load()
+ public static IReadOnlyDictionary ComponentsForRuntime(string runtimeIdentifier)
{
using var stream = typeof(EngineDownloads).Assembly.GetManifestResourceStream("Filee.Engines.engines.json")
?? throw new InvalidOperationException("Embedded engines.json is missing.");
using var json = JsonDocument.Parse(stream);
- return json.RootElement.GetProperty("components").EnumerateObject().ToDictionary(
+ JsonElement components;
+ if (runtimeIdentifier == "win-x64")
+ components = json.RootElement.GetProperty("components");
+ else if (json.RootElement.TryGetProperty("platforms", out var platforms)
+ && platforms.TryGetProperty(runtimeIdentifier, out var platform))
+ components = platform.GetProperty("components");
+ else
+ return new Dictionary();
+ return components.EnumerateObject().ToDictionary(
p => p.Name,
p => new EngineComponent(
p.Name,
diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
index 1ed4ada..4a33e4f 100644
--- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
+++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
@@ -54,43 +54,62 @@ public sealed class EngineEnvironment(string dataDirectory)
///
public static IReadOnlyDictionary OwnCopies { get; set; } = new Dictionary();
- /// Programs that must be in the folder of an own copy, per package (Windows names).
- public static IReadOnlyDictionary OwnCopyPrograms { get; } = new Dictionary
+ /// Programs required by each engine package on the current operating system.
+ public static IReadOnlyDictionary OwnCopyPrograms { get; } = Programs(OperatingSystem.IsWindows());
+
+ private static IReadOnlyDictionary Programs(bool windows) => new Dictionary
{
- ["libreoffice"] = ["soffice.exe"],
- ["pandoc"] = ["pandoc.exe"],
- ["ghostscript"] = ["gswin64c.exe"],
- ["ffmpeg"] = ["ffmpeg.exe", "ffprobe.exe"],
- ["calibre"] = ["ebook-convert.exe"],
+ ["libreoffice"] = [windows ? "soffice.exe" : "soffice"],
+ ["pandoc"] = [windows ? "pandoc.exe" : "pandoc"],
+ ["ghostscript"] = [windows ? "gswin64c.exe" : "gs"],
+ ["ffmpeg"] = windows ? ["ffmpeg.exe", "ffprobe.exe"] : ["ffmpeg", "ffprobe"],
+ ["calibre"] = [windows ? "ebook-convert.exe" : "ebook-convert"],
+ ["rhwp"] = [windows ? "rhwp.exe" : "rhwp"],
+ ["7zip"] = [windows ? "7z.exe" : "7zz"],
};
+ public static string ProgramName(string name) => OperatingSystem.IsWindows() ? name
+ : name == "gswin64c.exe" ? "gs" : name.EndsWith(".exe", StringComparison.Ordinal) ? name[..^4] : name;
+
/// The folder of the user's own copy of package , when it has every program.
public static string? OwnCopyFolder(string id) =>
OwnCopies.TryGetValue(id, out var path) ? FolderWithPrograms(id, path) : null;
/// A program of the user's own copy of package , or null.
public static string? OwnProgram(string id, string program) =>
- OwnCopyFolder(id) is { } folder ? Path.Combine(folder, program) : null;
+ OwnCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null;
+
+ public static string? BundledProgram(string id, string program) =>
+ FindBundled(id) is { } root && FolderWithPrograms(id, root) is { } folder
+ ? Path.Combine(folder, ProgramName(program)) : null;
///
/// The folder with all programs of package , given a program the user picked or a folder:
/// that folder itself, or its bin\ or program\ sub folder (FFmpeg builds, LibreOffice). Null when they are missing.
///
- public static string? FolderWithPrograms(string id, string path)
+ public static string? FolderWithPrograms(string id, string path) =>
+ FolderWithPrograms(id, path, OperatingSystem.IsWindows());
+
+ internal static string? FolderWithPrograms(string id, string path, bool windows)
{
- if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(path) || !OwnCopyPrograms.TryGetValue(id, out var programs))
+ if (string.IsNullOrWhiteSpace(path) || !Programs(windows).TryGetValue(id, out var programs))
return null;
var start = File.Exists(path) ? Path.GetDirectoryName(path) : Directory.Exists(path) ? path : null;
if (start is null)
return null;
- foreach (var folder in new[] { start, Path.Combine(start, "bin"), Path.Combine(start, "program") })
+ var app = id == "libreoffice" ? "LibreOffice.app" : id == "calibre" ? "calibre.app" : null;
+ var candidates = new List { start, Path.Combine(start, "bin"), Path.Combine(start, "program"), Path.Combine(start, "Contents", "MacOS") };
+ if (app is not null)
+ candidates.Add(Path.Combine(start, app, "Contents", "MacOS"));
+ foreach (var folder in candidates)
if (programs.All(p => File.Exists(Path.Combine(folder, p))))
return folder;
return null;
}
/// True when package runs from Filee's own copy (not the user's or the PC's).
- public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null && FindBundled(id) is not null;
+ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null
+ && FindBundled(id) is { } folder && FolderWithPrograms(id, folder) is not null;
/// Searching the PC for engines; tests turn it off so the machine's tools don't change results.
public static bool SearchSystem { get; set; } = true;
@@ -104,11 +123,11 @@ public sealed class EngineEnvironment(string dataDirectory)
/// .
///
public static string? SystemCopyFolder(string id) =>
- SearchSystem && OperatingSystem.IsWindows() ? SystemCopies.GetOrAdd(id, FindOnSystem) : null;
+ SearchSystem ? SystemCopies.GetOrAdd(id, FindOnSystem) : null;
/// A program of the copy of package on the PC, or null.
public static string? SystemProgram(string id, string program) =>
- SystemCopyFolder(id) is { } folder ? Path.Combine(folder, program) : null;
+ SystemCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null;
/// Searches the PC again (Settings → Engines → Check again, settings changes).
public static void ForgetSystemCopies() => SystemCopies.Clear();
@@ -118,14 +137,34 @@ public sealed class EngineEnvironment(string dataDirectory)
private static IEnumerable SystemFolders(string id)
{
- var path = string.Join(';',
+ var path = OperatingSystem.IsWindows() ? string.Join(Path.PathSeparator,
Environment.GetEnvironmentVariable("PATH"),
Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User),
- Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine));
- var folders = path.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine))
+ : Environment.GetEnvironmentVariable("PATH") ?? "";
+ var folders = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(entry => Environment.ExpandEnvironmentVariables(entry.Trim('"')))
.ToList();
- foreach (var root in new[] { Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 }
+ if (!OperatingSystem.IsWindows())
+ {
+ folders.AddRange(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/opt/local/bin"]);
+ if (id == "libreoffice")
+ {
+ folders.AddRange(["/Applications/LibreOffice.app", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "LibreOffice.app"), "/usr/lib/libreoffice/program", "/usr/lib64/libreoffice/program"]);
+ try
+ {
+ if (Directory.Exists("/opt"))
+ folders.AddRange(Directory.EnumerateDirectories("/opt", "libreoffice*")
+ .OrderDescending(StringComparer.Ordinal).Select(p => Path.Combine(p, "program")));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+ if (id == "calibre")
+ folders.AddRange(["/Applications/calibre.app", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "calibre.app"), "/opt/calibre"]);
+ }
+ foreach (var root in (OperatingSystem.IsWindows()
+ ? new[] { Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 }
+ : [])
.Select(Environment.GetFolderPath).Where(r => r.Length > 0))
{
switch (id)
@@ -152,11 +191,15 @@ private static IEnumerable SystemFolders(string id)
}
}
// Never Filee's own folders: those are its own copy, found before.
- return folders.Distinct(StringComparer.OrdinalIgnoreCase)
- .Where(f => !f.StartsWith(DownloadRoot, StringComparison.OrdinalIgnoreCase)
- && !f.StartsWith(AppContext.BaseDirectory, StringComparison.OrdinalIgnoreCase));
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ return folders.Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)
+ .Where(f => !IsWithin(f, DownloadRoot, comparison) && !IsWithin(f, AppContext.BaseDirectory, comparison));
}
+ private static bool IsWithin(string path, string root, StringComparison comparison) =>
+ string.Equals(Path.TrimEndingDirectorySeparator(path), Path.TrimEndingDirectorySeparator(root), comparison)
+ || path.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, comparison);
+
/// Returns the first path that exists as a file.
public static string? FirstExisting(params string?[] candidates) =>
candidates.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c) && File.Exists(c));
diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs
index 8a17741..b09f654 100644
--- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs
+++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs
@@ -15,6 +15,7 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using SharpCompress.Compressors.Xz;
using SharpCompress.Compressors.ZStandard;
namespace Filee.Engines.Infrastructure;
@@ -52,15 +53,19 @@ public EngineInstaller(HttpClient http, string? root = null, IReadOnlyDictionary
}
/// True when every component is installed (any version).
- public bool IsInstalled(EnginePackage package) => package.Components.All(c => Marker(c) is not null);
+ public bool IsInstalled(EnginePackage package) => package.Components.All(c => _components.ContainsKey(c) && Marker(c) is not null);
/// True when every component is installed in the version engines.json pins.
public bool IsUpToDate(EnginePackage package) =>
- package.Components.All(c => string.Equals(Marker(c), _components[c].Sha256, StringComparison.OrdinalIgnoreCase));
+ package.Components.All(c => _components.TryGetValue(c, out var component)
+ && string.Equals(Marker(c), component.Sha256, StringComparison.OrdinalIgnoreCase));
/// Downloads and unpacks the components that are missing or outdated.
public async Task InstallAsync(EnginePackage package, IProgress? progress, CancellationToken cancellationToken)
{
+ if (package.Components.Any(c => !_components.ContainsKey(c)))
+ throw new PlatformNotSupportedException(EngineDownloads.UnavailableReason(package)
+ ?? $"The {package.Id} package is unavailable on this platform.");
var pending = package.Components
.Where(c => !string.Equals(Marker(c), _components[c].Sha256, StringComparison.OrdinalIgnoreCase))
.Select(c => _components[c])
@@ -179,8 +184,19 @@ private async Task UnpackAsync(EngineComponent component, string file, Cancellat
{
case "zip":
ZipFile.ExtractToDirectory(file, staging);
+ EnsureExecutablePrograms(component.Id, SingleRoot(staging));
Replace(component.Id, SingleRoot(staging));
break;
+ case "tar.gz":
+ case "tar.xz":
+ await UnpackTarAsync(file, staging, component.Kind, cancellationToken);
+ EnsureExecutablePrograms(component.Id, SingleRoot(staging));
+ Replace(component.Id, SingleRoot(staging));
+ break;
+ case "dmg":
+ await UnpackDiskImageAsync(component.Id, file, staging, cancellationToken);
+ Replace(component.Id, staging);
+ break;
case "msi":
await UnpackMsiAsync(component.Id, file, staging, Path.Combine(Path.GetTempPath(), "filee-msiexec.log"), cancellationToken);
Replace(component.Id, MsiProgramFolder(component.Id, staging));
@@ -212,6 +228,80 @@ private async Task UnpackAsync(EngineComponent component, string file, Cancellat
}
}
+ internal static async Task UnpackTarAsync(string file, string target, string kind, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(target);
+ await using var input = File.OpenRead(file);
+ await using Stream content = kind switch
+ {
+ "tar.gz" => new GZipStream(input, CompressionMode.Decompress),
+ "tar.xz" => new XZStream(input),
+ _ => throw new NotSupportedException(kind),
+ };
+ await TarFile.ExtractToDirectoryAsync(content, target, overwriteFiles: false, cancellationToken);
+ }
+
+ internal static void EnsureExecutablePrograms(string id, string root)
+ {
+ var folder = RequiredProgramFolder(id, root, OperatingSystem.IsWindows());
+ if (OperatingSystem.IsWindows() || folder is null)
+ return;
+ foreach (var program in EngineEnvironment.OwnCopyPrograms[id])
+ {
+ var path = Path.Combine(folder, program);
+ File.SetUnixFileMode(path, File.GetUnixFileMode(path)
+ | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ }
+ }
+
+ internal static string? RequiredProgramFolder(string id, string root, bool windows)
+ {
+ if (!EngineEnvironment.OwnCopyPrograms.ContainsKey(id))
+ return null;
+ return EngineEnvironment.FolderWithPrograms(id, root, windows)
+ ?? throw new InvalidDataException($"The {id} package is missing required executable programs in its expected folders.");
+ }
+
+ private async Task UnpackDiskImageAsync(string id, string file, string target, CancellationToken cancellationToken)
+ {
+ if (!OperatingSystem.IsMacOS())
+ throw new PlatformNotSupportedException("Disk image packages can only be installed on macOS.");
+ var appName = id switch
+ {
+ "libreoffice" => "LibreOffice.app",
+ "calibre" => "calibre.app",
+ _ => throw new NotSupportedException($"No application bundle is specified for {id}."),
+ };
+ // Keep the mount outside staging so failed unmounts cannot be removed by staging cleanup.
+ var mount = Path.Combine(_root, ".mount-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(mount);
+ var attached = false;
+ try
+ {
+ var attach = await ProcessRunner.RunAsync("/usr/bin/hdiutil",
+ ["attach", "-readonly", "-nobrowse", "-mountpoint", mount, file], TimeSpan.FromMinutes(3), cancellationToken);
+ if (attach.ExitCode != 0)
+ throw new InvalidOperationException($"Could not open {id} disk image: {attach.StandardError.Trim()}");
+ attached = true;
+ var app = Path.Combine(mount, appName);
+ if (!Directory.Exists(app) || new DirectoryInfo(app).LinkTarget is not null)
+ throw new InvalidDataException($"{appName} is missing from the disk image.");
+ Directory.CreateDirectory(target);
+ var copy = await ProcessRunner.RunAsync("/usr/bin/ditto", [app, Path.Combine(target, appName)],
+ TimeSpan.FromMinutes(10), cancellationToken);
+ if (copy.ExitCode != 0)
+ throw new InvalidOperationException($"Could not copy {appName}: {copy.StandardError.Trim()}");
+ }
+ finally
+ {
+ var detach = await ProcessRunner.RunAsync("/usr/bin/hdiutil", ["detach", mount],
+ TimeSpan.FromSeconds(30), CancellationToken.None);
+ if (detach.ExitCode != 0 && attached)
+ throw new IOException($"Could not unmount the engine disk image at {mount}: {detach.StandardError.Trim()}");
+ Directory.Delete(mount);
+ }
+ }
+
///
/// The folder to keep from an administratively installed MSI, which lays files out as they would be installed
/// (PFiles64\...): LibreOffice's folder with program/ and share/ (trimmed), calibre's folder with ebook-convert.
@@ -351,6 +441,7 @@ private static string SingleRoot(string folder)
/// Makes the engine folder, replacing a previous version.
private void Replace(string id, string source)
{
+ RequiredProgramFolder(id, source, _components[id].Kind is "msi" or "conda" || OperatingSystem.IsWindows());
var target = FolderOf(id);
if (Directory.Exists(target))
Directory.Delete(target, recursive: true);
diff --git a/src/Filee.Engines/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json
index 3dfc93b..1501176 100644
--- a/src/Filee.Engines/Infrastructure/engines.json
+++ b/src/Filee.Engines/Infrastructure/engines.json
@@ -71,5 +71,163 @@
"size": 226549760,
"kind": "msi"
}
+ },
+ "platforms": {
+ "linux-x64": {
+ "components": {
+ "rhwp": {
+ "version": "0.8.6",
+ "url": "https://github.com/edwardkim/rhwp/releases/download/v0.8.6/rhwp-v0.8.6-linux-x86_64.tar.gz",
+ "sha256": "458DE22A6B9B86088DFDCD59552D4E8FAB5362A64578C64761FA28DF9BE45E9A",
+ "size": 10092553,
+ "kind": "tar.gz"
+ },
+ "7zip": {
+ "version": "26.03",
+ "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-linux-x64.tar.xz",
+ "sha256": "DC99EFF5008F1AB79BD7084C68513701547A808A89502BF4133683535AB3C695",
+ "size": 1575072,
+ "kind": "tar.xz"
+ },
+ "pandoc": {
+ "version": "3.12",
+ "url": "https://github.com/jgm/pandoc/releases/download/3.12/pandoc-3.12-linux-amd64.tar.gz",
+ "sha256": "67D7D011FED8C8543306022B985B9B2499AB9B74818DF91D8727C7E9EBC5BA06",
+ "size": 35326100,
+ "kind": "tar.gz"
+ },
+ "ffmpeg": {
+ "version": "9.0.2",
+ "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-30-13-08/ffmpeg-n9.0.2-17-g2a571b6068-linux64-gpl-9.0.tar.xz",
+ "sha256": "68EE646831ADAAE2495618346F3BBA94FF207FF83BBD34D643E7004730D66269",
+ "size": 151017172,
+ "kind": "tar.xz"
+ },
+ "calibre": {
+ "version": "9.15.0",
+ "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-x86_64.txz",
+ "sha256": "3F5301C0AA51E5FB2D5F6DCD04024BA4E86501AB328CE5D9D6760EFCCB887990",
+ "size": 194998300,
+ "kind": "tar.xz"
+ }
+ }
+ },
+ "linux-arm64": {
+ "components": {
+ "rhwp": {
+ "version": "0.8.6",
+ "url": "https://github.com/edwardkim/rhwp/releases/download/v0.8.6/rhwp-v0.8.6-linux-aarch64.tar.gz",
+ "sha256": "1288AA5609A67574A6B1372397BEF9A8941FDA9AD16B1224342512F915AE016E",
+ "size": 9330223,
+ "kind": "tar.gz"
+ },
+ "7zip": {
+ "version": "26.03",
+ "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-linux-arm64.tar.xz",
+ "sha256": "2389BA20E4D8295E8709C20B6263B69BD1EC4972FE38A04AD7A1BADBF595B996",
+ "size": 1328620,
+ "kind": "tar.xz"
+ },
+ "pandoc": {
+ "version": "3.12",
+ "url": "https://github.com/jgm/pandoc/releases/download/3.12/pandoc-3.12-linux-arm64.tar.gz",
+ "sha256": "6CEFCF7100E23A99447C26F89D1FF5B253F3407FCEF99A9E27AE06F3ED16CB82",
+ "size": 37857559,
+ "kind": "tar.gz"
+ },
+ "ffmpeg": {
+ "version": "9.0.2",
+ "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-30-13-08/ffmpeg-n9.0.2-17-g2a571b6068-linuxarm64-gpl-9.0.tar.xz",
+ "sha256": "91AFCD7695D4BBBB9E0057AA10BAAF9875EC4E1A97303F9059EC971336A2AB46",
+ "size": 127445692,
+ "kind": "tar.xz"
+ },
+ "calibre": {
+ "version": "9.15.0",
+ "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-arm64.txz",
+ "sha256": "15AB887F9D787809BC834CA0EA6408E981B409F0B23046C41C18DE948EB50A38",
+ "size": 182488416,
+ "kind": "tar.xz"
+ }
+ }
+ },
+ "osx-x64": {
+ "components": {
+ "rhwp": {
+ "version": "0.8.6",
+ "url": "https://github.com/edwardkim/rhwp/releases/download/v0.8.6/rhwp-v0.8.6-macos-x86_64.tar.gz",
+ "sha256": "A9581617E7CCAB75481B9D36069D28C46E30A543A1FB0AA9FF581567A25321B8",
+ "size": 9776200,
+ "kind": "tar.gz"
+ },
+ "7zip": {
+ "version": "26.03",
+ "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-mac.tar.xz",
+ "sha256": "5CA87677072C59F5602E5C49BAA27D4694BACD2259B4E507F0094249D4281480",
+ "size": 1863192,
+ "kind": "tar.xz"
+ },
+ "pandoc": {
+ "version": "3.12",
+ "url": "https://github.com/jgm/pandoc/releases/download/3.12/pandoc-3.12-x86_64-macOS.zip",
+ "sha256": "18577F9460C3DC5D2651AD3BAB37D513BC2034A5A777FBE18FA0A5ACF2E936EA",
+ "size": 26442035,
+ "kind": "zip"
+ },
+ "calibre": {
+ "version": "9.15.0",
+ "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg",
+ "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019",
+ "size": 350293492,
+ "kind": "dmg"
+ },
+ "libreoffice": {
+ "version": "26.2.6",
+ "url": "https://download.documentfoundation.org/libreoffice/stable/26.2.6/mac/x86_64/LibreOffice_26.2.6_MacOS_x86-64.dmg",
+ "sha256": "135B8A95B8133396D54BF8E726DBC0066145EFA0D785963FC3D4592ACBFCFE5B",
+ "size": 308308875,
+ "kind": "dmg"
+ }
+ }
+ },
+ "osx-arm64": {
+ "components": {
+ "rhwp": {
+ "version": "0.8.6",
+ "url": "https://github.com/edwardkim/rhwp/releases/download/v0.8.6/rhwp-v0.8.6-macos-aarch64.tar.gz",
+ "sha256": "7D8928FAEB03F00C35C8028F0B562F08F3DA22BEF86F750E0D7E0CD19344EAA3",
+ "size": 8972945,
+ "kind": "tar.gz"
+ },
+ "7zip": {
+ "version": "26.03",
+ "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-mac.tar.xz",
+ "sha256": "5CA87677072C59F5602E5C49BAA27D4694BACD2259B4E507F0094249D4281480",
+ "size": 1863192,
+ "kind": "tar.xz"
+ },
+ "pandoc": {
+ "version": "3.12",
+ "url": "https://github.com/jgm/pandoc/releases/download/3.12/pandoc-3.12-arm64-macOS.zip",
+ "sha256": "F148CA09C9F36594DB527A9FC988AD736290CE428F79594C50208CD1EC58B3C0",
+ "size": 42267373,
+ "kind": "zip"
+ },
+ "calibre": {
+ "version": "9.15.0",
+ "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg",
+ "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019",
+ "size": 350293492,
+ "kind": "dmg"
+ },
+ "libreoffice": {
+ "version": "26.2.6",
+ "url": "https://download.documentfoundation.org/libreoffice/stable/26.2.6/mac/aarch64/LibreOffice_26.2.6_MacOS_aarch64.dmg",
+ "sha256": "94BB3248DF074C225490A8A6D1D9DC87C7D6783DBB7A8E9F0D0C3D94348552AF",
+ "size": 297798926,
+ "kind": "dmg"
+ }
+ }
+ }
}
}
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index a9c8892..c8cfa98 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -126,23 +126,14 @@ private static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan?
}
}
- ///
- /// Filee's own FFmpeg (engines/ffmpeg, downloaded on demand; the programs are in its bin folder), never one
- /// found elsewhere on the system.
- ///
+ /// Both tools must come from the same installation.
internal static (string Ffmpeg, string Ffprobe)? Locate()
{
- // The user's own FFmpeg (Settings → Engines) comes first.
- if (EngineEnvironment.OwnCopyFolder("ffmpeg") is { } own)
- return (Path.Combine(own, "ffmpeg.exe"), Path.Combine(own, "ffprobe.exe"));
- if (EngineEnvironment.FindBundled("ffmpeg") is not { } folder)
- return EngineEnvironment.SystemCopyFolder("ffmpeg") is { } system
- ? (Path.Combine(system, "ffmpeg.exe"), Path.Combine(system, "ffprobe.exe"))
- : null;
- var suffix = OperatingSystem.IsWindows() ? ".exe" : "";
- var ffmpeg = EngineEnvironment.FirstExisting(Path.Combine(folder, "bin", "ffmpeg" + suffix), Path.Combine(folder, "ffmpeg" + suffix));
- var ffprobe = EngineEnvironment.FirstExisting(Path.Combine(folder, "bin", "ffprobe" + suffix), Path.Combine(folder, "ffprobe" + suffix));
- return ffmpeg is null || ffprobe is null ? null : (ffmpeg, ffprobe);
+ var folder = EngineEnvironment.OwnCopyFolder("ffmpeg")
+ ?? (EngineEnvironment.FindBundled("ffmpeg") is { } bundled ? EngineEnvironment.FolderWithPrograms("ffmpeg", bundled) : null)
+ ?? EngineEnvironment.SystemCopyFolder("ffmpeg");
+ return folder is null ? null : (Path.Combine(folder, EngineEnvironment.ProgramName("ffmpeg.exe")),
+ Path.Combine(folder, EngineEnvironment.ProgramName("ffprobe.exe")));
}
/// "9.0.2" from ffmpeg -version; cached per executable so re-checking the engines stays cheap.
diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs
index 581bf28..fc65860 100644
--- a/src/Filee.Engines/Office/LibreOfficeConverter.cs
+++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs
@@ -1,7 +1,7 @@
// Office documents via LibreOffice in headless mode (MPL-2.0): the optional fallback for formats the built-in engines
// don't handle (DOC, XLS, PPT, OpenDocument, ...) and the only reader of rarer ones: Works / WPS, WordPerfect, Lotus
// Word Pro, AbiWord, Apple Pages / Numbers / Keynote, StarOffice, WPS Office, Publisher, CorelDRAW, Visio, CGM.
-// Filee downloads its own copy on request (engines/libreoffice); a LibreOffice installed on the system is never used.
+// Uses a selected installation, Filee's downloaded copy, or the system installation.
//
// Gotchas handled here:
// * Each concurrent soffice process needs its own user profile (-env:UserInstallation), otherwise
@@ -42,7 +42,7 @@ public sealed class LibreOfficeConverter : IConverter
/// CGM opens in Impress but is a drawing: exported like one (PDF, ODG, SVG, PNG).
private static readonly string[] ImpressGraphics = ["cgm"];
- private static readonly string[] Draw = ["pub", "cdr", "vsd", "odg"];
+ private static readonly string[] Draw = ["pub", "cdr", "vsd", "odg", "emf", "wmf"];
private readonly EngineEnvironment _env;
@@ -187,7 +187,11 @@ private async Task RunAsync(string soffice, int slot, string filt
};
// The HWP import filter (H2Orestart) is written in Java: point LibreOffice at the bundled JRE.
if (EngineEnvironment.FindBundled("jre") is { } jre)
+ {
+ if (Directory.Exists(Path.Combine(jre, "Contents", "Home")))
+ jre = Path.Combine(jre, "Contents", "Home");
args.Add("-env:UNO_JAVA_JFW_JREHOME=" + new Uri(jre + Path.DirectorySeparatorChar).AbsoluteUri);
+ }
args.AddRange(["--convert-to", filter, "--outdir", outDir, input]);
return await ProcessRunner.RunAsync(soffice, args, Timeout, cancellationToken);
}
@@ -271,10 +275,8 @@ void Add(IEnumerable sources, IEnumerable targets, int cost)
///
private void Locate()
{
- var bundled = EngineEnvironment.FindBundled("libreoffice");
- _soffice = EngineEnvironment.OwnProgram("libreoffice", "soffice.exe") ?? (bundled is null ? null : EngineEnvironment.FirstExisting(
- Path.Combine(bundled, "program", OperatingSystem.IsWindows() ? "soffice.exe" : "soffice"),
- Path.Combine(bundled, "Contents", "MacOS", "soffice")))
+ _soffice = EngineEnvironment.OwnProgram("libreoffice", "soffice.exe")
+ ?? EngineEnvironment.BundledProgram("libreoffice", "soffice.exe")
?? EngineEnvironment.SystemProgram("libreoffice", "soffice.exe");
_hasHwpFilter = _soffice is not null && HasH2Orestart(_soffice);
}
@@ -284,6 +286,7 @@ private static bool HasH2Orestart(string soffice)
{
try
{
+ soffice = File.ResolveLinkTarget(soffice, returnFinalTarget: true)?.FullName ?? soffice;
var root = Directory.GetParent(Path.GetDirectoryName(soffice)!)!.FullName;
foreach (var sub in new[] { "share/uno_packages", "share/extensions", "Resources/uno_packages", "Resources/extensions" })
{
diff --git a/src/Filee.Engines/Office/PandocConverter.cs b/src/Filee.Engines/Office/PandocConverter.cs
index cc191ee..61c8cf6 100644
--- a/src/Filee.Engines/Office/PandocConverter.cs
+++ b/src/Filee.Engines/Office/PandocConverter.cs
@@ -93,12 +93,10 @@ public async Task> ConvertAsync(ConversionStep step, IProg
return [output];
}
- /// Filee's own pandoc (engines/pandoc, downloaded on demand), never one found elsewhere on the system.
+ /// Uses the selected copy, a bundled copy, then the system installation.
internal static string? Locate() =>
EngineEnvironment.OwnProgram("pandoc", "pandoc.exe") ?? LocateBundled() ?? EngineEnvironment.SystemProgram("pandoc", "pandoc.exe");
private static string? LocateBundled() =>
- EngineEnvironment.FindBundled("pandoc") is { } bundled
- ? EngineEnvironment.FirstExisting(Path.Combine(bundled, OperatingSystem.IsWindows() ? "pandoc.exe" : "pandoc"))
- : null;
+ EngineEnvironment.BundledProgram("pandoc", "pandoc.exe");
}
diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs
index fd93610..4ec4f1f 100644
--- a/src/Filee.Engines/Vector/GhostscriptConverter.cs
+++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs
@@ -65,9 +65,7 @@ public async Task> ConvertAsync(ConversionStep step, IProg
?? EngineEnvironment.SystemProgram("ghostscript", "gswin64c.exe");
private static string? LocateBundled() =>
- EngineEnvironment.FindBundled("ghostscript") is { } folder
- ? EngineEnvironment.FirstExisting(Path.Combine(folder, OperatingSystem.IsWindows() ? "gswin64c.exe" : "gs"))
- : null;
+ EngineEnvironment.BundledProgram("ghostscript", "gswin64c.exe");
///
/// Converts PostScript (PS, EPS, PostScript-based Illustrator files) to PDF. EPS pages are cropped to the
diff --git a/src/Filee.Platform.Linux/Filee.Platform.Linux.csproj b/src/Filee.Platform.Linux/Filee.Platform.Linux.csproj
new file mode 100644
index 0000000..4c528cf
--- /dev/null
+++ b/src/Filee.Platform.Linux/Filee.Platform.Linux.csproj
@@ -0,0 +1,9 @@
+
+
+ Filee.Platform.Linux
+
+
+
+
+
+
diff --git a/src/Filee.Platform.Linux/LinuxDesktopIntegration.cs b/src/Filee.Platform.Linux/LinuxDesktopIntegration.cs
new file mode 100644
index 0000000..73e555c
--- /dev/null
+++ b/src/Filee.Platform.Linux/LinuxDesktopIntegration.cs
@@ -0,0 +1,226 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using Filee.Core.Settings;
+
+namespace Filee.Platform.Linux;
+
+internal sealed class LinuxDesktopIntegration(string configHome, string dataHome)
+{
+ internal const string ThunarActionId = "filee-convert";
+ internal const string ThunarAcceleratorPath = "/ThunarActions/uca-action-" + ThunarActionId;
+
+ internal static string? ToThunarAccelerator(TriggerGesture gesture)
+ {
+ if (!gesture.Enabled || gesture.Kind != TriggerKind.KeyChord || string.IsNullOrEmpty(gesture.Key))
+ return null;
+ var key = gesture.Key switch
+ {
+ "Space" => "space",
+ "Enter" => "Return",
+ "Backspace" => "BackSpace",
+ "PageUp" => "Page_Up",
+ "PageDown" => "Page_Down",
+ "Semicolon" => "semicolon",
+ "Equals" => "equal",
+ "Comma" => "comma",
+ "Minus" => "minus",
+ "Period" => "period",
+ "Slash" => "slash",
+ "BackQuote" => "grave",
+ "OpenBracket" => "bracketleft",
+ "Backslash" or "BackSlash" => "backslash",
+ "CloseBracket" => "bracketright",
+ "Quote" => "apostrophe",
+ "PrintScreen" => "Print",
+ "Pause" => "Pause",
+ "Tab" or "Escape" or "Delete" or "Insert" or "Home" or "End" or "Left" or "Right" or "Up" or "Down" => gesture.Key,
+ { Length: 1 } single when char.IsAsciiLetterOrDigit(single[0]) => single.ToLowerInvariant(),
+ { Length: 7 } numberPad when numberPad.StartsWith("NumPad", StringComparison.Ordinal) && char.IsAsciiDigit(numberPad[6]) => "KP_" + numberPad[6],
+ var function when function.StartsWith('F') && int.TryParse(function.AsSpan(1), out var number) && number is >= 1 and <= 24 => function,
+ _ => throw new NotSupportedException($"The key '{gesture.Key}' has no Thunar shortcut mapping."),
+ };
+ return (gesture.Modifiers.HasFlag(ModifierKeys.Ctrl) ? "" : "")
+ + (gesture.Modifiers.HasFlag(ModifierKeys.Alt) ? "" : "")
+ + (gesture.Modifiers.HasFlag(ModifierKeys.Shift) ? "" : "")
+ + (gesture.Modifiers.HasFlag(ModifierKeys.Meta) ? "" : "") + key;
+ }
+
+ public bool SetThunarShortcut(string? accelerator)
+ {
+ var path = Path.Combine(configHome, "Thunar", "accels.scm");
+ var contents = File.Exists(path) ? File.ReadAllText(path) : "";
+ var updated = UpdateThunarShortcut(contents, accelerator);
+ if (contents == updated)
+ return false;
+ Write(path, updated);
+ return true;
+ }
+
+ internal static string UpdateThunarShortcut(string contents, string? accelerator)
+ {
+ if (accelerator is not null && !Regex.IsMatch(accelerator,
+ @"\A(?:<(?:Control|Primary|Alt|Shift|Super|Meta)>)*[A-Za-z0-9_]+\z", RegexOptions.CultureInvariant))
+ throw new ArgumentException("Use a GTK shortcut such as space.", nameof(accelerator));
+ var bindingPattern = @"(?m)^[ \t]*(?;[ \t]*)?\(gtk_accel_path ""(?[^""\r\n]+)"" ""(?[^""\r\n]*)""\)[ \t]*\r?$";
+ foreach (Match match in Regex.Matches(contents, bindingPattern, RegexOptions.CultureInvariant))
+ {
+ if (accelerator is not null && !match.Groups["disabled"].Success
+ && match.Groups["path"].Value != ThunarAcceleratorPath
+ && string.Equals(NormalizeAccelerator(match.Groups["key"].Value), NormalizeAccelerator(accelerator), StringComparison.Ordinal))
+ throw new InvalidOperationException($"Thunar already uses {accelerator} for {match.Groups["path"].Value}.");
+ }
+ var ownPattern = @"(?m)^[ \t]*;?[ \t]*\(gtk_accel_path """ + Regex.Escape(ThunarAcceleratorPath)
+ + @""" ""[^""\r\n]*""\)[ \t]*(?:\r?\n|$)";
+ var updated = Regex.Replace(contents, ownPattern, "", RegexOptions.CultureInvariant);
+ if (accelerator is not null)
+ {
+ if (updated.Length > 0 && !updated.EndsWith('\n'))
+ updated += "\n";
+ updated += $"(gtk_accel_path \"{ThunarAcceleratorPath}\" \"{accelerator}\")\n";
+ }
+ return updated;
+ }
+
+ private static string NormalizeAccelerator(string value)
+ {
+ var pieces = value.Replace("", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant().Split('>');
+ return string.Join('>', pieces[..^1].Order(StringComparer.Ordinal)) + ">" + pieces[^1];
+ }
+
+ public void SetAutostart(bool enabled, string executablePath)
+ {
+ var path = Path.Combine(configHome, "autostart", "filee.desktop");
+ if (!enabled)
+ {
+ File.Delete(path);
+ return;
+ }
+ Write(path, $"[Desktop Entry]\nType=Application\nName=Filee\nExec={DesktopArgument(executablePath)} --background\nTerminal=false\nX-GNOME-Autostart-enabled=true\n");
+ }
+
+ public void SetContextMenu(bool enabled, string executablePath, string label)
+ {
+ var thunarPath = Path.Combine(configHome, "Thunar", "uca.xml");
+ var menuPaths = new[]
+ {
+ Path.Combine(dataHome, "Thunar", "sendto", "filee.desktop"),
+ Path.Combine(dataHome, "nautilus", "scripts", "Filee"),
+ Path.Combine(dataHome, "nemo", "actions", "filee.nemo_action"),
+ Path.Combine(dataHome, "kio", "servicemenus", "filee.desktop"),
+ };
+ var thunar = LoadThunarActions(thunarPath);
+ UpdateThunarAction(thunar, enabled, executablePath, label);
+ if (!enabled)
+ {
+ if (File.Exists(thunarPath))
+ Write(thunarPath, thunar.ToString());
+ foreach (var path in menuPaths)
+ File.Delete(path);
+ return;
+ }
+
+ var command = $"{DesktopArgument(executablePath)} --convert %F";
+ var name = DesktopValue(label);
+ Write(thunarPath, thunar.ToString());
+ Write(menuPaths[0], $"[Desktop Entry]\nType=Application\nName={name}\nExec={command}\nIcon=document-save-as\nTerminal=false\n");
+ Write(menuPaths[1], $"#!/bin/sh\n[ \"$#\" -gt 0 ] || exit 0\nfor file do\n case \"$file\" in\n /*) set -- \"$@\" \"$file\" ;;\n *) set -- \"$@\" \"$PWD/$file\" ;;\n esac\n shift\ndone\nexec {ShellArgument(executablePath)} --convert \"$@\"\n", executable: true);
+ Write(menuPaths[2], $"[Nemo Action]\nName={name.Replace("%", "%%", StringComparison.Ordinal)}\nExec={command}\nSelection=notnone\nExtensions=nodirs;\nUriScheme=file\nIcon-Name=document-save-as\n");
+ Write(menuPaths[3], $"[Desktop Entry]\nType=Service\nMimeType=application/octet-stream;\nActions=convert;\nX-KDE-Protocols=file\n\n[Desktop Action convert]\nName={name}\nIcon=document-save-as\nExec={command}\n", executable: true);
+ }
+
+ internal static void UpdateThunarAction(XDocument document, bool enabled, string executablePath, string label)
+ {
+ if (document.Root?.Name != "actions")
+ throw new InvalidDataException("Thunar's custom actions file has an unexpected root element.");
+ document.Root.Elements("action")
+ .Where(action => (string?)action.Element("unique-id") == ThunarActionId).Remove();
+ if (!enabled)
+ return;
+ var command = ShellArgument(executablePath).Replace("%", "%%", StringComparison.Ordinal) + " --convert %F";
+ document.Root.Add(new XElement("action",
+ new XElement("icon", "document-save-as"),
+ new XElement("name", label),
+ new XElement("unique-id", ThunarActionId),
+ new XElement("command", command),
+ new XElement("description", label),
+ new XElement("patterns", "*"),
+ new XElement("audio-files"),
+ new XElement("image-files"),
+ new XElement("other-files"),
+ new XElement("text-files"),
+ new XElement("video-files")));
+ }
+
+ private static XDocument LoadThunarActions(string path)
+ {
+ if (File.Exists(path))
+ return XDocument.Load(path, LoadOptions.PreserveWhitespace);
+ var systemDirectories = (Environment.GetEnvironmentVariable("XDG_CONFIG_DIRS") ?? "/etc/xdg")
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var directory in systemDirectories.Where(Path.IsPathFullyQualified))
+ {
+ var defaults = Path.Combine(directory, "Thunar", "uca.xml");
+ if (File.Exists(defaults))
+ return XDocument.Load(defaults, LoadOptions.PreserveWhitespace);
+ }
+ return new XDocument(new XElement("actions"));
+ }
+
+ internal static string DesktopArgument(string value)
+ {
+ ValidateCommandValue(value);
+ if (value.Contains('='))
+ throw new ArgumentException("Desktop launchers do not support '=' in an executable path.", nameof(value));
+ var argument = new StringBuilder("\"");
+ foreach (var character in value)
+ {
+ if (character is '"' or '`' or '$' or '\\')
+ argument.Append('\\');
+ argument.Append(character);
+ }
+ argument.Append('"');
+ return DesktopValue(argument.ToString()).Replace("%", "%%", StringComparison.Ordinal);
+ }
+
+ internal static string ShellArgument(string value)
+ {
+ ValidateCommandValue(value);
+ return "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
+ }
+
+ internal static string DesktopValue(string value) => value
+ .Replace("\\", "\\\\", StringComparison.Ordinal)
+ .Replace("\r", "\\r", StringComparison.Ordinal)
+ .Replace("\n", "\\n", StringComparison.Ordinal)
+ .Replace("\t", "\\t", StringComparison.Ordinal);
+
+ private static void ValidateCommandValue(string value)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value);
+ if (value.IndexOfAny(['\0', '\r', '\n']) >= 0)
+ throw new ArgumentException("The executable path cannot contain a null character or line break.", nameof(value));
+ }
+
+ private static void Write(string path, string contents, bool executable = false)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllText(temporary, contents.ReplaceLineEndings("\n"), new UTF8Encoding(false));
+ if (!OperatingSystem.IsWindows())
+ {
+ var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
+ if (executable)
+ mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+ File.SetUnixFileMode(temporary, mode);
+ }
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ File.Delete(temporary);
+ }
+ }
+}
diff --git a/src/Filee.Platform.Linux/LinuxPlatformServices.cs b/src/Filee.Platform.Linux/LinuxPlatformServices.cs
new file mode 100644
index 0000000..7ee2d01
--- /dev/null
+++ b/src/Filee.Platform.Linux/LinuxPlatformServices.cs
@@ -0,0 +1,111 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.Versioning;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+
+namespace Filee.Platform.Linux;
+
+[SupportedOSPlatform("linux")]
+public sealed class LinuxPlatformServices : IPlatformServices
+{
+ private readonly LinuxDesktopIntegration _integration = new(
+ XdgDirectory("XDG_CONFIG_HOME", ".config"), XdgDirectory("XDG_DATA_HOME", ".local/share"));
+
+ private static readonly HashSet FileManagers = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "thunar", "nautilus", "org.gnome.nautilus", "nemo", "dolphin", "caja", "pcmanfm", "pcmanfm-qt", "xfdesktop",
+ };
+
+ public static bool IsWaylandSession =>
+ string.Equals(Environment.GetEnvironmentVariable("XDG_SESSION_TYPE"), "wayland", StringComparison.OrdinalIgnoreCase)
+ || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"));
+
+ public bool SupportsGlobalPointerGestures => X11WindowSystem.IsAvailable;
+ public bool SupportsSelectionShortcut => false;
+ public string SelectionShortcutUnavailableReason =>
+ "Linux file managers do not expose a shared selected-file API. Use the Filee context action or its Thunar shortcut.";
+ public string? GlobalPointerGesturesUnavailableReason => SupportsGlobalPointerGestures ? null : IsWaylandSession
+ ? "The cursor-following gesture requires an X11 desktop session. File actions and the drop zone remain available on Wayland."
+ : "The cursor-following gesture requires an X11 display.";
+ public string ThunarShortcutRestartRequired =>
+ "Close Thunar before changing its shortcut, then reopen it to load the Filee action and shortcut.";
+
+ public bool IsFileManagerAt(int x, int y) => ProcessNameAt(x, y) is { } name && FileManagers.Contains(name);
+ public string? ProcessNameAt(int x, int y) => X11WindowSystem.ProcessNameAt(x, y);
+ public IReadOnlyList GetFileManagerSelection() => throw new NotSupportedException(SelectionShortcutUnavailableReason);
+ public int SystemDragThreshold => 8;
+
+ public bool PrefersReducedMotion
+ {
+ get
+ {
+ var result = Run("gsettings", ["get", "org.gnome.desktop.interface", "enable-animations"], allowFailure: true);
+ return result?.Trim() == "false";
+ }
+ }
+
+ public void SetStartWithSystem(bool enabled, string executablePath) => _integration.SetAutostart(enabled, executablePath);
+ public void SetContextMenu(bool enabled, string executablePath, string label) => _integration.SetContextMenu(enabled, executablePath, label);
+ public bool SetThunarShortcut(string? accelerator) => _integration.SetThunarShortcut(accelerator);
+ public static string? ToThunarAccelerator(TriggerGesture gesture) => LinuxDesktopIntegration.ToThunarAccelerator(gesture);
+ public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported;
+ public Task SetModernContextMenuAsync(bool enabled, string executablePath) =>
+ Task.FromResult(new ModernContextMenuResult(false, Error: "Use the Linux file manager context action."));
+
+ public void RevealInFileManager(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ if (File.Exists(fullPath))
+ {
+ var uri = new Uri(fullPath).AbsoluteUri;
+ var result = Run("gdbus", ["call", "--session", "--dest", "org.freedesktop.FileManager1", "--object-path",
+ "/org/freedesktop/FileManager1", "--method", "org.freedesktop.FileManager1.ShowItems",
+ "['" + uri.Replace("'", "\\'", StringComparison.Ordinal) + "']", ""], allowFailure: true);
+ if (result is not null)
+ return;
+ }
+ var directory = Directory.Exists(fullPath) ? fullPath : Path.GetDirectoryName(fullPath);
+ if (directory is null || !Directory.Exists(directory))
+ throw new DirectoryNotFoundException($"Cannot find the folder containing '{path}'.");
+ var opener = new ProcessStartInfo("xdg-open") { UseShellExecute = false };
+ opener.ArgumentList.Add(directory);
+ using var process = Process.Start(opener) ?? throw new InvalidOperationException("Could not start xdg-open.");
+ }
+
+ public void RaiseTopmost(nint windowHandle) => X11WindowSystem.RaiseTopmost(windowHandle);
+
+ private static string XdgDirectory(string variable, string fallback)
+ {
+ var configured = Environment.GetEnvironmentVariable(variable);
+ return !string.IsNullOrEmpty(configured) && Path.IsPathFullyQualified(configured)
+ ? configured
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), fallback);
+ }
+
+ private static string? Run(string executable, IEnumerable arguments, bool allowFailure = false)
+ {
+ try
+ {
+ var info = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
+ foreach (var argument in arguments)
+ info.ArgumentList.Add(argument);
+ using var process = Process.Start(info) ?? throw new InvalidOperationException($"Could not start {executable}.");
+ var output = process.StandardOutput.ReadToEndAsync();
+ var error = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(3000))
+ {
+ process.Kill(entireProcessTree: true);
+ throw new TimeoutException($"{executable} did not respond within 3 seconds.");
+ }
+ if (process.ExitCode != 0)
+ throw new InvalidOperationException($"{executable}: {error.GetAwaiter().GetResult().Trim()}");
+ return output.GetAwaiter().GetResult();
+ }
+ catch (Exception exception) when (allowFailure && exception is Win32Exception or InvalidOperationException or TimeoutException)
+ {
+ Trace.TraceInformation($"Optional Linux integration command failed: {exception.Message}");
+ return null;
+ }
+ }
+}
diff --git a/src/Filee.Platform.Linux/X11WindowSystem.cs b/src/Filee.Platform.Linux/X11WindowSystem.cs
new file mode 100644
index 0000000..96cfaac
--- /dev/null
+++ b/src/Filee.Platform.Linux/X11WindowSystem.cs
@@ -0,0 +1,179 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Filee.Platform.Linux;
+
+internal static class X11WindowSystem
+{
+ private const string Library = "libX11.so.6";
+
+ internal static bool IsAvailable => OperatingSystem.IsLinux()
+ && !LinuxPlatformServices.IsWaylandSession
+ && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"));
+
+ internal static string? ProcessNameAt(int x, int y)
+ {
+ if (!IsAvailable)
+ return null;
+ var display = OpenDisplay();
+ if (display == 0)
+ return null;
+ try
+ {
+ // Keep windows alive between hit testing and reading their properties.
+ XGrabServer(display);
+ var root = XDefaultRootWindow(display);
+ var window = root;
+ string? name = null;
+ for (var depth = 0; depth < 32; depth++)
+ {
+ name = ReadProcessName(display, window) ?? name;
+ if (XTranslateCoordinates(display, root, window, x, y, out _, out _, out var child) == 0 || child == 0)
+ break;
+ window = child;
+ }
+ return name;
+ }
+ finally
+ {
+ XUngrabServer(display);
+ XCloseDisplay(display);
+ }
+ }
+
+ internal static void RaiseTopmost(nint window)
+ {
+ if (window == 0 || !IsAvailable)
+ return;
+ var display = OpenDisplay();
+ if (display == 0)
+ return;
+ try
+ {
+ var above = XInternAtom(display, "_NET_WM_STATE_ABOVE", 0);
+ SendClientMessage(display, window, "_NET_WM_STATE", 1, (nint)above, 0, 1);
+ SendClientMessage(display, window, "_NET_RESTACK_WINDOW", 1, 0, 0, 0);
+ XFlush(display);
+ }
+ finally
+ {
+ XCloseDisplay(display);
+ }
+ }
+
+ private static nint OpenDisplay()
+ {
+ try { return XOpenDisplay(0); }
+ catch (DllNotFoundException exception)
+ {
+ Trace.TraceWarning($"X11 integration is unavailable: {exception.Message}");
+ return 0;
+ }
+ catch (EntryPointNotFoundException exception)
+ {
+ Trace.TraceWarning($"X11 integration is unavailable: {exception.Message}");
+ return 0;
+ }
+ }
+
+ private static string? ReadProcessName(nint display, nuint window)
+ {
+ var property = XInternAtom(display, "_NET_WM_PID", 1);
+ if (property != 0 && XGetWindowProperty(display, window, property, 0, 1, 0, 0,
+ out _, out var format, out var count, out _, out var data) == 0)
+ {
+ try
+ {
+ if (format == 32 && count > 0 && data != 0)
+ {
+ var pid = unchecked((int)Marshal.ReadIntPtr(data));
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ return process.ProcessName;
+ }
+ catch (ArgumentException) { }
+ catch (InvalidOperationException) { }
+ catch (Win32Exception) { }
+ }
+ }
+ finally
+ {
+ if (data != 0)
+ XFree(data);
+ }
+ }
+ var windowClass = XInternAtom(display, "WM_CLASS", 1);
+ if (windowClass == 0 || XGetWindowProperty(display, window, windowClass, 0, 256, 0, 0,
+ out _, out var classFormat, out var classCount, out _, out var classData) != 0)
+ return null;
+ try
+ {
+ if (classData == 0 || classFormat != 8 || classCount == 0)
+ return null;
+ var bytes = new byte[Math.Min((int)classCount, 1024)];
+ Marshal.Copy(classData, bytes, 0, bytes.Length);
+ return Encoding.UTF8.GetString(bytes).Split('\0', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ }
+ finally
+ {
+ if (classData != 0)
+ XFree(classData);
+ }
+ }
+
+ private static void SendClientMessage(nint display, nint window, string message, nint a, nint b, nint c, nint d)
+ {
+ var clientEvent = new XClientMessageEvent
+ {
+ Type = 33,
+ SendEvent = 1,
+ Display = display,
+ Window = (nuint)window,
+ MessageType = XInternAtom(display, message, 0),
+ Format = 32,
+ Data0 = a,
+ Data1 = b,
+ Data2 = c,
+ Data3 = d,
+ };
+ XSendEvent(display, XDefaultRootWindow(display), 0, (1L << 20) | (1L << 19), ref clientEvent);
+ }
+
+ // XEvent is a union of 24 native longs on supported 64-bit Linux targets.
+ [StructLayout(LayoutKind.Sequential, Size = 192)]
+ private struct XClientMessageEvent
+ {
+ public int Type;
+ public nuint Serial;
+ public int SendEvent;
+ public nint Display;
+ public nuint Window;
+ public nuint MessageType;
+ public int Format;
+ public nint Data0;
+ public nint Data1;
+ public nint Data2;
+ public nint Data3;
+ public nint Data4;
+ }
+
+ [DllImport(Library)] private static extern nint XOpenDisplay(nint displayName);
+ [DllImport(Library)] private static extern int XCloseDisplay(nint display);
+ [DllImport(Library)] private static extern nuint XDefaultRootWindow(nint display);
+ [DllImport(Library)] private static extern int XGrabServer(nint display);
+ [DllImport(Library)] private static extern int XUngrabServer(nint display);
+ [DllImport(Library)]
+ private static extern int XTranslateCoordinates(nint display, nuint source, nuint destination,
+ int x, int y, out int destinationX, out int destinationY, out nuint child);
+ [DllImport(Library)] private static extern nuint XInternAtom(nint display, [MarshalAs(UnmanagedType.LPUTF8Str)] string name, int onlyIfExists);
+ [DllImport(Library)]
+ private static extern int XGetWindowProperty(nint display, nuint window, nuint property,
+ nint offset, nint length, int delete, nuint requestedType, out nuint actualType, out int actualFormat,
+ out nuint itemCount, out nuint bytesAfter, out nint propertyData);
+ [DllImport(Library)] private static extern int XFree(nint data);
+ [DllImport(Library)] private static extern int XSendEvent(nint display, nuint window, int propagate, long mask, ref XClientMessageEvent clientEvent);
+ [DllImport(Library)] private static extern int XFlush(nint display);
+}
diff --git a/src/Filee.Platform.MacOS/Filee.Platform.MacOS.csproj b/src/Filee.Platform.MacOS/Filee.Platform.MacOS.csproj
new file mode 100644
index 0000000..5056b1d
--- /dev/null
+++ b/src/Filee.Platform.MacOS/Filee.Platform.MacOS.csproj
@@ -0,0 +1,9 @@
+
+
+ Filee.Platform.MacOS
+
+
+
+
+
+
diff --git a/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs b/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
new file mode 100644
index 0000000..de5673d
--- /dev/null
+++ b/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
@@ -0,0 +1,89 @@
+using System.Xml.Linq;
+
+namespace Filee.Platform.MacOS;
+
+internal static class MacOSIntegrationFiles
+{
+ internal static string LaunchAgent(string executablePath) => Plist(Dict(
+ ("Label", "com.filee.app"),
+ ("AssociatedBundleIdentifiers", Array("com.filee.app")),
+ ("ProgramArguments", Array(executablePath, "--background")),
+ ("LimitLoadToSessionType", "Aqua"),
+ ("RunAtLoad", true),
+ ("KeepAlive", false)));
+
+ internal static string ServiceInfo(string label) => Plist(Dict(
+ ("CFBundleName", label),
+ ("CFBundleIdentifier", "com.filee.app.convert-service"),
+ ("NSServices", Array(Dict(
+ ("NSMenuItem", Dict(("default", label))),
+ ("NSMessage", "runWorkflowAsService"),
+ ("NSRequiredContext", Dict(("NSApplicationIdentifier", "com.apple.finder"))),
+ ("NSSendFileTypes", Array("public.item")))))));
+
+ internal static string Workflow(string executablePath) => Plist(Dict(
+ ("AMDocumentVersion", "2"),
+ ("actions", Array(Dict(("action", Dict(
+ ("AMAccepts", Dict(("Container", "List"), ("Optional", false), ("Types", Array("com.apple.cocoa.path")))),
+ ("AMProvides", Dict(("Container", "List"), ("Types", Array("com.apple.cocoa.string")))),
+ ("AMApplication", Array("Automator")),
+ ("AMActionVersion", "2.0.3"),
+ ("AMParameterProperties", Dict(("COMMAND_STRING", Dict()), ("inputMethod", Dict()), ("shell", Dict()), ("source", Dict()))),
+ ("ActionBundlePath", "/System/Library/Automator/Run Shell Script.action"),
+ ("ActionName", "Run Shell Script"),
+ ("ActionParameters", Dict(
+ ("COMMAND_STRING", ConversionCommand(executablePath)),
+ ("CheckedForUserDefaultShell", true),
+ ("inputMethod", 1),
+ ("shell", "/bin/sh"),
+ ("source", ""))),
+ ("BundleIdentifier", "com.apple.RunShellScript"),
+ ("Class Name", "RunShellScriptAction"),
+ ("CanShowWhenRun", false),
+ ("InputUUID", "E79B8709-D276-4DB2-A0D9-07FDF599774E"),
+ ("OutputUUID", "09A1D308-CB6C-4EC8-9545-68272C47BA52"),
+ ("UUID", "ED186538-5D03-4A64-88BD-52FC9A905E4F"),
+ ("arguments", Dict())))))),
+ ("connectors", Dict()),
+ ("workflowMetaData", Dict(
+ ("serviceApplicationBundleID", "com.apple.finder"),
+ ("serviceApplicationPath", "/System/Library/CoreServices/Finder.app"),
+ ("serviceInputTypeIdentifier", "com.apple.Automator.fileSystemObject"),
+ ("serviceOutputTypeIdentifier", "com.apple.Automator.nothing"),
+ ("serviceProcessesInput", true),
+ ("workflowTypeIdentifier", "com.apple.Automator.servicesMenu")))));
+
+ internal static string ConversionCommand(string executablePath)
+ {
+ const string contents = "/Contents/MacOS/";
+ var index = executablePath.LastIndexOf(contents, StringComparison.Ordinal);
+ if (index > 0 && executablePath[..index].EndsWith(".app", StringComparison.OrdinalIgnoreCase))
+ {
+ // A fresh process forwards arguments to Filee's running instance; open otherwise discards them.
+ return $"exec /usr/bin/open -n -a {ShellQuote(executablePath[..index])} --args --convert \"$@\"";
+ }
+ return $"exec {ShellQuote(executablePath)} --convert \"$@\"";
+ }
+
+ private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'";
+
+ private static string Plist(XElement dictionary) =>
+ "\n" +
+ new XDocument(
+ new XDocumentType("plist", "-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd", null),
+ new XElement("plist", new XAttribute("version", "1.0"), dictionary));
+
+ private static XElement Dict(params (string Key, object Value)[] entries) => new("dict",
+ entries.SelectMany(entry => new[] { new XElement("key", entry.Key), Value(entry.Value) }));
+
+ private static XElement Array(params object[] values) => new("array", values.Select(Value));
+
+ private static XElement Value(object value) => value switch
+ {
+ XElement element => element,
+ bool flag => new XElement(flag ? "true" : "false"),
+ int number => new XElement("integer", number),
+ string text => new XElement("string", text),
+ _ => throw new ArgumentException("Unsupported property-list value.", nameof(value)),
+ };
+}
diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
new file mode 100644
index 0000000..9b5963a
--- /dev/null
+++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
@@ -0,0 +1,149 @@
+using System.Diagnostics;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Text.Json;
+using Filee.Core.Platform;
+
+namespace Filee.Platform.MacOS;
+
+[SupportedOSPlatform("macos")]
+public sealed class MacOSPlatformServices : IPlatformServices
+{
+ private const string FinderSelectionScript = """
+ ObjC.import('Foundation');
+ const finder = Application('com.apple.finder');
+ JSON.stringify(finder.selection().map(item => ObjC.unwrap($.NSURL.URLWithString(item.url()).path)));
+ """;
+
+ public bool HasAccessibilityPermission => NativeMethods.AXIsProcessTrusted();
+
+ public bool SupportsGlobalPointerGestures => HasAccessibilityPermission;
+
+ public bool SupportsSelectionShortcut => true;
+
+ public string? LastSelectionError { get; private set; }
+
+ public bool IsFileManagerAt(int x, int y) =>
+ string.Equals(ProcessNameAt(x, y), "Finder", StringComparison.OrdinalIgnoreCase);
+
+ public string? ProcessNameAt(int x, int y) => NativeMethods.ProcessNameAt(x, y);
+
+ public IReadOnlyList GetFileManagerSelection()
+ {
+ LastSelectionError = null;
+ if (!NativeMethods.IsFinderFrontmost())
+ return [];
+
+ try
+ {
+ var output = Run("/usr/bin/osascript", ["-l", "JavaScript", "-e", FinderSelectionScript], 30_000);
+ return ParseSelection(output).Where(path => File.Exists(path) || Directory.Exists(path)).ToArray();
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException or InvalidOperationException or JsonException)
+ {
+ LastSelectionError = ex.Message;
+ Trace.TraceWarning($"Reading Finder selection failed: {ex.Message}");
+ throw new InvalidOperationException($"Could not read Finder selection. Allow Filee to control Finder in System Settings > Privacy & Security > Automation. {ex.Message}", ex);
+ }
+ }
+
+ // AppKit exposes no public system drag-distance preference; match its usual four-point threshold.
+ public int SystemDragThreshold => 4;
+
+ public bool PrefersReducedMotion => NativeMethods.PrefersReducedMotion();
+
+ public void SetStartWithSystem(bool enabled, string executablePath)
+ {
+ var path = Path.Combine(UserLibrary, "LaunchAgents", "com.filee.app.plist");
+ if (!enabled)
+ {
+ File.Delete(path);
+ return;
+ }
+
+ RequireExecutable(executablePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, MacOSIntegrationFiles.LaunchAgent(executablePath), new UTF8Encoding(false));
+ }
+
+ public void SetContextMenu(bool enabled, string executablePath, string label)
+ {
+ var workflow = Path.Combine(UserLibrary, "Services", "Filee.workflow");
+ var contents = Path.Combine(workflow, "Contents");
+ if (enabled)
+ {
+ RequireExecutable(executablePath);
+ Directory.CreateDirectory(contents);
+ File.WriteAllText(Path.Combine(contents, "Info.plist"), MacOSIntegrationFiles.ServiceInfo(label), new UTF8Encoding(false));
+ File.WriteAllText(Path.Combine(contents, "document.wflow"), MacOSIntegrationFiles.Workflow(executablePath), new UTF8Encoding(false));
+ }
+ else if (Directory.Exists(contents))
+ {
+ File.Delete(Path.Combine(contents, "Info.plist"));
+ File.Delete(Path.Combine(contents, "document.wflow"));
+ if (!Directory.EnumerateFileSystemEntries(contents).Any())
+ Directory.Delete(contents);
+ if (!Directory.EnumerateFileSystemEntries(workflow).Any())
+ Directory.Delete(workflow);
+ }
+
+ Run("/System/Library/CoreServices/pbs", ["-update"], 10_000);
+ }
+
+ public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported;
+
+ public Task SetModernContextMenuAsync(bool enabled, string executablePath) =>
+ Task.FromResult(new ModernContextMenuResult(false, Error: "The Windows Explorer extension is unavailable on macOS. Use the Finder Quick Action."));
+
+ public void RevealInFileManager(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ Run("/usr/bin/open", File.Exists(fullPath) ? ["-R", fullPath] : [fullPath], 10_000);
+ }
+
+ public void OpenAccessibilitySettings() =>
+ Run("/usr/bin/open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"], 10_000);
+
+ public void RaiseTopmost(nint windowHandle)
+ {
+ if (windowHandle != 0)
+ NativeMethods.RaiseTopmost(windowHandle);
+ }
+
+ internal static IReadOnlyList ParseSelection(string json) =>
+ JsonSerializer.Deserialize(json) ?? [];
+
+ private static string UserLibrary => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library");
+
+ private static void RequireExecutable(string path)
+ {
+ if (!Path.IsPathFullyQualified(path) || !File.Exists(path))
+ throw new FileNotFoundException("Filee's executable must have an existing absolute path.", path);
+ }
+
+ private static string Run(string executable, IReadOnlyList arguments, int timeoutMs)
+ {
+ var start = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ foreach (var argument in arguments)
+ start.ArgumentList.Add(argument);
+ using var process = Process.Start(start) ?? throw new IOException($"Could not start {executable}.");
+ var output = process.StandardOutput.ReadToEndAsync();
+ var error = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(timeoutMs))
+ {
+ try { process.Kill(entireProcessTree: true); }
+ catch (InvalidOperationException) { }
+ throw new TimeoutException($"{Path.GetFileName(executable)} did not respond within {timeoutMs / 1000} seconds.");
+ }
+ if (process.ExitCode != 0)
+ throw new IOException($"{Path.GetFileName(executable)} failed ({process.ExitCode}): {error.GetAwaiter().GetResult().Trim()}");
+ return output.GetAwaiter().GetResult();
+ }
+}
diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs
new file mode 100644
index 0000000..e5247b3
--- /dev/null
+++ b/src/Filee.Platform.MacOS/NativeMethods.cs
@@ -0,0 +1,158 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace Filee.Platform.MacOS;
+
+[SupportedOSPlatform("macos")]
+internal static class NativeMethods
+{
+ private const string CoreGraphics = "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics";
+ private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+ private const string ApplicationServices = "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
+ private const string ObjC = "/usr/lib/libobjc.A.dylib";
+ private static readonly nint AppKit = NativeLibrary.Load("/System/Library/Frameworks/AppKit.framework/AppKit");
+ private static readonly nint BoundsKey = CFStringCreateWithCString(0, "kCGWindowBounds", 0x08000100);
+ private static readonly nint PidKey = CFStringCreateWithCString(0, "kCGWindowOwnerPID", 0x08000100);
+ private static readonly nint AlphaKey = CFStringCreateWithCString(0, "kCGWindowAlpha", 0x08000100);
+
+ internal static string? ProcessNameAt(int x, int y)
+ {
+ var windows = CGWindowListCopyWindowInfo(1, 0);
+ if (windows == 0)
+ return null;
+ try
+ {
+ for (nint index = 0; index < CFArrayGetCount(windows); index++)
+ {
+ var window = CFArrayGetValueAtIndex(windows, index);
+ var boundsValue = CFDictionaryGetValue(window, BoundsKey);
+ if (boundsValue == 0 || !CGRectMakeWithDictionaryRepresentation(boundsValue, out var bounds))
+ continue;
+ // Quartz and Avalonia's macOS desktop coordinates use top-left points, including on Retina displays.
+ if (x < bounds.X || y < bounds.Y || x >= bounds.X + bounds.Width || y >= bounds.Y + bounds.Height)
+ continue;
+ var alphaValue = CFDictionaryGetValue(window, AlphaKey);
+ if (alphaValue != 0 && CFNumberGetDouble(alphaValue, 6, out var alpha) && alpha <= 0)
+ continue;
+ var pidValue = CFDictionaryGetValue(window, PidKey);
+ if (pidValue == 0 || !CFNumberGetInt(pidValue, 3, out var pid))
+ continue;
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ var name = process.ProcessName;
+ if (!string.Equals(name, "WindowServer", StringComparison.OrdinalIgnoreCase))
+ return name;
+ }
+ catch (ArgumentException) { }
+ catch (InvalidOperationException) { }
+ catch (System.ComponentModel.Win32Exception) { }
+ }
+ return null;
+ }
+ finally
+ {
+ CFRelease(windows);
+ }
+ }
+
+ internal static bool IsFinderFrontmost()
+ {
+ using var pool = new AutoreleasePool();
+ var workspace = Send(objc_getClass("NSWorkspace"), sel_registerName("sharedWorkspace"));
+ var application = Send(workspace, sel_registerName("frontmostApplication"));
+ var bundle = Send(application, sel_registerName("bundleIdentifier"));
+ return Marshal.PtrToStringUTF8(Send(bundle, sel_registerName("UTF8String"))) == "com.apple.finder";
+ }
+
+ internal static bool PrefersReducedMotion()
+ {
+ using var pool = new AutoreleasePool();
+ var workspace = Send(objc_getClass("NSWorkspace"), sel_registerName("sharedWorkspace"));
+ return SendBool(workspace, sel_registerName("accessibilityDisplayShouldReduceMotion"));
+ }
+
+ internal static void RaiseTopmost(nint window)
+ {
+ const nint canJoinAllSpaces = 1, moveToActiveSpace = 1 << 1;
+ const nint fullScreenPrimary = 1 << 7, fullScreenAuxiliary = 1 << 8, fullScreenNone = 1 << 9;
+ var behavior = Send(window, sel_registerName("collectionBehavior"));
+ behavior = (behavior & ~(moveToActiveSpace | fullScreenPrimary | fullScreenNone)) | canJoinAllSpaces | fullScreenAuxiliary;
+ if (OperatingSystem.IsMacOSVersionAtLeast(13))
+ {
+ const nint primary = 1 << 16, auxiliary = 1 << 17, canJoinAllApplications = 1 << 18;
+ behavior = (behavior & ~(primary | auxiliary)) | canJoinAllApplications;
+ }
+ // Finder can occupy a full-screen Space that would otherwise hide another app's toolbar.
+ SendInteger(window, sel_registerName("setCollectionBehavior:"), behavior);
+ SendInteger(window, sel_registerName("setLevel:"), 3);
+ SendVoid(window, sel_registerName("orderFrontRegardless"));
+ }
+
+ private readonly struct AutoreleasePool : IDisposable
+ {
+ private readonly nint _pool = Send(Send(objc_getClass("NSAutoreleasePool"), sel_registerName("alloc")), sel_registerName("init"));
+ public AutoreleasePool() { }
+ public void Dispose() => SendVoid(_pool, sel_registerName("drain"));
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CGRect
+ {
+ public double X, Y, Width, Height;
+ }
+
+ [DllImport(ApplicationServices)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static extern bool AXIsProcessTrusted();
+
+ [DllImport(CoreGraphics)]
+ private static extern nint CGWindowListCopyWindowInfo(uint option, uint relativeToWindow);
+
+ [DllImport(CoreGraphics)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool CGRectMakeWithDictionaryRepresentation(nint dictionary, out CGRect rect);
+
+ [DllImport(CoreFoundation)]
+ private static extern void CFRelease(nint value);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFArrayGetCount(nint array);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFArrayGetValueAtIndex(nint array, nint index);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFDictionaryGetValue(nint dictionary, nint key);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFStringCreateWithCString(nint allocator, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, uint encoding);
+
+ [DllImport(CoreFoundation, EntryPoint = "CFNumberGetValue")]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool CFNumberGetInt(nint number, nint type, out int value);
+
+ [DllImport(CoreFoundation, EntryPoint = "CFNumberGetValue")]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool CFNumberGetDouble(nint number, nint type, out double value);
+
+ [DllImport(ObjC)]
+ private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
+
+ [DllImport(ObjC)]
+ private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern nint Send(nint receiver, nint selector);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool SendBool(nint receiver, nint selector);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern void SendVoid(nint receiver, nint selector);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern void SendInteger(nint receiver, nint selector, nint value);
+}
diff --git a/tests/Filee.App.Tests/InstallerTests.cs b/tests/Filee.App.Tests/InstallerTests.cs
index 591e7e0..b3ca636 100644
--- a/tests/Filee.App.Tests/InstallerTests.cs
+++ b/tests/Filee.App.Tests/InstallerTests.cs
@@ -50,6 +50,7 @@ public void Installer_choices_are_parsed()
Assert.Null(CommandLine.Parse(["--background"]).InstallEngines);
Assert.Null(CommandLine.Parse(["--start-with-windows=maybe"]).StartWithWindows);
Assert.Null(CommandLine.Parse([]).ContextMenu);
+ Assert.True(CommandLine.Parse(["--start-with-system=on"]).StartWithWindows);
}
[Fact]
diff --git a/tests/Filee.App.Tests/SystemIntegrationTests.cs b/tests/Filee.App.Tests/SystemIntegrationTests.cs
new file mode 100644
index 0000000..6490f63
--- /dev/null
+++ b/tests/Filee.App.Tests/SystemIntegrationTests.cs
@@ -0,0 +1,94 @@
+using Filee.App.Services;
+using Filee.Core.Localization;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Filee.App.Tests;
+
+public sealed class SystemIntegrationTests
+{
+ [Fact]
+ public void Unrelated_settings_do_not_reregister_the_file_manager()
+ {
+ var platform = new RecordingPlatform();
+ var service = Create(platform);
+ var settings = new AppSettings();
+ service.Apply(settings);
+ settings.Donut.OuterRadius = 170;
+ service.Apply(settings);
+ Assert.Equal(1, platform.StartupCalls);
+ Assert.Equal(1, platform.MenuCalls);
+ settings.ContextMenuEnabled = false;
+ service.Apply(settings);
+ Assert.Equal(1, platform.StartupCalls);
+ Assert.Equal(2, platform.MenuCalls);
+ }
+
+ [Fact]
+ public void Failed_registration_is_reported_and_can_be_retried_without_repeating_successful_work()
+ {
+ var platform = new RecordingPlatform { FailMenu = true };
+ var service = Create(platform);
+ var settings = new AppSettings();
+ service.Apply(settings);
+ Assert.Contains("denied", service.Error);
+ platform.FailMenu = false;
+ service.Apply(settings);
+ Assert.Null(service.Error);
+ Assert.Equal(1, platform.StartupCalls);
+ Assert.Equal(2, platform.MenuCalls);
+ }
+
+ [Fact]
+ public void Source_builds_do_not_register_os_integration()
+ {
+ var platform = new RecordingPlatform();
+ var service = new SystemIntegrationService(platform, new Texts(), NullLogger.Instance)
+ {
+ RegistrationEnabled = false,
+ };
+ service.Apply(new AppSettings());
+ Assert.Equal(0, platform.StartupCalls);
+ Assert.Equal(0, platform.MenuCalls);
+ }
+
+ private static SystemIntegrationService Create(RecordingPlatform platform) =>
+ new(platform, new Texts(), NullLogger.Instance)
+ {
+ RegistrationEnabled = true,
+ ExecutablePath = Path.Combine(Path.GetTempPath(), "Filee test", "Filee"),
+ };
+
+ private sealed class Texts : ILocalizer
+ {
+ public string Language => "en";
+ public string this[string key] => key;
+ public string Format(string key, params object[] args) => key + ": " + string.Join(", ", args);
+ public event EventHandler? LanguageChanged { add { } remove { } }
+ }
+
+ private sealed class RecordingPlatform : IPlatformServices
+ {
+ public int StartupCalls { get; private set; }
+ public int MenuCalls { get; private set; }
+ public bool FailMenu { get; set; }
+ public void SetStartWithSystem(bool enabled, string executablePath) => StartupCalls++;
+ public void SetContextMenu(bool enabled, string executablePath, string label)
+ {
+ MenuCalls++;
+ if (FailMenu)
+ throw new IOException("denied");
+ }
+ public bool IsFileManagerAt(int x, int y) => false;
+ public string? ProcessNameAt(int x, int y) => null;
+ public IReadOnlyList GetFileManagerSelection() => [];
+ public int SystemDragThreshold => 4;
+ public bool PrefersReducedMotion => false;
+ public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported;
+ public Task SetModernContextMenuAsync(bool enabled, string executablePath) =>
+ Task.FromResult(new ModernContextMenuResult(false));
+ public void RevealInFileManager(string path) { }
+ public void RaiseTopmost(nint windowHandle) { }
+ }
+}
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
index d0225cf..fdf3af1 100644
--- a/tests/Filee.Cli.Tests/CliTests.cs
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -179,4 +179,50 @@ public async Task Help_and_version_need_no_engines()
Assert.Equal(Cli.Success, code);
Assert.StartsWith("filee ", output);
}
+
+ [Fact]
+ public async Task Case_distinct_inputs_are_both_converted_and_exact_duplicates_are_removed()
+ {
+ var lower = Png("photo.png");
+ if (File.Exists(Path.Combine(_folder, "PHOTO.png")))
+ {
+ Assert.Skip("The temporary volume is case-insensitive.");
+ return;
+ }
+ var upper = Png("PHOTO.png");
+ var outputFolder = Path.Combine(_folder, "out");
+
+ var (code, output, errors) = await Run("convert", lower, upper, lower, "--to", "jpg", "-o", outputFolder, "--json");
+
+ Assert.Equal(Cli.Success, code);
+ Assert.Equal("", errors);
+ using var json = JsonDocument.Parse(output);
+ Assert.Equal(2, json.RootElement.GetProperty("converted").GetInt32());
+ Assert.Equal(2, json.RootElement.GetProperty("files").GetArrayLength());
+ var outputPaths = json.RootElement.GetProperty("files").EnumerateArray()
+ .Select(file => file.GetProperty("outputs")[0].GetString()!).ToArray();
+ Assert.Equal(2, outputPaths.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ Assert.All(outputPaths, path => Assert.True(File.Exists(path)));
+ }
+
+ [Fact]
+ public async Task Unicode_spaces_and_quotes_stay_in_a_single_input_argument()
+ {
+ var name = OperatingSystem.IsWindows() ? "한글 report's 100%.png" : "한글 report's \"quoted\" 100%.png";
+ var source = Png(name);
+ var outputFolder = Path.Combine(_folder, "결과 output's");
+
+ var (code, output, errors) = await Run("convert", source, "--to", "jpg", "-o", outputFolder, "--json");
+
+ Assert.Equal(Cli.Success, code);
+ Assert.Equal("", errors);
+ using var json = JsonDocument.Parse(output);
+ Assert.Equal(1, json.RootElement.GetProperty("converted").GetInt32());
+ var result = json.RootElement.GetProperty("files")[0].GetProperty("outputs")[0].GetString();
+ Assert.NotNull(result);
+ Assert.Equal(outputFolder, Path.GetDirectoryName(result));
+ Assert.Contains("한글 report's", Path.GetFileName(result));
+ Assert.True(File.Exists(result));
+ Assert.True(File.Exists(source));
+ }
}
diff --git a/tests/Filee.Core.Tests/FolderWatcherTests.cs b/tests/Filee.Core.Tests/FolderWatcherTests.cs
index a12af78..3854b28 100644
--- a/tests/Filee.Core.Tests/FolderWatcherTests.cs
+++ b/tests/Filee.Core.Tests/FolderWatcherTests.cs
@@ -157,4 +157,64 @@ public async Task A_missing_folder_is_reported_instead_of_watched()
Assert.False(watcher.Start());
Assert.NotNull(watcher.Error);
}
+
+ [Fact]
+ public async Task Case_distinct_files_with_identical_metadata_are_both_converted()
+ {
+ using var dir = new TempDir();
+ var lower = dir.File("photo.jpg", "one");
+ var upper = Path.Combine(dir.Path, "PHOTO.jpg");
+ if (File.Exists(upper))
+ {
+ Assert.Skip("The temporary volume is case-insensitive.");
+ return;
+ }
+ File.WriteAllText(upper, "two");
+ File.SetLastWriteTimeUtc(upper, File.GetLastWriteTimeUtc(lower));
+ var (watcher, time, jobs, queue) = Create(new WatchRule { Folder = dir.Path });
+ await using var queueLifetime = queue;
+ await using var watcherLifetime = watcher;
+
+ watcher.Notice(lower);
+ watcher.Notice(upper);
+ time.Advance(3);
+
+ Assert.Equal(2, await watcher.ProcessPendingAsync());
+ var job = Assert.Single(jobs);
+ Assert.Equal(2, job.Files.Count);
+ Assert.Equal(2, job.Outputs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ foreach (var result in job.Files)
+ {
+ Assert.Equal(FileState.Done, result.State);
+ Assert.Contains(Path.GetFileName(result.SourcePath), File.ReadAllText(Assert.Single(result.Outputs)));
+ }
+
+ watcher.Notice(lower);
+ watcher.Notice(upper);
+ time.Advance(3);
+ Assert.Equal(0, await watcher.ProcessPendingAsync());
+ Assert.Single(jobs);
+ }
+
+ [Fact]
+ public void A_case_distinct_folder_is_not_mistaken_for_the_output_folder()
+ {
+ using var dir = new TempDir();
+ var rule = new WatchRule { Folder = dir.Path, IncludeSubfolders = true };
+ Directory.CreateDirectory(rule.ResolvedOutputFolder);
+ var alternate = Path.Combine(dir.Path, "Converted");
+ if (Directory.Exists(alternate))
+ {
+ Assert.Skip("The temporary volume is case-insensitive.");
+ return;
+ }
+ Directory.CreateDirectory(alternate);
+ var input = Path.Combine(alternate, "photo.jpg");
+ var output = Path.Combine(rule.ResolvedOutputFolder, "photo.jpg");
+ File.WriteAllText(input, "input");
+ File.WriteAllText(output, "output");
+
+ Assert.False(FolderWatcher.ShouldIgnore(input, rule));
+ Assert.True(FolderWatcher.ShouldIgnore(output, rule));
+ }
}
diff --git a/tests/Filee.Core.Tests/OutputPathResolverTests.cs b/tests/Filee.Core.Tests/OutputPathResolverTests.cs
index 5003156..a4d98d4 100644
--- a/tests/Filee.Core.Tests/OutputPathResolverTests.cs
+++ b/tests/Filee.Core.Tests/OutputPathResolverTests.cs
@@ -116,4 +116,21 @@ public void Custom_folder_is_used_when_set()
Assert.Equal(Path.Combine("D:", "out", "a.png"), path);
}
+
+ [Theory]
+ [InlineData(ConflictPolicy.Overwrite)]
+ [InlineData(ConflictPolicy.Skip)]
+ [InlineData(ConflictPolicy.Rename)]
+ public void A_case_alias_of_the_source_is_never_overwritten(ConflictPolicy policy)
+ {
+ using var dir = new TempDir();
+ var source = dir.File("photo.jpg", "original");
+ var rule = new OutputRule { FileNamePattern = "PHOTO", Conflict = policy };
+
+ var path = OutputPathResolver.Resolve(rule, Tokens(source), "jpg", File.Exists);
+
+ Assert.Equal(Path.Combine(dir.Path, "PHOTO (2).jpg"), path);
+ Assert.Equal("original", File.ReadAllText(source));
+ }
+
}
diff --git a/tests/Filee.Engines.Tests/EngineInstallerTests.cs b/tests/Filee.Engines.Tests/EngineInstallerTests.cs
index 08afdee..0d52a7f 100644
--- a/tests/Filee.Engines.Tests/EngineInstallerTests.cs
+++ b/tests/Filee.Engines.Tests/EngineInstallerTests.cs
@@ -52,6 +52,18 @@ private static byte[] Zip(params (string Path, string Text)[] files)
return buffer.ToArray();
}
+ private static byte[] TarGzip(params (string Path, string Text)[] files)
+ {
+ using var buffer = new MemoryStream();
+ using (var gzip = new GZipStream(buffer, CompressionLevel.Fastest, leaveOpen: true))
+ using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
+ {
+ foreach (var (path, text) in files)
+ tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, path) { DataStream = new MemoryStream(Encoding.UTF8.GetBytes(text)) });
+ }
+ return buffer.ToArray();
+ }
+
/// A conda package: a zip with metadata and the files as a zstd-compressed tarball.
private static byte[] Conda(params (string Path, string Text)[] files)
{
@@ -150,6 +162,53 @@ public async Task A_download_with_the_wrong_checksum_is_rejected_and_nothing_is_
Assert.Empty(Directory.GetFiles(Path.Combine(_root, ".downloads")));
}
+ [Theory]
+ [InlineData("zip")]
+ [InlineData("tar.gz")]
+ public async Task An_incomplete_engine_update_preserves_the_working_install_and_its_marker(string kind)
+ {
+ var programs = EngineEnvironment.OwnCopyPrograms["ffmpeg"];
+ byte[] Archive(params (string Path, string Text)[] files) => kind == "zip" ? Zip(files) : TarGzip(files);
+ var previousArchive = Archive(($"ffmpeg-1.0/bin/{programs[0]}", "working encoder"),
+ ($"ffmpeg-1.0/bin/{programs[1]}", "working probe"));
+ var previousComponent = Component("ffmpeg", kind, previousArchive);
+ var package = new EnginePackage("ffmpeg", ["ffmpeg"], 1, ["ffmpeg"]);
+ var previousInstaller = Installer(new FakeServer(new() { [previousComponent.Url] = previousArchive }), previousComponent);
+ await previousInstaller.InstallAsync(package, null, TestContext.Current.CancellationToken);
+
+ var incompleteArchive = Archive(($"ffmpeg-2.0/bin/{programs[0]}", "new encoder without probe"));
+ var incompleteComponent = Component("ffmpeg", kind, incompleteArchive) with { Version = "2.0" };
+ var installer = Installer(new FakeServer(new() { [incompleteComponent.Url] = incompleteArchive }), incompleteComponent);
+ var error = await Assert.ThrowsAsync(() =>
+ installer.InstallAsync(package, null, TestContext.Current.CancellationToken));
+
+ Assert.Contains("ffmpeg", error.Message);
+ var folder = Path.Combine(_root, "ffmpeg");
+ Assert.Equal("working encoder", File.ReadAllText(Path.Combine(folder, "bin", programs[0])));
+ Assert.Equal("working probe", File.ReadAllText(Path.Combine(folder, "bin", programs[1])));
+ Assert.Equal(previousComponent.Sha256, File.ReadAllText(Path.Combine(folder, ".filee-component")));
+ Assert.True(previousInstaller.IsUpToDate(package));
+ Assert.True(installer.IsInstalled(package));
+ Assert.False(installer.IsUpToDate(package));
+ Assert.Empty(Directory.GetDirectories(_root, "~*"));
+ Assert.Empty(Directory.GetFiles(Path.Combine(_root, ".downloads")));
+ }
+
+ [Theory]
+ [InlineData("libreoffice", "LibreOffice.app", "soffice")]
+ [InlineData("calibre", "calibre.app", "ebook-convert")]
+ public void A_Mac_application_bundle_requires_its_conversion_program(string id, string app, string program)
+ {
+ var folder = Path.Combine(_root, app, "Contents", "MacOS");
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "README"), "not an executable");
+
+ Assert.Throws(() => EngineInstaller.RequiredProgramFolder(id, _root, windows: false));
+
+ File.WriteAllText(Path.Combine(folder, program), "binary");
+ Assert.Equal(folder, EngineInstaller.RequiredProgramFolder(id, _root, windows: false));
+ }
+
[Fact]
public async Task Redirects_to_plain_http_mirrors_are_followed_because_the_hash_is_checked()
{
@@ -235,7 +294,7 @@ public void LibreOffice_is_trimmed_to_what_headless_conversion_needs()
[Fact]
public void Every_package_component_is_pinned_in_the_manifest()
{
- foreach (var package in EngineDownloads.Packages)
+ foreach (var package in EngineDownloads.Packages.Where(EngineDownloads.CanDownload))
{
foreach (var id in package.Components)
{
diff --git a/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs b/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs
index c2c39ba..fec1e6d 100644
--- a/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs
+++ b/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs
@@ -37,13 +37,15 @@ private void SkipUnlessInstalled() =>
[InlineData("odg", "odg", null, "odg:draw8")]
[InlineData("cgm", "odg", null, "odg:impress8_draw")]
[InlineData("cgm", "svg", null, "svg:impress_svg_Export")]
+ [InlineData("emf", "pdf", null, "pdf:draw_pdf_Export")]
+ [InlineData("wmf", "png", null, "png:draw_png_Export")]
public void Filters_follow_the_application_that_opens_the_file(string from, string to, string? extension, string expected) =>
Assert.Equal(expected, LibreOfficeConverter.FilterFor(from, to, pdfA: false, extension));
[Fact]
public void Every_edge_has_a_filter_of_its_application()
{
- string[] draw = ["pub", "cdr", "vsd", "odg"];
+ string[] draw = ["pub", "cdr", "vsd", "odg", "emf", "wmf"];
string[] impress = ["pptx", "ppt", "odp", "key", "dps", "sda", "cgm"];
string[] calc = ["xlsx", "xls", "ods", "csv", "et", "numbers", "sdc"];
foreach (var edge in LibreOffice.Edges)
diff --git a/tests/Filee.Engines.Tests/OwnCopyTests.cs b/tests/Filee.Engines.Tests/OwnCopyTests.cs
index 0d8afda..64486f2 100644
--- a/tests/Filee.Engines.Tests/OwnCopyTests.cs
+++ b/tests/Filee.Engines.Tests/OwnCopyTests.cs
@@ -30,13 +30,11 @@ private string Program(string relative)
[Fact]
public void The_users_own_copy_wins_over_filees_download()
{
- if (!OperatingSystem.IsWindows())
- return;
- var ffmpeg = Program(@"ffmpeg\bin\ffmpeg.exe");
- var ffprobe = Program(@"ffmpeg\bin\ffprobe.exe");
- var pandoc = Program(@"pandoc\pandoc.exe");
- var calibre = Program(@"Calibre2\calibre.exe");
- var convert = Program(@"Calibre2\ebook-convert.exe");
+ var ffmpeg = Program(Path.Combine("ffmpeg", "bin", EngineEnvironment.ProgramName("ffmpeg.exe")));
+ var ffprobe = Program(Path.Combine("ffmpeg", "bin", EngineEnvironment.ProgramName("ffprobe.exe")));
+ var pandoc = Program(Path.Combine("pandoc", EngineEnvironment.ProgramName("pandoc.exe")));
+ var calibre = Program(Path.Combine("Calibre2", EngineEnvironment.ProgramName("calibre.exe")));
+ var convert = Program(Path.Combine("Calibre2", EngineEnvironment.ProgramName("ebook-convert.exe")));
EngineEnvironment.OwnCopies = new Dictionary
{
@@ -53,9 +51,7 @@ public void The_users_own_copy_wins_over_filees_download()
[Fact]
public void A_folder_without_every_program_is_not_used()
{
- if (!OperatingSystem.IsWindows())
- return;
- var ffmpeg = Program(@"tools\ffmpeg.exe"); // no ffprobe.exe next to it
+ var ffmpeg = Program(Path.Combine("tools", EngineEnvironment.ProgramName("ffmpeg.exe")));
Assert.Null(EngineEnvironment.FolderWithPrograms("ffmpeg", ffmpeg));
EngineEnvironment.OwnCopies = new Dictionary { ["ffmpeg"] = ffmpeg };
@@ -84,16 +80,13 @@ public void Dispose()
[Fact]
public void A_tool_on_the_path_is_found_when_filee_has_no_copy()
{
- if (!OperatingSystem.IsWindows())
- return;
- // Like Scoop's shims folder: ffmpeg.exe and ffprobe.exe side by side, on the PATH.
- File.WriteAllBytes(Path.Combine(_folder, "ffmpeg.exe"), [0x4D, 0x5A]);
- File.WriteAllBytes(Path.Combine(_folder, "ffprobe.exe"), [0x4D, 0x5A]);
- Environment.SetEnvironmentVariable("PATH", _folder + ";" + _path);
+ File.WriteAllBytes(Path.Combine(_folder, EngineEnvironment.ProgramName("ffmpeg.exe")), [0x4D, 0x5A]);
+ File.WriteAllBytes(Path.Combine(_folder, EngineEnvironment.ProgramName("ffprobe.exe")), [0x4D, 0x5A]);
+ Environment.SetEnvironmentVariable("PATH", _folder + Path.PathSeparator + _path);
EngineEnvironment.ForgetSystemCopies();
Assert.Equal(_folder, EngineEnvironment.SystemCopyFolder("ffmpeg"));
- Assert.Equal(Path.Combine(_folder, "ffmpeg.exe"), EngineEnvironment.SystemProgram("ffmpeg", "ffmpeg.exe"));
+ Assert.Equal(Path.Combine(_folder, EngineEnvironment.ProgramName("ffmpeg.exe")), EngineEnvironment.SystemProgram("ffmpeg", "ffmpeg.exe"));
}
[Theory]
@@ -104,6 +97,7 @@ public void A_tool_on_the_path_is_found_when_filee_has_no_copy()
[InlineData("calibre", "ebook-convert.exe (calibre 8.5.0)\nCreated by: Kovid Goyal", "8.5")]
[InlineData("ghostscript", "10.04.0\r\n", "10.4")]
[InlineData("libreoffice", "[Version]\nAllLanguages=en-US\nMsiProductVersion=25.2.4.3\n", "25.2")]
+ [InlineData("libreoffice", "LibreOffice 26.2.6.2 98d5f8\n", "26.2")]
public void Versions_are_read_from_each_program(string id, string output, string? expected) =>
Assert.Equal(expected is null ? null : Version.Parse(expected), CopyVersions.ParseVersion(id, output));
diff --git a/tests/Filee.Engines.Tests/PortableEngineTests.cs b/tests/Filee.Engines.Tests/PortableEngineTests.cs
new file mode 100644
index 0000000..b6e90cd
--- /dev/null
+++ b/tests/Filee.Engines.Tests/PortableEngineTests.cs
@@ -0,0 +1,136 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Text;
+using Filee.Engines.Infrastructure;
+
+namespace Filee.Engines.Tests;
+
+public sealed class PortableEngineTests : IDisposable
+{
+ private readonly string _root = Directory.CreateTempSubdirectory("filee-portable-").FullName;
+
+ public void Dispose() => Directory.Delete(_root, recursive: true);
+
+ [Theory]
+ [InlineData("linux-x64")]
+ [InlineData("linux-arm64")]
+ [InlineData("osx-x64")]
+ [InlineData("osx-arm64")]
+ public void Unix_downloads_are_pinned_for_the_requested_runtime(string runtime)
+ {
+ var components = EngineDownloads.ComponentsForRuntime(runtime);
+ Assert.Contains("rhwp", components.Keys);
+ Assert.Contains("7zip", components.Keys);
+ Assert.Contains("pandoc", components.Keys);
+ Assert.Contains("calibre", components.Keys);
+ Assert.DoesNotContain("vcruntime", components.Keys);
+ Assert.DoesNotContain("ghostscript", components.Keys);
+ foreach (var component in components.Values)
+ {
+ Assert.StartsWith("https://", component.Url);
+ Assert.DoesNotContain("windows", component.Url, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain(component.Kind, new[] { "msi", "conda" });
+ Assert.Matches("^[0-9A-F]{64}$", component.Sha256);
+ Assert.True(component.Size > 0);
+ }
+ var arch = runtime.EndsWith("arm64", StringComparison.Ordinal) ? "aarch64" : "x86_64";
+ Assert.Contains(arch, components["rhwp"].Url);
+ }
+
+ [Fact]
+ public void Unknown_platforms_cannot_fall_back_to_Windows_downloads() =>
+ Assert.Empty(EngineDownloads.ComponentsForRuntime("linux-riscv64"));
+
+ [Theory]
+ [InlineData("libreoffice", "LibreOffice.app/Contents/MacOS", "soffice")]
+ [InlineData("calibre", "calibre.app/Contents/MacOS", "ebook-convert")]
+ [InlineData("pandoc", "bin", "pandoc")]
+ [InlineData("ghostscript", "bin", "gs")]
+ public void Unix_programs_are_found_inside_archives_and_Mac_application_bundles(string id, string relative, string program)
+ {
+ var folder = Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, program), "binary");
+ Assert.Equal(folder, EngineEnvironment.FolderWithPrograms(id, _root, windows: false));
+ Assert.Equal(folder, EngineEnvironment.FolderWithPrograms(id, Path.Combine(folder, program), windows: false));
+ Assert.Null(EngineEnvironment.FolderWithPrograms(id, _root, windows: true));
+ }
+
+ [Fact]
+ public void Unix_FFmpeg_requires_both_tools_in_the_same_folder()
+ {
+ File.WriteAllText(Path.Combine(_root, "ffmpeg"), "binary");
+ Assert.Null(EngineEnvironment.FolderWithPrograms("ffmpeg", _root, windows: false));
+ File.WriteAllText(Path.Combine(_root, "ffprobe"), "binary");
+ Assert.Equal(_root, EngineEnvironment.FolderWithPrograms("ffmpeg", _root, windows: false));
+ }
+
+ [Fact]
+ public async Task Tar_archives_preserve_program_layout_contents_and_Unix_executable_mode()
+ {
+ var file = TarGzip(new PaxTarEntry(TarEntryType.RegularFile, "tool/bin/tool")
+ {
+ DataStream = new MemoryStream(Encoding.UTF8.GetBytes("#!/bin/sh\necho ready\n")),
+ Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
+ });
+ var target = Path.Combine(_root, "extract");
+ await EngineInstaller.UnpackTarAsync(file, target, "tar.gz", TestContext.Current.CancellationToken);
+ var program = Path.Combine(target, "tool", "bin", "tool");
+ Assert.Contains("echo ready", File.ReadAllText(program));
+ if (!OperatingSystem.IsWindows())
+ Assert.True(File.GetUnixFileMode(program).HasFlag(UnixFileMode.UserExecute));
+ }
+
+ [Fact]
+ public async Task Tar_entries_cannot_escape_the_staging_folder()
+ {
+ var file = TarGzip(new PaxTarEntry(TarEntryType.RegularFile, "../escaped")
+ {
+ DataStream = new MemoryStream([1]),
+ });
+ await Assert.ThrowsAsync(() => EngineInstaller.UnpackTarAsync(file,
+ Path.Combine(_root, "extract"), "tar.gz", TestContext.Current.CancellationToken));
+ Assert.False(File.Exists(Path.Combine(_root, "escaped")));
+ }
+
+ [Fact]
+ public async Task Tar_symbolic_links_cannot_write_outside_staging()
+ {
+ var outside = Path.Combine(_root, "outside");
+ Directory.CreateDirectory(outside);
+ var file = TarGzip(
+ new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "../outside" },
+ new PaxTarEntry(TarEntryType.RegularFile, "link/escaped") { DataStream = new MemoryStream([1]) });
+ var exception = await Record.ExceptionAsync(() => EngineInstaller.UnpackTarAsync(file,
+ Path.Combine(_root, "extract"), "tar.gz", TestContext.Current.CancellationToken));
+ Assert.NotNull(exception);
+ Assert.False(File.Exists(Path.Combine(outside, "escaped")));
+ }
+
+ [Fact]
+ public void Downloaded_Unix_programs_get_execute_permission_without_changing_data_files()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+ var program = Path.Combine(_root, "pandoc");
+ var readme = Path.Combine(_root, "README");
+ File.WriteAllText(program, "binary");
+ File.WriteAllText(readme, "data");
+ File.SetUnixFileMode(program, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ var dataMode = File.GetUnixFileMode(readme);
+ EngineInstaller.EnsureExecutablePrograms("pandoc", _root);
+ Assert.True(File.GetUnixFileMode(program).HasFlag(UnixFileMode.UserExecute));
+ Assert.Equal(dataMode, File.GetUnixFileMode(readme));
+ }
+
+ private string TarGzip(params TarEntry[] entries)
+ {
+ var file = Path.Combine(_root, Guid.NewGuid().ToString("N") + ".tar.gz");
+ using var output = File.Create(file);
+ using var gzip = new GZipStream(output, CompressionLevel.Fastest);
+ using var writer = new TarWriter(gzip);
+ foreach (var entry in entries)
+ writer.WriteEntry(entry);
+ return file;
+ }
+}
diff --git a/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj
new file mode 100644
index 0000000..f6ccad1
--- /dev/null
+++ b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ false
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Filee.Platform.Tests/LinuxDesktopIntegrationTests.cs b/tests/Filee.Platform.Tests/LinuxDesktopIntegrationTests.cs
new file mode 100644
index 0000000..19e3e86
--- /dev/null
+++ b/tests/Filee.Platform.Tests/LinuxDesktopIntegrationTests.cs
@@ -0,0 +1,122 @@
+using System.Xml.Linq;
+using Filee.Core.Settings;
+using Filee.Platform.Linux;
+
+namespace Filee.Platform.Tests;
+
+public sealed class LinuxDesktopIntegrationTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "Filee.Platform.Tests", Guid.NewGuid().ToString("N"));
+
+ [Fact]
+ public void ContextMenuRoundTripPreservesOtherThunarActions()
+ {
+ var configuration = Path.Combine(_root, "config");
+ var data = Path.Combine(_root, "data");
+ var path = Path.Combine(configuration, "Thunar", "uca.xml");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ const string existing = "Keep meotherecho unchanged";
+ File.WriteAllText(path, "" + existing + "");
+ var integration = new LinuxDesktopIntegration(configuration, data);
+
+ integration.SetContextMenu(true, "/opt/Filee's app/Filee.App", "Filee로 변환 & test");
+ integration.SetContextMenu(true, "/opt/new/Filee.App", "Updated");
+
+ var document = XDocument.Load(path);
+ Assert.Equal(2, document.Root!.Elements("action").Count());
+ Assert.Equal("echo unchanged", document.Root.Elements("action")
+ .Single(action => (string?)action.Element("unique-id") == "other").Element("command")!.Value);
+ Assert.Equal("'/opt/new/Filee.App' --convert %F", document.Root.Elements("action")
+ .Single(action => (string?)action.Element("unique-id") == LinuxDesktopIntegration.ThunarActionId).Element("command")!.Value);
+ Assert.Contains("exec '/opt/new/Filee.App' --convert \"$@\"", File.ReadAllText(Path.Combine(data, "nautilus", "scripts", "Filee")));
+
+ integration.SetContextMenu(false, "", "");
+
+ document = XDocument.Load(path);
+ Assert.Single(document.Root!.Elements("action"));
+ Assert.Equal("other", document.Root.Element("action")!.Element("unique-id")!.Value);
+ Assert.False(File.Exists(Path.Combine(data, "kio", "servicemenus", "filee.desktop")));
+ }
+
+ [Fact]
+ public void MalformedThunarConfigurationIsNotOverwritten()
+ {
+ var configuration = Path.Combine(_root, "config");
+ var path = Path.Combine(configuration, "Thunar", "uca.xml");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, "");
+
+ Assert.Throws(() =>
+ new LinuxDesktopIntegration(configuration, Path.Combine(_root, "data"))
+ .SetContextMenu(true, "/opt/Filee.App", "Convert"));
+ Assert.Equal("", File.ReadAllText(path));
+ Assert.False(Directory.Exists(Path.Combine(_root, "data")));
+ }
+
+ [Fact]
+ public void AutostartEscapesLineBreaksAndKeepsTheExecutableAsOneArgument()
+ {
+ var integration = new LinuxDesktopIntegration(_root, Path.Combine(_root, "data"));
+ integration.SetAutostart(true, "/opt/Filee test/Filee.App");
+ var path = Path.Combine(_root, "autostart", "filee.desktop");
+ Assert.Contains("Exec=\"/opt/Filee test/Filee.App\" --background\n", File.ReadAllText(path));
+ Assert.Throws(() => integration.SetAutostart(true, "/opt/Filee\nExec=bad"));
+ Assert.Contains("Exec=\"/opt/Filee test/Filee.App\" --background\n", File.ReadAllText(path));
+ integration.SetAutostart(false, "");
+ Assert.False(File.Exists(path));
+ }
+
+ [Fact]
+ public void DesktopArgumentsEscapeBothDesktopParsingLayers()
+ {
+ Assert.Equal("\"/tmp/a\\\\\"b\\\\$c\\\\`d\\\\\\\\e%%f\"",
+ LinuxDesktopIntegration.DesktopArgument("/tmp/a\"b$c`d\\e%f"));
+ Assert.Equal("'/tmp/a'\\''b $c'", LinuxDesktopIntegration.ShellArgument("/tmp/a'b $c"));
+ Assert.Equal("label\\nExec=bad", LinuxDesktopIntegration.DesktopValue("label\nExec=bad"));
+ }
+
+ [Fact]
+ public void ShortcutUpdatePreservesOthersAndIsIdempotent()
+ {
+ const string original = "; other comment\n(gtk_accel_path \"/ThunarWindow/open\" \"o\")\n";
+ var updated = LinuxDesktopIntegration.UpdateThunarShortcut(original, "space");
+ Assert.StartsWith(original, updated);
+ Assert.Contains("\"space\")\n", updated);
+ Assert.Equal(updated, LinuxDesktopIntegration.UpdateThunarShortcut(updated, "space"));
+ Assert.Equal(original, LinuxDesktopIntegration.UpdateThunarShortcut(updated, null));
+ Assert.Throws(() => LinuxDesktopIntegration.UpdateThunarShortcut(original, "o"));
+ Assert.Throws(() => LinuxDesktopIntegration.UpdateThunarShortcut(
+ original.Replace("o", "space", StringComparison.Ordinal), "space"));
+ Assert.Throws(() => LinuxDesktopIntegration.UpdateThunarShortcut(original, "space\")\n(bad)"));
+ }
+
+ [Theory]
+ [InlineData("Backslash", "backslash")]
+ [InlineData("BackSlash", "backslash")]
+ [InlineData("NumPad0", "KP_0")]
+ [InlineData("NumPad7", "KP_7")]
+ [InlineData("NumPad9", "KP_9")]
+ [InlineData("PrintScreen", "Print")]
+ [InlineData("Pause", "Pause")]
+ [InlineData("PageDown", "Page_Down")]
+ [InlineData("Space", "space")]
+ public void RecordedKeysHaveGtkAccelerators(string recorded, string expected)
+ {
+ var gesture = new TriggerGesture
+ {
+ Kind = TriggerKind.KeyChord,
+ Modifiers = ModifierKeys.Ctrl | ModifierKeys.Alt,
+ Key = recorded,
+ };
+ var accelerator = LinuxDesktopIntegration.ToThunarAccelerator(gesture);
+ Assert.Equal("" + expected, accelerator);
+ var contents = LinuxDesktopIntegration.UpdateThunarShortcut("", accelerator);
+ Assert.Contains("\"" + accelerator + "\"", contents);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ Directory.Delete(_root, recursive: true);
+ }
+}
diff --git a/tests/Filee.Platform.Tests/LinuxNativeIntegrationTests.cs b/tests/Filee.Platform.Tests/LinuxNativeIntegrationTests.cs
new file mode 100644
index 0000000..b7ba567
--- /dev/null
+++ b/tests/Filee.Platform.Tests/LinuxNativeIntegrationTests.cs
@@ -0,0 +1,121 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using Filee.Platform.Linux;
+
+namespace Filee.Platform.Tests;
+
+public sealed class LinuxNativeIntegrationTests
+{
+ [Fact]
+ public void NautilusScriptPreservesDistinctPathsAndShellCharacters()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Skip("Requires the Linux POSIX shell.");
+ return;
+ }
+ var root = Path.Combine(Path.GetTempPath(), "filee-linux-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ var capture = Path.Combine(root, "arguments");
+ var target = Path.Combine(root, "Filee's app");
+ File.WriteAllText(target, "#!/bin/sh\nprintf '%s\\0' \"$@\" > " + LinuxDesktopIntegration.ShellArgument(capture) + "\n");
+ File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ var first = Path.Combine(root, "a", "한글 ' $(touch BAD) %F.txt");
+ var second = Path.Combine(root, "b", Path.GetFileName(first));
+ foreach (var file in new[] { first, second })
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(file)!);
+ File.WriteAllText(file, "sample");
+ }
+ var data = Path.Combine(root, "data");
+ new LinuxDesktopIntegration(Path.Combine(root, "config"), data).SetContextMenu(true, target, "Convert");
+ var processInfo = new ProcessStartInfo(Path.Combine(data, "nautilus", "scripts", "Filee"))
+ {
+ UseShellExecute = false,
+ WorkingDirectory = root,
+ };
+ processInfo.ArgumentList.Add(Path.GetRelativePath(root, first));
+ processInfo.ArgumentList.Add(second);
+ using var process = Process.Start(processInfo)!;
+ Assert.True(process.WaitForExit(5000));
+ Assert.Equal(0, process.ExitCode);
+ Assert.Equal(new[] { "--convert", first, second }, File.ReadAllText(capture).Split('\0', StringSplitOptions.RemoveEmptyEntries));
+ Assert.False(File.Exists(Path.Combine(root, "BAD")));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void X11CanIdentifyAnOwnedWindowAndRequestRestackingWithoutChangingFocus()
+ {
+ if (!OperatingSystem.IsLinux() || !X11WindowSystem.IsAvailable)
+ {
+ Assert.Skip("Requires a Linux X11 desktop session.");
+ return;
+ }
+ var display = XOpenDisplay(0);
+ Assert.NotEqual(0, display);
+ var window = XCreateSimpleWindow(display, XDefaultRootWindow(display), 40, 40, 80, 80, 0, 0, 0);
+ var pid = Marshal.AllocHGlobal(nint.Size);
+ try
+ {
+ var attributes = new XSetWindowAttributes { OverrideRedirect = 1 };
+ XChangeWindowAttributes(display, window, 1 << 9, ref attributes);
+ Marshal.WriteIntPtr(pid, Environment.ProcessId);
+ XChangeProperty(display, window, XInternAtom(display, "_NET_WM_PID", 0), 6, 32, 0, pid, 1);
+ XMapRaised(display, window);
+ XSync(display, 0);
+ XTranslateCoordinates(display, window, XDefaultRootWindow(display), 20, 20, out var x, out var y, out _);
+ Assert.Equal(Process.GetCurrentProcess().ProcessName, X11WindowSystem.ProcessNameAt(x, y));
+ XGetInputFocus(display, out var previousFocus, out _);
+ X11WindowSystem.RaiseTopmost((nint)window);
+ XSync(display, 0);
+ XGetInputFocus(display, out var currentFocus, out _);
+ Assert.Equal(previousFocus, currentFocus);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(pid);
+ XDestroyWindow(display, window);
+ XCloseDisplay(display);
+ }
+ }
+
+ [DllImport("libX11.so.6")] private static extern nint XOpenDisplay(nint name);
+ [DllImport("libX11.so.6")] private static extern int XCloseDisplay(nint display);
+ [DllImport("libX11.so.6")] private static extern nuint XDefaultRootWindow(nint display);
+ [DllImport("libX11.so.6")] private static extern nuint XCreateSimpleWindow(nint display, nuint parent, int x, int y, uint width, uint height, uint borderWidth, nuint border, nuint background);
+ [DllImport("libX11.so.6")] private static extern int XDestroyWindow(nint display, nuint window);
+ [DllImport("libX11.so.6")] private static extern nuint XInternAtom(nint display, [MarshalAs(UnmanagedType.LPUTF8Str)] string name, int onlyIfExists);
+ [DllImport("libX11.so.6")] private static extern int XChangeProperty(nint display, nuint window, nuint property, nuint type, int format, int mode, nint data, int count);
+ [DllImport("libX11.so.6")] private static extern int XMapRaised(nint display, nuint window);
+ [DllImport("libX11.so.6")] private static extern int XSync(nint display, int discard);
+ [DllImport("libX11.so.6")] private static extern int XGetInputFocus(nint display, out nuint focus, out int revertTo);
+ [DllImport("libX11.so.6")] private static extern int XTranslateCoordinates(nint display, nuint source, nuint destination, int x, int y, out int destinationX, out int destinationY, out nuint child);
+ [DllImport("libX11.so.6")] private static extern int XChangeWindowAttributes(nint display, nuint window, nuint mask, ref XSetWindowAttributes attributes);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct XSetWindowAttributes
+ {
+ public nuint BackgroundPixmap;
+ public nuint BackgroundPixel;
+ public nuint BorderPixmap;
+ public nuint BorderPixel;
+ public int BitGravity;
+ public int WinGravity;
+ public int BackingStore;
+ public nuint BackingPlanes;
+ public nuint BackingPixel;
+ public int SaveUnder;
+ public nint EventMask;
+ public nint DoNotPropagateMask;
+ public int OverrideRedirect;
+ public nuint Colormap;
+ public nuint Cursor;
+ }
+}
diff --git a/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs b/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
new file mode 100644
index 0000000..00df686
--- /dev/null
+++ b/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
@@ -0,0 +1,73 @@
+using System.Xml.Linq;
+using Filee.Platform.MacOS;
+
+namespace Filee.Platform.Tests;
+
+public sealed class MacOSIntegrationTests
+{
+ [Fact]
+ public void LaunchAgentKeepsExecutableAndArgumentsSeparate()
+ {
+ const string executable = "/Users/a & b/도구 '$(touch nope)'/Filee.app/Contents/MacOS/Filee";
+ var root = ReadPlist(MacOSIntegrationFiles.LaunchAgent(executable));
+ Assert.Equal([executable, "--background"], Property(root, "ProgramArguments").Elements().Select(x => x.Value));
+ Assert.Equal("true", Property(root, "RunAtLoad").Name.LocalName);
+ Assert.Equal("false", Property(root, "KeepAlive").Name.LocalName);
+ Assert.Equal("Aqua", Property(root, "LimitLoadToSessionType").Value);
+ }
+
+ [Fact]
+ public void ServiceMenuEscapesLocalizedLabelAndOnlyAcceptsFinderFiles()
+ {
+ const string label = "Filee로 변환 <빠르게> & 'convert'";
+ var root = ReadPlist(MacOSIntegrationFiles.ServiceInfo(label));
+ var service = Property(root, "NSServices").Element("dict")!;
+ Assert.Equal(label, Property(Property(service, "NSMenuItem"), "default").Value);
+ Assert.Equal(label, Property(root, "CFBundleName").Value);
+ Assert.Equal("com.apple.finder", Property(Property(service, "NSRequiredContext"), "NSApplicationIdentifier").Value);
+ Assert.Equal("public.item", Property(service, "NSSendFileTypes").Element("string")!.Value);
+ }
+
+ [Fact]
+ public void QuickActionLaunchesFreshBundleInstanceToForwardArguments()
+ {
+ const string executable = "/Applications/O'Brien & Friends/Filee.app/Contents/MacOS/Filee";
+ var workflow = ReadPlist(MacOSIntegrationFiles.Workflow(executable));
+ var action = Property(Property(workflow, "actions").Element("dict")!, "action");
+ var parameters = Property(action, "ActionParameters");
+ Assert.Equal("1", Property(parameters, "inputMethod").Value);
+ Assert.Equal("/bin/sh", Property(parameters, "shell").Value);
+ Assert.Equal("exec /usr/bin/open -n -a '/Applications/O'\"'\"'Brien & Friends/Filee.app' --args --convert \"$@\"",
+ Property(parameters, "COMMAND_STRING").Value);
+ var metadata = Property(workflow, "workflowMetaData");
+ Assert.Equal("com.apple.Automator.fileSystemObject", Property(metadata, "serviceInputTypeIdentifier").Value);
+ Assert.Equal("com.apple.finder", Property(metadata, "serviceApplicationBundleID").Value);
+ }
+
+ [Fact]
+ public void RawExecutableIsQuotedWithoutEvaluatingShellMetacharacters()
+ {
+ Assert.Equal("exec '/tmp/$(touch nope) `echo bad` \"quote\"' --convert \"$@\"",
+ MacOSIntegrationFiles.ConversionCommand("/tmp/$(touch nope) `echo bad` \"quote\""));
+ }
+
+ [Fact]
+ public void SelectionJsonPreservesWhitespaceUnicodeAndNewlines()
+ {
+#pragma warning disable CA1416 // Pure JSON parsing does not access the operating system.
+ var paths = MacOSPlatformServices.ParseSelection("[\"/tmp/ 한글 .txt\",\"/tmp/line\\nbreak.txt\",\"/tmp/quote\\\".txt\"]");
+#pragma warning restore CA1416
+ Assert.Equal(["/tmp/ 한글 .txt", "/tmp/line\nbreak.txt", "/tmp/quote\".txt"], paths);
+ }
+
+ private static XElement ReadPlist(string text)
+ {
+ var document = XDocument.Parse(text);
+ Assert.Equal("plist", document.Root!.Name.LocalName);
+ Assert.Equal("1.0", document.Root.Attribute("version")!.Value);
+ return document.Root.Element("dict")!;
+ }
+
+ private static XElement Property(XElement dictionary, string key) =>
+ dictionary.Elements("key").Single(x => x.Value == key).ElementsAfterSelf().First();
+}
diff --git a/tests/Filee.Platform.Tests/MacOSNativeTests.cs b/tests/Filee.Platform.Tests/MacOSNativeTests.cs
new file mode 100644
index 0000000..523a8aa
--- /dev/null
+++ b/tests/Filee.Platform.Tests/MacOSNativeTests.cs
@@ -0,0 +1,22 @@
+using Filee.Platform.MacOS;
+
+namespace Filee.Platform.Tests;
+
+public sealed class MacOSNativeTests
+{
+ [Fact]
+ public void NativeFrameworksAndPermissionProbeLoadOnMacOS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ Assert.Skip("Requires macOS frameworks.");
+ return;
+ }
+
+ var platform = new MacOSPlatformServices();
+ _ = platform.HasAccessibilityPermission;
+ _ = platform.PrefersReducedMotion;
+ _ = platform.ProcessNameAt(0, 0);
+ Assert.Equal(4, platform.SystemDragThreshold);
+ }
+}
From 574363695d577a7cc3fbdcbb5f779f99837be4ae Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Tue, 6 Oct 2026 23:15:11 +0900
Subject: [PATCH 02/16] macOS: package signs, tests pass, a clear alert when no
window can open
Tested on macOS 15 (Intel, VMware): the solution builds, the tests pass and the osx-x64 package installs and converts
with the filee command.
- build-portable.ps1 signs every file in Contents/MacOS (the .NET assemblies too, signed into extended attributes)
before the main executable; signing the main executable checked the unsigned assemblies and failed.
- .gitattributes keeps plist files LF: codesign can't read entitlements with CRLF line ends.
- The "update recommended" check knows the tested version of engines Filee doesn't download on macOS (FFmpeg and
Ghostscript from Homebrew).
- When Avalonia can't start (no display link: a Mac VM without graphics acceleration), Filee logs it and shows a
system alert instead of quitting silently.
- Tests that assumed Windows (C:\ paths, CRLF mail text, Windows DLL imports, a download for every engine, Skia's
glyph path for empty glyphs) work on macOS.
Co-Authored-By: Claude Opus 5.5
---
.gitattributes | 1 +
build/build-portable.ps1 | 14 +++---
src/Filee.App/Program.cs | 9 ++++
src/Filee.App/StartupFailure.cs | 46 +++++++++++++++++++
.../Infrastructure/CopyVersions.cs | 8 +++-
tests/Filee.App.Tests/ExplorerMenuTests.cs | 2 +
tests/Filee.App.Tests/RenderTests.cs | 3 +-
tests/Filee.Engines.Tests/ArchiveTests.cs | 4 +-
tests/Filee.Engines.Tests/EmlTests.cs | 2 +-
tests/Filee.Engines.Tests/FontTests.cs | 5 +-
10 files changed, 82 insertions(+), 12 deletions(-)
create mode 100644 src/Filee.App/StartupFailure.cs
diff --git a/.gitattributes b/.gitattributes
index 603fb60..6eda92c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,6 +6,7 @@
*.md text
*.ps1 text eol=crlf
*.sh text eol=lf
+*.plist text eol=lf
*.ttf binary
*.otf binary
diff --git a/build/build-portable.ps1 b/build/build-portable.ps1
index f82317a..b56f639 100644
--- a/build/build-portable.ps1
+++ b/build/build-portable.ps1
@@ -87,13 +87,15 @@ if ($Runtime.StartsWith('osx-')) {
$entitlements = Join-Path $root 'installer/macos/entitlements.plist'
$signOptions = @('--force', '--sign', $SigningIdentity)
if ($SigningIdentity -ne '-') { $signOptions += @('--timestamp', '--options', 'runtime') }
- foreach ($file in Get-ChildItem -LiteralPath $publish -Recurse -File | Sort-Object { $_.FullName.Length } -Descending) {
- $format = & /usr/bin/file --brief $file.FullName
- if ($LASTEXITCODE -ne 0) { throw "Cannot inspect $($file.FullName) before signing." }
- if ($format -match 'Mach-O') {
- Invoke-Checked 'Sign native binary' { codesign @signOptions --entitlements $entitlements $file.FullName }
- }
+ # Every file in Contents/MacOS is code to codesign, the .NET assemblies too (their signature goes into extended
+ # attributes): signing the main executable checks all of them. Deepest paths first, the main executable last.
+ $mainExecutable = Join-Path $publish 'Filee'
+ $files = Get-ChildItem -LiteralPath $publish -Recurse -File | Where-Object { $_.FullName -ne $mainExecutable } |
+ Sort-Object { $_.FullName.Length } -Descending
+ foreach ($file in $files) {
+ Invoke-Checked "Sign $($file.Name)" { codesign @signOptions --entitlements $entitlements $file.FullName }
}
+ Invoke-Checked 'Sign main executable' { codesign @signOptions --entitlements $entitlements $mainExecutable }
Invoke-Checked 'Sign app bundle' { codesign @signOptions --entitlements $entitlements $appBundle }
Invoke-Checked 'Verify app signature' { codesign --verify --deep --strict $appBundle }
Copy-Item -LiteralPath (Join-Path $root 'installer/macos/install.sh') -Destination $package
diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs
index 0687200..b09b51d 100644
--- a/src/Filee.App/Program.cs
+++ b/src/Filee.App/Program.cs
@@ -41,6 +41,15 @@ public static int Main(string[] args)
{
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
}
+ catch (InvalidOperationException ex) when (OperatingSystem.IsMacOS() && ex.Message.Contains("RenderTimer", StringComparison.Ordinal))
+ {
+ // The window system has no display link for this screen: a Mac in a virtual machine without graphics
+ // acceleration (VMware). Avalonia can't draw there; say so instead of quitting without a word.
+ StartupFailure.Report(ex, "Filee can't open its window on this Mac: macOS reports no display it can draw on. " +
+ "This happens in virtual machines without graphics acceleration (such as VMware). " +
+ "The filee command still works in Terminal.");
+ return 1;
+ }
finally
{
Instance.Dispose();
diff --git a/src/Filee.App/StartupFailure.cs b/src/Filee.App/StartupFailure.cs
new file mode 100644
index 0000000..42b92cc
--- /dev/null
+++ b/src/Filee.App/StartupFailure.cs
@@ -0,0 +1,46 @@
+// A start that fails before any window exists (Avalonia could not initialise): written to the log and shown in a
+// system alert, since Filee has no window of its own to show it in.
+
+using System.Diagnostics;
+using Filee.Core.Settings;
+
+namespace Filee.App;
+
+internal static class StartupFailure
+{
+ public static void Report(Exception exception, string message)
+ {
+ try
+ {
+ var logs = Path.Combine(UserDataStore.DefaultDirectory, "logs");
+ Directory.CreateDirectory(logs);
+ File.AppendAllText(Path.Combine(logs, $"filee-{DateTime.Now:yyyyMMdd}.log"),
+ $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [Error] Startup: {message}{Environment.NewLine}{exception}{Environment.NewLine}");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+
+ if (!OperatingSystem.IsMacOS())
+ return;
+ try
+ {
+ // osascript shows the alert even though this process has no window; the text goes in as an argument.
+ using var alert = Process.Start(new ProcessStartInfo("/usr/bin/osascript")
+ {
+ ArgumentList =
+ {
+ "-e", "on run argv",
+ "-e", "display alert \"Filee\" message (item 1 of argv) as critical buttons {\"OK\"}",
+ "-e", "end run",
+ message,
+ },
+ UseShellExecute = false,
+ });
+ alert?.WaitForExit(TimeSpan.FromMinutes(10));
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ }
+ }
+}
diff --git a/src/Filee.Engines/Infrastructure/CopyVersions.cs b/src/Filee.Engines/Infrastructure/CopyVersions.cs
index dc4e661..b8c9429 100644
--- a/src/Filee.Engines/Infrastructure/CopyVersions.cs
+++ b/src/Filee.Engines/Infrastructure/CopyVersions.cs
@@ -13,7 +13,13 @@ public static partial class CopyVersions
private static readonly ConcurrentDictionary Cache = new();
/// The version Filee is tested with ("9.0"), from engines.json; null for unknown packages.
- public static Version? Expected(string id) => Parse(EngineVersions.Component(id));
+ // Where Filee offers no download (FFmpeg and Ghostscript on macOS come from Homebrew), the version it is tested
+ // with is the one the Windows list names, so an older copy still gets "update recommended".
+ public static Version? Expected(string id) =>
+ Parse(EngineVersions.Component(id) ?? ReferenceComponents.Value.GetValueOrDefault(id)?.Version);
+
+ private static readonly Lazy> ReferenceComponents =
+ new(() => EngineDownloads.ComponentsForRuntime("win-x64"));
///
/// The version of the copy in (major.minor), or null when it can't be told (e.g. an
diff --git a/tests/Filee.App.Tests/ExplorerMenuTests.cs b/tests/Filee.App.Tests/ExplorerMenuTests.cs
index c6ed47d..d1df752 100644
--- a/tests/Filee.App.Tests/ExplorerMenuTests.cs
+++ b/tests/Filee.App.Tests/ExplorerMenuTests.cs
@@ -93,6 +93,7 @@ public void Family_name_matches_what_Windows_computes_from_the_publisher()
[Fact]
public void Every_native_import_exists_in_the_dll_it_names()
{
+ Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows only.");
// 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)!;
@@ -112,6 +113,7 @@ public void Every_native_import_exists_in_the_dll_it_names()
[Fact]
public void Reading_the_registered_package_never_throws()
{
+ Assert.SkipUnless(OperatingSystem.IsWindowsVersionAtLeast(10), "Package APIs need Windows.");
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)
diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs
index 09128ac..ca1d642 100644
--- a/tests/Filee.App.Tests/RenderTests.cs
+++ b/tests/Filee.App.Tests/RenderTests.cs
@@ -176,7 +176,8 @@ 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(
+ // (On macOS FFmpeg and Ghostscript come from Homebrew: listed, but not offered for download.)
+ Assert.All(vm.Packages.Where(p => !p.IsInstalled && p.CanInstall), p => Assert.Equal(
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);
diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs
index 76d30c2..25b25aa 100644
--- a/tests/Filee.Engines.Tests/ArchiveTests.cs
+++ b/tests/Filee.Engines.Tests/ArchiveTests.cs
@@ -584,7 +584,9 @@ public void Listing_parser_reads_7zip_technical_output()
[Fact]
public void Clashing_names_in_one_archive_are_numbered()
{
- var items = ArchiveConverter.UniqueEntries([@"C:\a\x.txt", @"C:\b\X.txt", @"C:\c\x.txt", @"C:\d\y"]);
+ var root = Path.GetTempPath();
+ var items = ArchiveConverter.UniqueEntries([Path.Combine(root, "a", "x.txt"), Path.Combine(root, "b", "X.txt"),
+ Path.Combine(root, "c", "x.txt"), Path.Combine(root, "d", "y")]);
Assert.Equal(["x.txt", "X (2).txt", "x (3).txt", "y"], items.Select(i => i.EntryName));
}
diff --git a/tests/Filee.Engines.Tests/EmlTests.cs b/tests/Filee.Engines.Tests/EmlTests.cs
index 5a4714f..107be91 100644
--- a/tests/Filee.Engines.Tests/EmlTests.cs
+++ b/tests/Filee.Engines.Tests/EmlTests.cs
@@ -157,7 +157,7 @@ await File.WriteAllTextAsync(path,
var html = await File.ReadAllTextAsync(await ConvertAsync(path, "html"), TestContext.Current.CancellationToken);
Assert.Contains("한글 제목", html);
- Assert.Contains("
본문 <첫 줄>\r\n둘째 줄", html);
+ Assert.Contains("
본문 <첫 줄>\n둘째 줄", html.ReplaceLineEndings("\n"));
Assert.DoesNotContain("Attachments", html);
// No attachments: a clear error instead of an empty ZIP.
diff --git a/tests/Filee.Engines.Tests/FontTests.cs b/tests/Filee.Engines.Tests/FontTests.cs
index c55e813..8ef6476 100644
--- a/tests/Filee.Engines.Tests/FontTests.cs
+++ b/tests/Filee.Engines.Tests/FontTests.cs
@@ -210,8 +210,9 @@ public async Task Cff_to_ttf_keeps_glyphs_metrics_and_outlines(string sample)
using var ttfFont = new SKFont(ttfFace, 1000) { Hinting = SKFontHinting.None };
for (ushort gid = 0; gid < glyphs.Length; gid++)
{
- using var expected = cffFont.GetGlyphPath(gid);
- using var actual = ttfFont.GetGlyphPath(gid);
+ // macOS's font backend gives no path at all (null) for an empty glyph.
+ using var expected = cffFont.GetGlyphPath(gid) ?? new SKPath();
+ using var actual = ttfFont.GetGlyphPath(gid) ?? new SKPath();
Assert.Equal(expected.IsEmpty, actual.IsEmpty);
if (expected.IsEmpty)
continue;
From 21e8c59d995e8dcf712239d821344766ce40c879 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Tue, 6 Oct 2026 23:25:44 +0900
Subject: [PATCH 03/16] Linux and macOS packages run on every push to
wip/linux-macos, with screenshots of the app on real Macs
The launch check takes two screenshots on macOS (after the start and after a second start) and uploads them with
Filee's own log files.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/portable.yml | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml
index 7056a72..4092663 100644
--- a/.github/workflows/portable.yml
+++ b/.github/workflows/portable.yml
@@ -13,6 +13,9 @@ on:
description: 'Package version (empty uses Directory.Build.props)'
type: string
required: false
+ # The Linux and macOS work in progress: every push builds, tests and launches the app on real Macs.
+ push:
+ branches: ['wip/linux-macos']
permissions:
contents: read
@@ -125,6 +128,8 @@ jobs:
import time
app = os.environ['FILEE_SMOKE_APP']
+ shots = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-screens'
+ shots.mkdir(exist_ok=True)
command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off']
if os.environ['RUNNER_OS'] == 'Linux':
command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command]
@@ -135,6 +140,18 @@ jobs:
time.sleep(8)
if process.poll() is not None:
raise RuntimeError('The desktop app exited before the startup check.')
+ if os.environ['RUNNER_OS'] == 'macOS':
+ # The real screen with Filee's window, as a person would see it (uploaded with the results).
+ subprocess.run(['screencapture', '-x', str(shots / 'settings.png')], check=False, timeout=30)
+ # A second start forwards to the running Filee, which brings its window to the front.
+ try:
+ subprocess.run([app], check=False, timeout=30)
+ except subprocess.TimeoutExpired:
+ raise RuntimeError('A second start did not hand over to the running Filee.')
+ time.sleep(4)
+ subprocess.run(['screencapture', '-x', str(shots / 'second-start.png')], check=False, timeout=30)
+ if process.poll() is not None:
+ raise RuntimeError('The desktop app exited after a second start.')
quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])'
subprocess.run([sys.executable, '-c', quit_code, app], check=True, timeout=30)
if process.wait(timeout=15) != 0:
@@ -153,6 +170,15 @@ jobs:
process.wait()
PY
+ - name: Collect Filee logs
+ if: always()
+ shell: bash
+ run: |
+ mkdir -p "$RUNNER_TEMP/filee-screens"
+ for logs in "$HOME/Library/Application Support/Filee/logs" "$HOME/.config/Filee/logs"; do
+ if [ -d "$logs" ]; then cp -R "$logs" "$RUNNER_TEMP/filee-screens/"; fi
+ done
+
- name: Upload packages
uses: actions/upload-artifact@v4
with:
@@ -171,4 +197,5 @@ jobs:
TestResults/${{ matrix.rid }}/
tests/Filee.App.Tests/bin/Release/net10.0/screenshots/
${{ runner.temp }}/filee-startup.log
+ ${{ runner.temp }}/filee-screens/
if-no-files-found: ignore
From 031da575df3a9738089332b6da6a2f1abe423f29 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Tue, 6 Oct 2026 23:35:24 +0900
Subject: [PATCH 04/16] macOS: the menu bar icon no longer crashes Filee at
start; Linux keeps only the modification date
- The tray icon gets its tooltip before it is attached: Avalonia's macOS tray passed the null tooltip to native
code and Filee quit right after starting (found on GitHub's macOS runners).
- Linux file systems keep no creation time a program can set, so the keep-dates test checks only the modification
time there.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/Services/TrayService.cs | 2 ++
tests/Filee.Core.Tests/JobQueueTests.cs | 4 +++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/Filee.App/Services/TrayService.cs b/src/Filee.App/Services/TrayService.cs
index 065412c..52ebcef 100644
--- a/src/Filee.App/Services/TrayService.cs
+++ b/src/Filee.App/Services/TrayService.cs
@@ -33,6 +33,8 @@ public void Create(Application app)
_tray = new TrayIcon
{
Icon = new WindowIcon(iconStream),
+ // Set before the icon is attached: on macOS Avalonia passes the tooltip to native code, which can't take null.
+ ToolTipText = "Filee",
Menu = new NativeMenu { Items = { _open, _pause, new NativeMenuItemSeparator(), _quit } },
IsVisible = true,
};
diff --git a/tests/Filee.Core.Tests/JobQueueTests.cs b/tests/Filee.Core.Tests/JobQueueTests.cs
index 190c278..74a3e6f 100644
--- a/tests/Filee.Core.Tests/JobQueueTests.cs
+++ b/tests/Filee.Core.Tests/JobQueueTests.cs
@@ -48,7 +48,9 @@ public async Task Keep_dates_gives_outputs_the_dates_of_their_source()
await Run(queue, [plain], new Preset { TargetFormat = "png" });
var output = Path.Combine(dir.Path, "taken.png");
- Assert.Equal(created, File.GetCreationTimeUtc(output));
+ // Linux file systems keep no creation time a program can set: there only the modification time is kept.
+ if (!OperatingSystem.IsLinux())
+ Assert.Equal(created, File.GetCreationTimeUtc(output));
Assert.Equal(modified, File.GetLastWriteTimeUtc(output));
// Without the option the output has the time it was made.
Assert.True(File.GetLastWriteTimeUtc(Path.Combine(dir.Path, "plain.png")) > DateTime.UtcNow.AddMinutes(-5));
From aae7195a101828ca4d3ea07e138cc15d183c721b Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Tue, 6 Oct 2026 23:47:03 +0900
Subject: [PATCH 05/16] Finder and the file manager by name on macOS and Linux;
HWPX read-back tests run on Linux ARM64
- The home page hint and the gesture scope say Finder on macOS and the file manager on Linux instead of Explorer
(seen in the first screenshots from a real Mac).
- Tests read an HWPX back with Unhwp where its native library exists and from the section XML on Linux ARM64,
which Unhwp doesn't ship for (the app already lists Unhwp as unsupported there).
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/Assets/i18n/en.json | 4 ++++
src/Filee.App/Assets/i18n/ko.json | 4 ++++
src/Filee.App/Assets/i18n/zh-CN.json | 4 ++++
.../ViewModels/Pages/HomePageViewModel.cs | 2 +-
.../ViewModels/Pages/TriggersPageViewModel.cs | 6 +++++-
.../Filee.Engines.Tests/DocumentEngineTests.cs | 3 +--
tests/Filee.Engines.Tests/HtmlReaderTests.cs | 3 +--
tests/Filee.Engines.Tests/HwpxAssert.cs | 17 +++++++++++++++++
tests/Filee.Engines.Tests/HwpxWriterTests.cs | 6 ++----
tests/Filee.Engines.Tests/MarkdownTests.cs | 3 +--
10 files changed, 40 insertions(+), 12 deletions(-)
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 737798d..8a97953 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -22,6 +22,8 @@
"home.title": "Home",
"home.drop_title": "Drop files here",
"home.drop_hint": "Or hold {0} while dragging files in Explorer. The donut toolbar opens at your cursor.",
+ "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.",
+ "home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.",
"home.recent": "Recent conversions",
"home.recent_empty": "Your converted files will show up here.",
"home.open_folder": "Open folder",
@@ -208,6 +210,8 @@
"triggers.hold_ms": "Hold time",
"triggers.scope": "Active in",
"triggers.scope.FileManager": "Explorer and desktop",
+ "triggers.scope.FileManager_macos": "Finder and desktop",
+ "triggers.scope.FileManager_linux": "File manager and desktop",
"triggers.scope.Anywhere": "Everywhere",
"triggers.excluded": "Never trigger in these apps",
"triggers.excluded_hint": "Process names separated by commas, e.g. photoshop, code",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 5b61fe5..251d48a 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -22,6 +22,8 @@
"home.title": "홈",
"home.drop_title": "여기에 파일을 놓으세요",
"home.drop_hint": "또는 탐색기에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
+ "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
+ "home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.recent": "최근 변환",
"home.recent_empty": "변환한 파일이 여기에 표시됩니다.",
"home.open_folder": "폴더 열기",
@@ -208,6 +210,8 @@
"triggers.hold_ms": "누르는 시간",
"triggers.scope": "동작 범위",
"triggers.scope.FileManager": "탐색기와 바탕화면",
+ "triggers.scope.FileManager_macos": "Finder와 데스크탑",
+ "triggers.scope.FileManager_linux": "파일 관리자와 바탕화면",
"triggers.scope.Anywhere": "모든 곳",
"triggers.excluded": "이 앱에서는 동작하지 않기",
"triggers.excluded_hint": "프로세스 이름을 쉼표로 구분, 예: photoshop, code",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 191d16b..92dc0c8 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -22,6 +22,8 @@
"home.title": "首页",
"home.drop_title": "将文件拖放到这里",
"home.drop_hint": "或在资源管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
+ "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
+ "home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.recent": "最近的转换",
"home.recent_empty": "转换后的文件会显示在这里。",
"home.open_folder": "打开文件夹",
@@ -208,6 +210,8 @@
"triggers.hold_ms": "按住时间",
"triggers.scope": "生效范围",
"triggers.scope.FileManager": "资源管理器和桌面",
+ "triggers.scope.FileManager_macos": "访达和桌面",
+ "triggers.scope.FileManager_linux": "文件管理器和桌面",
"triggers.scope.Anywhere": "所有位置",
"triggers.excluded": "在这些应用中不触发",
"triggers.excluded_hint": "进程名,用逗号分隔,例如 photoshop, code",
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index f85b828..84e1e86 100644
--- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
@@ -54,7 +54,7 @@ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices
_loc = loc;
_platform = platform;
var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag);
- DropHint = loc.Format("home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers));
+ DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers));
store.HistoryChanged += OnHistoryChanged;
Reload();
}
diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
index 1d5d459..5408ae9 100644
--- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
@@ -29,7 +29,11 @@ public TriggerGestureViewModel(TriggerGesture gesture, ILocalizer loc, Action sa
Kinds = Enum.GetValues().Select(k => new Choice(k, loc[$"triggers.kind.{k}"])).ToList();
Buttons = Enum.GetValues().Where(b => b != TriggerMouseButton.None)
.Select(b => new Choice(b, loc[$"triggers.button.{b}"])).ToList();
- Scopes = Enum.GetValues().Select(s => new Choice(s, loc[$"triggers.scope.{s}"])).ToList();
+ // The file manager has its own name on each system: Explorer, Finder, the Linux desktop's file manager.
+ var platform = OperatingSystem.IsMacOS() ? "_macos" : OperatingSystem.IsLinux() ? "_linux" : "";
+ Scopes = Enum.GetValues()
+ .Select(s => new Choice(s, loc[s == TriggerScope.FileManager ? $"triggers.scope.{s}{platform}" : $"triggers.scope.{s}"]))
+ .ToList();
RecordLabel = loc["triggers.record"];
RecordingLabel = loc["triggers.recording"];
SystemLabel = loc["triggers.threshold_system"];
diff --git a/tests/Filee.Engines.Tests/DocumentEngineTests.cs b/tests/Filee.Engines.Tests/DocumentEngineTests.cs
index ba730b3..61e20e2 100644
--- a/tests/Filee.Engines.Tests/DocumentEngineTests.cs
+++ b/tests/Filee.Engines.Tests/DocumentEngineTests.cs
@@ -89,8 +89,7 @@ public async Task Rhwp_converts_between_hwpx_and_hwp()
var back = await fx.ConvertAsync([hwp.Outputs.Single()], new Preset { TargetFormat = "hwpx" });
Assert.Equal(JobState.Completed, back.State);
Assert.Equal("rhwp", Assert.Single(fx.Catalog.CreatePlanner().Plan("hwp", "hwpx")!.Steps).Converter.Id);
- using var document = Unhwp.UnhwpDocument.ParseFile(back.Outputs.Single());
- Assert.Contains("두 번째 문단입니다", document.ToText());
+ Assert.Contains("두 번째 문단입니다", HwpxAssert.ReadBackText(back.Outputs.Single()));
}
[Fact]
diff --git a/tests/Filee.Engines.Tests/HtmlReaderTests.cs b/tests/Filee.Engines.Tests/HtmlReaderTests.cs
index a570698..1c55576 100644
--- a/tests/Filee.Engines.Tests/HtmlReaderTests.cs
+++ b/tests/Filee.Engines.Tests/HtmlReaderTests.cs
@@ -280,9 +280,8 @@ await File.WriteAllTextAsync(html, """
Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
var hwpx = job.Outputs.Single();
ValidPackage(hwpx, expectImages: 1);
- using (var document = Unhwp.UnhwpDocument.ParseFile(hwpx))
{
- var text = document.ToText();
+ var text = HwpxAssert.ReadBackText(hwpx);
foreach (var expected in new[] { "분기 보고서", "12%", "서울", "부산", "매출", "둘째 쪽" })
Assert.Contains(expected, text);
}
diff --git a/tests/Filee.Engines.Tests/HwpxAssert.cs b/tests/Filee.Engines.Tests/HwpxAssert.cs
index e1927fa..211917a 100644
--- a/tests/Filee.Engines.Tests/HwpxAssert.cs
+++ b/tests/Filee.Engines.Tests/HwpxAssert.cs
@@ -86,6 +86,23 @@ public static XElement Xml(string hwpx, string entry = "Contents/section0.xml")
return XDocument.Load(zip.GetEntry(entry)!.Open(), LoadOptions.PreserveWhitespace).Root!;
}
+ ///
+ /// The text of an HWPX as an independent reader sees it: Unhwp where its native library exists, else (Linux ARM64)
+ /// the text of the section XML.
+ ///
+ public static string ReadBackText(string hwpx)
+ {
+ if (new Filee.Engines.Hwp.UnhwpConverter().GetStatus().IsAvailable)
+ {
+ using var document = Unhwp.UnhwpDocument.ParseFile(hwpx);
+ return document.ToText();
+ }
+ using var zip = ZipFile.OpenRead(hwpx);
+ return string.Join("\n", zip.Entries.Where(e => e.FullName.StartsWith("Contents/section", StringComparison.Ordinal))
+ .OrderBy(e => e.FullName, StringComparer.Ordinal)
+ .Select(e => XDocument.Load(e.Open()).Root!.Value));
+ }
+
/// The header element with the given id.
public static XElement HeadItem(string hwpx, string name, string id) =>
Xml(hwpx, "Contents/header.xml").Descendants(Hh + name).Single(e => (string?)e.Attribute("id") == id);
diff --git a/tests/Filee.Engines.Tests/HwpxWriterTests.cs b/tests/Filee.Engines.Tests/HwpxWriterTests.cs
index 5d80d46..99cd350 100644
--- a/tests/Filee.Engines.Tests/HwpxWriterTests.cs
+++ b/tests/Filee.Engines.Tests/HwpxWriterTests.cs
@@ -70,8 +70,7 @@ public async Task Markdown_becomes_a_valid_hwpx_that_other_readers_understand()
var hwpx = await ConvertAsync(md);
ValidPackage(hwpx, expectImages: 1);
- using var document = UnhwpDocument.ParseFile(hwpx);
- var text = document.ToText();
+ var text = HwpxAssert.ReadBackText(hwpx);
foreach (var expected in new[] { "첫 번째 제목", "굵게", "취소선", "인용문도 사라지면 안 됩니다", "셋째부터 시작", "중첩 글머리", "표 셀", "42", "코드 블록" })
Assert.Contains(expected, text);
@@ -122,8 +121,7 @@ public async Task Plain_text_needs_no_pandoc()
var hwpx = await ConvertAsync(txt);
ValidPackage(hwpx, expectImages: 0);
- using var document = UnhwpDocument.ParseFile(hwpx);
- Assert.Contains("세 번째 줄 <태그> & 기호", document.ToText());
+ Assert.Contains("세 번째 줄 <태그> & 기호", HwpxAssert.ReadBackText(hwpx));
}
[Fact]
diff --git a/tests/Filee.Engines.Tests/MarkdownTests.cs b/tests/Filee.Engines.Tests/MarkdownTests.cs
index bacaea1..aab80f7 100644
--- a/tests/Filee.Engines.Tests/MarkdownTests.cs
+++ b/tests/Filee.Engines.Tests/MarkdownTests.cs
@@ -112,8 +112,7 @@ public async Task Markdown_to_hwpx_keeps_structure()
var hwpx = await ConvertAsync(await WriteSampleAsync(dir), "hwpx");
HwpxAssert.ValidPackage(hwpx, expectImages: 1);
- using var document = Unhwp.UnhwpDocument.ParseFile(hwpx);
- var text = document.ToText();
+ var text = HwpxAssert.ReadBackText(hwpx);
foreach (var expected in new[] { "주간 회의", "배포", "v1.0.1", "설치", "민지", "☑ 끝낸 일", "☐ 남은 일" })
Assert.Contains(expected, text);
Assert.DoesNotContain("title:", text);
From 94462c0a673a56ae17f45d97f8edb0f164ea776f Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 03:40:10 +0900
Subject: [PATCH 06/16] Engine install guidance for macOS and Linux in the
app's language
The Homebrew and package manager hints for engines Filee doesn't download there were English in every language.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/Assets/i18n/en.json | 7 +++++++
src/Filee.App/Assets/i18n/ko.json | 7 +++++++
src/Filee.App/Assets/i18n/zh-CN.json | 7 +++++++
src/Filee.App/Services/EngineDownloadService.cs | 17 ++++++++++++++++-
4 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 8a97953..f6b30df 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -389,6 +389,13 @@
"engines.package.calibre.description": "Saves as MOBI and AZW3 for Kindle, and converts rare e-book formats: LIT, LRF, PDB, PML, RB, SNB and TCR, plus CHM and OEB input. EPUB, MOBI, AZW3, FB2 and comics (CBZ, CBR) are read without it.",
"engines.package.size": "Download {0} · {1} on disk",
"engines.package.system_required": "Use a copy installed on this computer.",
+ "engines.guidance.macos": "Install {0} for this Mac, then choose “Use my copy…” and select its program or .app folder.",
+ "engines.guidance.macos.ffmpeg": "Install it with Homebrew (brew install ffmpeg), then choose “Check again”, or “Use my copy…” and select its bin folder.",
+ "engines.guidance.macos.ghostscript": "Install it with Homebrew (brew install ghostscript), then choose “Check again”, or “Use my copy…” and select its bin folder.",
+ "engines.guidance.linux": "Install {0} with your distribution’s package manager, then choose “Use my copy…” and select its program folder.",
+ "engines.guidance.linux.libreoffice": "Install LibreOffice with your distribution’s package manager (Ubuntu/Debian: sudo apt install libreoffice), then choose “Check again”. Opening HWP through LibreOffice also needs Java and the H2Orestart extension.",
+ "engines.guidance.linux.ghostscript": "Install Ghostscript with your distribution’s package manager (Ubuntu/Debian: sudo apt install ghostscript), then choose “Check again”.",
+ "engines.guidance.windows": "Filee has no download of {0} for this PC. Choose “Use my copy…” and select an installed copy.",
"engines.use_own_app_folder": "Choose the folder containing {0}.app",
"engines.progress_detail": "{0} of {1} · {2}/s · {3}",
"engines.eta.estimating": "estimating time left…",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 251d48a..8e47a00 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -389,6 +389,13 @@
"engines.package.calibre.description": "킨들용 MOBI·AZW3로 저장하고, 드문 전자책 형식(LIT·LRF·PDB·PML·RB·SNB·TCR, CHM·OEB 읽기)을 변환해요. EPUB·MOBI·AZW3·FB2와 만화책(CBZ·CBR)은 없어도 읽을 수 있어요.",
"engines.package.size": "다운로드 {0} · 설치 후 {1}",
"engines.package.system_required": "이 컴퓨터에 설치된 엔진을 연결해 주세요.",
+ "engines.guidance.macos": "이 Mac에 {0}을(를) 설치한 뒤 “내 프로그램 쓰기…”로 프로그램이나 .app 폴더를 선택해 주세요.",
+ "engines.guidance.macos.ffmpeg": "Homebrew로 설치(brew install ffmpeg)한 뒤 “다시 확인”을 누르거나, “내 프로그램 쓰기…”로 bin 폴더를 선택해 주세요.",
+ "engines.guidance.macos.ghostscript": "Homebrew로 설치(brew install ghostscript)한 뒤 “다시 확인”을 누르거나, “내 프로그램 쓰기…”로 bin 폴더를 선택해 주세요.",
+ "engines.guidance.linux": "배포판의 패키지 관리자로 {0}을(를) 설치한 뒤 “내 프로그램 쓰기…”로 프로그램 폴더를 선택해 주세요.",
+ "engines.guidance.linux.libreoffice": "배포판의 패키지 관리자로 LibreOffice를 설치(Ubuntu/Debian: sudo apt install libreoffice)한 뒤 “다시 확인”을 눌러 주세요. LibreOffice로 HWP를 열려면 Java와 H2Orestart 확장도 필요해요.",
+ "engines.guidance.linux.ghostscript": "배포판의 패키지 관리자로 Ghostscript를 설치(Ubuntu/Debian: sudo apt install ghostscript)한 뒤 “다시 확인”을 눌러 주세요.",
+ "engines.guidance.windows": "이 PC용 {0} 다운로드는 없어요. “내 프로그램 쓰기…”로 설치된 프로그램을 선택해 주세요.",
"engines.use_own_app_folder": "{0}.app이 들어 있는 폴더 선택",
"engines.progress_detail": "{0} / {1} · {2}/s · {3}",
"engines.eta.estimating": "남은 시간 계산 중…",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 92dc0c8..9678143 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -389,6 +389,13 @@
"engines.package.calibre.description": "可保存为 Kindle 使用的 MOBI 和 AZW3,并转换少见的电子书格式:LIT、LRF、PDB、PML、RB、SNB、TCR,以及读取 CHM 和 OEB。EPUB、MOBI、AZW3、FB2 和漫画(CBZ、CBR)无需它即可读取。",
"engines.package.size": "下载 {0} · 占用 {1}",
"engines.package.system_required": "请使用此电脑上已安装的引擎。",
+ "engines.guidance.macos": "先在这台 Mac 上安装 {0},再点“使用我的程序…”选择它的程序或 .app 文件夹。",
+ "engines.guidance.macos.ffmpeg": "用 Homebrew 安装(brew install ffmpeg)后点“重新检查”,或点“使用我的程序…”选择它的 bin 文件夹。",
+ "engines.guidance.macos.ghostscript": "用 Homebrew 安装(brew install ghostscript)后点“重新检查”,或点“使用我的程序…”选择它的 bin 文件夹。",
+ "engines.guidance.linux": "用发行版的软件包管理器安装 {0},再点“使用我的程序…”选择它的程序文件夹。",
+ "engines.guidance.linux.libreoffice": "用发行版的软件包管理器安装 LibreOffice(Ubuntu/Debian:sudo apt install libreoffice)后点“重新检查”。通过 LibreOffice 打开 HWP 还需要 Java 和 H2Orestart 扩展。",
+ "engines.guidance.linux.ghostscript": "用发行版的软件包管理器安装 Ghostscript(Ubuntu/Debian:sudo apt install ghostscript)后点“重新检查”。",
+ "engines.guidance.windows": "没有适用于这台电脑的 {0} 下载。点“使用我的程序…”选择已安装的程序。",
"engines.use_own_app_folder": "选择包含 {0}.app 的文件夹",
"engines.progress_detail": "{0} / {1} · {2}/s · {3}",
"engines.eta.estimating": "正在估算剩余时间…",
diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs
index c094abd..1e5ba3d 100644
--- a/src/Filee.App/Services/EngineDownloadService.cs
+++ b/src/Filee.App/Services/EngineDownloadService.cs
@@ -43,7 +43,22 @@ public EnginePackageState(EnginePackage package, ILocalizer loc)
? _loc.Format("engines.package.size", FormatBytes(EngineDownloads.DownloadSize(Package)), FormatBytes(Package.InstalledSize))
: _loc["engines.package.system_required"];
- public string? InstallGuidance => EngineDownloads.UnavailableReason(Package);
+ ///
+ /// How to get an engine Filee doesn't download on this system (FFmpeg and Ghostscript from Homebrew on macOS,
+ /// the distribution's packages on Linux), in the app's language; null when Filee can download it.
+ ///
+ public string? InstallGuidance
+ {
+ get
+ {
+ if (EngineDownloads.CanDownload(Package))
+ return null;
+ var system = OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "windows";
+ var key = $"engines.guidance.{system}.{Package.Id}";
+ var text = _loc[key];
+ return text != key ? text : _loc.Format($"engines.guidance.{system}", Name);
+ }
+ }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText), nameof(IsInstalled), nameof(IsBusy), nameof(CanInstall), nameof(CanRemove),
From a8715f55777c853e166dfd629f99f92083696284 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 04:06:54 +0900
Subject: [PATCH 07/16] macOS: Filee follows the system language when started
from Finder
An app started from Finder gets no LANG, so .NET saw no UI language and Filee came up in English on a Korean Mac.
"auto" now reads the language list macOS keeps (CFLocaleCopyPreferredLanguages), and the bundle declares its
localizations (en, ko, zh-Hans) so system panels follow too.
Co-Authored-By: Claude Opus 5.5
---
installer/macos/Info.plist | 1 +
src/Filee.App/Services/LocalizationService.cs | 3 +-
src/Filee.Platform.MacOS/MacOSLocale.cs | 54 +++++++++++++++++++
3 files changed, 57 insertions(+), 1 deletion(-)
create mode 100644 src/Filee.Platform.MacOS/MacOSLocale.cs
diff --git a/installer/macos/Info.plist b/installer/macos/Info.plist
index 49eca65..6b069a3 100644
--- a/installer/macos/Info.plist
+++ b/installer/macos/Info.plist
@@ -3,6 +3,7 @@
CFBundleDevelopmentRegionen
+ CFBundleLocalizationsenkozh-HansCFBundleIdentifiercom.filee.appCFBundleNameFileeCFBundleDisplayNameFilee
diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs
index cf6a9a2..f09685a 100644
--- a/src/Filee.App/Services/LocalizationService.cs
+++ b/src/Filee.App/Services/LocalizationService.cs
@@ -91,7 +91,8 @@ public static string Resolve(string setting)
Languages.Any(l => l.Code.Equals(setting, StringComparison.OrdinalIgnoreCase)))
return Languages.First(l => l.Code.Equals(setting, StringComparison.OrdinalIgnoreCase)).Code;
- var ui = CultureInfo.InstalledUICulture.Name;
+ // macOS gives an app started from Finder no LANG: its language list comes from the system instead.
+ var ui = Filee.Platform.MacOS.MacOSLocale.PreferredLanguage() ?? CultureInfo.InstalledUICulture.Name;
if (ui.StartsWith("ko", StringComparison.OrdinalIgnoreCase))
return "ko";
if (ui.StartsWith("zh", StringComparison.OrdinalIgnoreCase))
diff --git a/src/Filee.Platform.MacOS/MacOSLocale.cs b/src/Filee.Platform.MacOS/MacOSLocale.cs
new file mode 100644
index 0000000..1b8edf2
--- /dev/null
+++ b/src/Filee.Platform.MacOS/MacOSLocale.cs
@@ -0,0 +1,54 @@
+// The user's language on macOS. An app started from Finder gets no LANG variable, so .NET's InstalledUICulture is
+// the invariant culture there; macOS keeps the language list (System Settings > General > Language & Region) in
+// AppleLanguages, which CFLocaleCopyPreferredLanguages returns, unfiltered by the app's own localizations.
+
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Filee.Platform.MacOS;
+
+public static class MacOSLocale
+{
+ private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+ private const uint Utf8 = 0x08000100;
+
+ /// The first language the user prefers, e.g. "ko-KR" or "zh-Hans-CN"; null when macOS gives none.
+ public static string? PreferredLanguage()
+ {
+ if (!OperatingSystem.IsMacOS())
+ return null;
+ var languages = CFLocaleCopyPreferredLanguages();
+ if (languages == 0)
+ return null;
+ try
+ {
+ if (CFArrayGetCount(languages) == 0)
+ return null;
+ var first = CFArrayGetValueAtIndex(languages, 0);
+ var buffer = new byte[128];
+ return CFStringGetCString(first, buffer, buffer.Length, Utf8)
+ ? Encoding.UTF8.GetString(buffer, 0, Array.IndexOf(buffer, (byte)0) is var end and >= 0 ? end : buffer.Length)
+ : null;
+ }
+ finally
+ {
+ CFRelease(languages);
+ }
+ }
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFLocaleCopyPreferredLanguages();
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFArrayGetCount(nint array);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFArrayGetValueAtIndex(nint array, nint index);
+
+ [DllImport(CoreFoundation)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool CFStringGetCString(nint value, [Out] byte[] buffer, nint size, uint encoding);
+
+ [DllImport(CoreFoundation)]
+ private static extern void CFRelease(nint value);
+}
From 8a7dff565fe8a77772ccf48bdecf2a1b3df8cc89 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 04:20:53 +0900
Subject: [PATCH 08/16] macOS: ask for Accessibility at start, remind on the
home page, start gestures as soon as it is given
- When Filee is opened (not at sign-in) with a drag gesture on and no Accessibility permission, macOS's own alert
asks for it and lists Filee under Accessibility, so the user only switches it on.
- The home page shows a reminder with an "Open Accessibility settings" button until the permission is given.
- Gestures start by themselves within two seconds of the permission being given; no restart.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/App.axaml.cs | 6 +++
src/Filee.App/Assets/i18n/en.json | 3 +-
src/Filee.App/Assets/i18n/ko.json | 3 +-
src/Filee.App/Assets/i18n/zh-CN.json | 3 +-
.../Services/Triggers/TriggerService.cs | 39 +++++++++++++++++++
.../ViewModels/Pages/HomePageViewModel.cs | 25 +++++++++++-
src/Filee.App/Views/Pages/HomePage.axaml | 10 +++++
.../MacOSPlatformServices.cs | 3 ++
src/Filee.Platform.MacOS/NativeMethods.cs | 30 ++++++++++++++
9 files changed, 117 insertions(+), 5 deletions(-)
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index 8d29003..4e93f27 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -76,6 +76,12 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
};
triggers.Start();
log.LogInformation("Filee {Version} started", UpdateService.CurrentVersion);
+ // macOS: ask for Accessibility with the system's own alert when the user opened Filee (not at sign-in) and
+ // a drag gesture is on. The home page keeps a reminder until it is given.
+ if (OperatingSystem.IsMacOS() && triggers.NeedsPermission && !Program.Options.Background
+ && platform is Filee.Platform.MacOS.MacOSPlatformServices mac
+ && store.Settings.Triggers.Any(t => t.Enabled && t.Kind == TriggerKind.Drag))
+ mac.RequestAccessibilityPermission();
store.SettingsChanged += (_, _) => ApplySettings(store.Settings);
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index f6b30df..cb93018 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -24,6 +24,7 @@
"home.drop_hint": "Or hold {0} while dragging files in Explorer. The donut toolbar opens at your cursor.",
"home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.",
"home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.",
+ "home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility. It works as soon as you switch it on.",
"home.recent": "Recent conversions",
"home.recent_empty": "Your converted files will show up here.",
"home.open_folder": "Open folder",
@@ -178,7 +179,7 @@
"triggers.title": "Shortcuts",
"triggers.description": "Choose how the donut toolbar opens. You can add as many gestures as you like.",
- "triggers.macos_permissions": "Allow Filee in System Settings > Privacy & Security > Accessibility, then retry gestures. Finder also asks for Automation permission when you first use a selection shortcut.",
+ "triggers.macos_permissions": "Allow Filee in System Settings > Privacy & Security > Accessibility; gestures start as soon as you switch it on. Finder also asks for Automation permission when you first use a selection shortcut.",
"triggers.linux_x11": "Drag gestures use X11. The first enabled keyboard shortcut is assigned to the Thunar Filee action when file manager integration is on. Close Thunar before changing it, then reopen Thunar. Hold gestures and global selection shortcuts in other file managers are unavailable; use their Filee menu action or the drop zone.",
"triggers.linux_wayland": "Cursor-following gestures are unavailable in this Wayland session. Use an X11 session for drag gestures, or convert through file manager actions and the drop zone. Thunar can use the first enabled keyboard shortcut after it restarts.",
"triggers.open_accessibility": "Open Accessibility settings",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 8e47a00..fdd406f 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -24,6 +24,7 @@
"home.drop_hint": "또는 탐색기에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
+ "home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요. 켜는 즉시 동작해요.",
"home.recent": "최근 변환",
"home.recent_empty": "변환한 파일이 여기에 표시됩니다.",
"home.open_folder": "폴더 열기",
@@ -178,7 +179,7 @@
"triggers.title": "단축키",
"triggers.description": "원형 툴바를 여는 방법을 정하세요. 제스처는 원하는 만큼 추가할 수 있어요.",
- "triggers.macos_permissions": "시스템 설정 > 개인정보 보호 및 보안 > 손쉬운 사용에서 Filee를 허용한 뒤 제스처를 다시 시도해 주세요. 선택 파일 단축키를 처음 사용하면 Finder 자동화 권한도 요청해요.",
+ "triggers.macos_permissions": "시스템 설정 > 개인정보 보호 및 보안 > 손쉬운 사용에서 Filee를 허용하면 바로 제스처를 쓸 수 있어요. 선택 파일 단축키를 처음 사용하면 Finder 자동화 권한도 요청해요.",
"triggers.linux_x11": "드래그 제스처는 X11에서 작동해요. 파일 관리자 연동을 켜면 첫 번째 활성 키보드 단축키를 Thunar의 Filee 동작에 연결해요. 변경할 때는 Thunar를 닫고 변경 후 다시 열어 주세요. 길게 누르기와 다른 파일 관리자의 전역 선택 단축키는 사용할 수 없어요. Filee 메뉴나 드롭 영역을 사용해 주세요.",
"triggers.linux_wayland": "이 Wayland 세션에서는 커서를 따라가는 제스처를 사용할 수 없어요. 드래그 제스처는 X11 세션에서 사용하거나, 파일 관리자 메뉴와 드롭 영역으로 변환해 주세요. Thunar는 다시 시작한 뒤 첫 번째 활성 키보드 단축키를 사용할 수 있어요.",
"triggers.open_accessibility": "손쉬운 사용 설정 열기",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 9678143..73229bb 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -24,6 +24,7 @@
"home.drop_hint": "或在资源管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
+ "home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。打开后立即生效。",
"home.recent": "最近的转换",
"home.recent_empty": "转换后的文件会显示在这里。",
"home.open_folder": "打开文件夹",
@@ -178,7 +179,7 @@
"triggers.title": "快捷操作",
"triggers.description": "选择打开环形工具栏的方式。可以添加任意多个手势。",
- "triggers.macos_permissions": "请在系统设置 > 隐私与安全性 > 辅助功能中允许 Filee,然后重试手势。首次使用选择文件的快捷键时,Finder 还会请求自动化权限。",
+ "triggers.macos_permissions": "请在系统设置 > 隐私与安全性 > 辅助功能中允许 Filee,打开后手势立即可用。首次使用选择文件的快捷键时,Finder 还会请求自动化权限。",
"triggers.linux_x11": "拖动手势需要 X11。启用文件管理器集成后,第一个已启用的键盘快捷键会分配给 Thunar 的 Filee 操作。修改前请关闭 Thunar,修改后重新打开。不支持长按手势及其他文件管理器的全局选择快捷键,请使用 Filee 菜单或拖放区域。",
"triggers.linux_wayland": "当前 Wayland 会话不支持跟随光标的手势。请使用 X11 会话进行拖动,或通过文件管理器操作和拖放区域转换。重启 Thunar 后可使用第一个已启用的键盘快捷键。",
"triggers.open_accessibility": "打开辅助功能设置",
diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs
index 24eb0bf..0f50b68 100644
--- a/src/Filee.App/Services/Triggers/TriggerService.cs
+++ b/src/Filee.App/Services/Triggers/TriggerService.cs
@@ -63,12 +63,26 @@ public void Apply(AppSettings settings)
StringComparer.OrdinalIgnoreCase);
}
+ /// macOS without Accessibility permission: gestures wait for it (and start by themselves once it is given).
+ public bool NeedsPermission { get; private set; }
+
+ private Timer? _permissionWatch;
+
public void Start()
{
if (_hook is not null)
return;
if (!_platform.SupportsGlobalPointerGestures)
+ {
+ if (OperatingSystem.IsMacOS())
+ WatchForPermission();
return;
+ }
+ if (NeedsPermission)
+ {
+ NeedsPermission = false;
+ NotifyStatus();
+ }
Error = null;
try
{
@@ -153,8 +167,33 @@ internal static string KeyName(KeyCode code)
return name.StartsWith("Vc", StringComparison.Ordinal) ? name[2..] : name;
}
+ ///
+ /// Checks every two seconds whether the user switched Filee on under Accessibility, then starts the gestures;
+ /// no restart needed.
+ ///
+ private void WatchForPermission()
+ {
+ if (!NeedsPermission)
+ {
+ NeedsPermission = true;
+ _log.LogInformation("Gestures wait for the Accessibility permission");
+ NotifyStatus();
+ }
+ _permissionWatch ??= new Timer(_ =>
+ {
+ if (!_platform.SupportsGlobalPointerGestures)
+ return;
+ _permissionWatch?.Dispose();
+ _permissionWatch = null;
+ _log.LogInformation("Accessibility permission granted");
+ Post(Start);
+ }, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+ }
+
public void Dispose()
{
+ _permissionWatch?.Dispose();
+ _permissionWatch = null;
_hook?.Dispose();
_hook = null;
}
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index 84e1e86..9384c8a 100644
--- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
@@ -47,12 +47,16 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable
private readonly UserDataStore _store;
private readonly ILocalizer _loc;
private readonly IPlatformServices _platform;
+ private readonly Services.Triggers.TriggerService? _triggers;
- public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform)
+ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null)
{
_store = store;
_loc = loc;
_platform = platform;
+ _triggers = triggers;
+ if (triggers is not null)
+ triggers.StatusChanged += OnTriggerStatusChanged;
var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag);
DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers));
store.HistoryChanged += OnHistoryChanged;
@@ -61,7 +65,24 @@ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices
private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload);
- public void Dispose() => _store.HistoryChanged -= OnHistoryChanged;
+ public void Dispose()
+ {
+ _store.HistoryChanged -= OnHistoryChanged;
+ if (_triggers is not null)
+ _triggers.StatusChanged -= OnTriggerStatusChanged;
+ }
+
+ private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility));
+
+ /// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone).
+ public bool NeedsAccessibility => _triggers?.NeedsPermission == true;
+
+ [RelayCommand]
+ private void OpenAccessibility()
+ {
+ if (OperatingSystem.IsMacOS() && _platform is Filee.Platform.MacOS.MacOSPlatformServices mac)
+ mac.OpenAccessibilitySettings();
+ }
public string DropHint { get; }
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml
index c80c4e8..c4d1acb 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml
+++ b/src/Filee.App/Views/Pages/HomePage.axaml
@@ -22,6 +22,16 @@
+
+
+
+
+
+
+
+
NativeMethods.AXIsProcessTrusted();
+ /// Asks for Accessibility with macOS's own alert (nothing happens when Filee already has it).
+ public bool RequestAccessibilityPermission() => NativeMethods.RequestAccessibility();
+
public bool SupportsGlobalPointerGestures => HasAccessibilityPermission;
public bool SupportsSelectionShortcut => true;
diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs
index e5247b3..7e4b907 100644
--- a/src/Filee.Platform.MacOS/NativeMethods.cs
+++ b/src/Filee.Platform.MacOS/NativeMethods.cs
@@ -107,6 +107,36 @@ private struct CGRect
[return: MarshalAs(UnmanagedType.I1)]
internal static extern bool AXIsProcessTrusted();
+ ///
+ /// Whether Filee may watch input; when not, macOS shows its own alert (Open System Settings / Deny) and lists
+ /// Filee under Accessibility, switched off, so the user only has to switch it on.
+ ///
+ internal static bool RequestAccessibility()
+ {
+ var coreFoundation = NativeLibrary.Load(CoreFoundation);
+ var trueValue = Marshal.ReadIntPtr(NativeLibrary.GetExport(coreFoundation, "kCFBooleanTrue"));
+ var key = CFStringCreateWithCString(0, "AXTrustedCheckOptionPrompt", 0x08000100);
+ var options = CFDictionaryCreate(0, [key], [trueValue], 1,
+ NativeLibrary.GetExport(coreFoundation, "kCFTypeDictionaryKeyCallBacks"),
+ NativeLibrary.GetExport(coreFoundation, "kCFTypeDictionaryValueCallBacks"));
+ try
+ {
+ return AXIsProcessTrustedWithOptions(options);
+ }
+ finally
+ {
+ CFRelease(options);
+ CFRelease(key);
+ }
+ }
+
+ [DllImport(ApplicationServices)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool AXIsProcessTrustedWithOptions(nint options);
+
+ [DllImport(CoreFoundation)]
+ private static extern nint CFDictionaryCreate(nint allocator, nint[] keys, nint[] values, nint count, nint keyCallBacks, nint valueCallBacks);
+
[DllImport(CoreGraphics)]
private static extern nint CGWindowListCopyWindowInfo(uint option, uint relativeToWindow);
From 756ad775026669b0c79076298a5cd0e2c9df04cb Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 04:29:00 +0900
Subject: [PATCH 09/16] macOS: ask for Accessibility once the main window is
up, so the alert isn't hidden behind it
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/App.axaml.cs | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index 4e93f27..f420dde 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -77,11 +77,16 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
triggers.Start();
log.LogInformation("Filee {Version} started", UpdateService.CurrentVersion);
// macOS: ask for Accessibility with the system's own alert when the user opened Filee (not at sign-in) and
- // a drag gesture is on. The home page keeps a reminder until it is given.
+ // a drag gesture is on. The home page keeps a reminder until it is given. Asked once the main window is up:
+ // asked earlier, the alert ends up behind it.
if (OperatingSystem.IsMacOS() && triggers.NeedsPermission && !Program.Options.Background
&& platform is Filee.Platform.MacOS.MacOSPlatformServices mac
&& store.Settings.Triggers.Any(t => t.Enabled && t.Kind == TriggerKind.Drag))
- mac.RequestAccessibilityPermission();
+ DispatcherTimer.RunOnce(() =>
+ {
+ if (triggers.NeedsPermission)
+ mac.RequestAccessibilityPermission();
+ }, TimeSpan.FromSeconds(1.5));
store.SettingsChanged += (_, _) => ApplySettings(store.Settings);
From bc158a29dc4780aef001f458c303c5f0ac7a4c88 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 05:01:21 +0900
Subject: [PATCH 10/16] macOS: Option+drag opens the donut over Finder;
FILEE_TRACE_GESTURES logs what a gesture sees
The window under the cursor was looked up among all on-screen windows, and the Dock keeps a transparent window
above the whole screen: every drag was taken as over the Dock, never over Finder. Only app windows (layer 0) and
the desktop count now. FILEE_TRACE_GESTURES=1 logs each press with its modifiers and the window it was over.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/Services/Triggers/TriggerService.cs | 12 +++++++++++-
src/Filee.Platform.MacOS/NativeMethods.cs | 6 ++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs
index 0f50b68..0104991 100644
--- a/src/Filee.App/Services/Triggers/TriggerService.cs
+++ b/src/Filee.App/Services/Triggers/TriggerService.cs
@@ -22,6 +22,9 @@ public sealed class TriggerService : IDisposable
private EventLoopGlobalHook? _hook;
private HashSet _excluded = new(StringComparer.OrdinalIgnoreCase);
+ /// FILEE_TRACE_GESTURES=1 logs each mouse press and scope decision, to find why a gesture doesn't fire.
+ private static readonly bool Trace = Environment.GetEnvironmentVariable("FILEE_TRACE_GESTURES") == "1";
+
public TriggerService(IPlatformServices platform, ILogger log)
{
_platform = platform;
@@ -89,7 +92,12 @@ public void Start()
_hook = new EventLoopGlobalHook();
_hook.HookEnabled += (_, _) => { _log.LogInformation("Global input hook started"); NotifyStatus(); };
_hook.HookDisabled += (_, _) => { _log.LogInformation("Global input hook stopped"); NotifyStatus(); };
- _hook.MousePressed += (_, e) => _detector.MouseDown(Map(e.Data.Button), e.Data.X, e.Data.Y, Map(e.RawEvent.Mask));
+ _hook.MousePressed += (_, e) =>
+ {
+ if (Trace)
+ _log.LogInformation("Trace: press {Button} at {X},{Y}, mask {Mask} = {Modifiers}", e.Data.Button, e.Data.X, e.Data.Y, e.RawEvent.Mask, Map(e.RawEvent.Mask));
+ _detector.MouseDown(Map(e.Data.Button), e.Data.X, e.Data.Y, Map(e.RawEvent.Mask));
+ };
_hook.MouseReleased += (_, e) => _detector.MouseUp(Map(e.Data.Button), e.Data.X, e.Data.Y);
_hook.MouseDragged += (_, e) => OnMove(e);
_hook.MouseMoved += (_, e) => OnMove(e);
@@ -125,6 +133,8 @@ private void OnMove(MouseHookEventArgs e)
private bool InScope(TriggerGesture gesture, int x, int y)
{
var process = _platform.ProcessNameAt(x, y);
+ if (Trace)
+ _log.LogInformation("Trace: {Gesture} at {X},{Y} over {Process}", gesture.Kind, x, y, process ?? "(none)");
if (process is not null && (string.Equals(process, _ownProcess, StringComparison.OrdinalIgnoreCase) || _excluded.Contains(process)))
return false;
return gesture.Scope == TriggerScope.Anywhere || _platform.IsFileManagerAt(x, y);
diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs
index 7e4b907..3a7af6c 100644
--- a/src/Filee.Platform.MacOS/NativeMethods.cs
+++ b/src/Filee.Platform.MacOS/NativeMethods.cs
@@ -15,6 +15,7 @@ internal static class NativeMethods
private static readonly nint BoundsKey = CFStringCreateWithCString(0, "kCGWindowBounds", 0x08000100);
private static readonly nint PidKey = CFStringCreateWithCString(0, "kCGWindowOwnerPID", 0x08000100);
private static readonly nint AlphaKey = CFStringCreateWithCString(0, "kCGWindowAlpha", 0x08000100);
+ private static readonly nint LayerKey = CFStringCreateWithCString(0, "kCGWindowLayer", 0x08000100);
internal static string? ProcessNameAt(int x, int y)
{
@@ -32,6 +33,11 @@ internal static class NativeMethods
// Quartz and Avalonia's macOS desktop coordinates use top-left points, including on Retina displays.
if (x < bounds.X || y < bounds.Y || x >= bounds.X + bounds.Width || y >= bounds.Y + bounds.Height)
continue;
+ // Only app windows (layer 0) and the desktop (below 0): the Dock, the menu bar and other system
+ // overlays sit above (the Dock keeps a transparent window over the whole screen).
+ var layerValue = CFDictionaryGetValue(window, LayerKey);
+ if (layerValue != 0 && CFNumberGetInt(layerValue, 3, out var layer) && layer > 0)
+ continue;
var alphaValue = CFDictionaryGetValue(window, AlphaKey);
if (alphaValue != 0 && CFNumberGetDouble(alphaValue, 6, out var alpha) && alpha <= 0)
continue;
From 4704fe066817d2cda2b73ff5defa84e8faeeebc7 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 05:44:55 +0900
Subject: [PATCH 11/16] macOS: Filee answers "Convert with Filee" in Finder's
right-click menu itself
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Automator Quick Action didn't run in testing (not even one made in Automator), and needed a shell, a second
Filee process and the pipe between them. Filee.app now declares the service in its Info.plist (NSServices,
message convertFiles) and registers the object that answers it at start, so macOS hands the selected files
straight to the running Filee, starting it first if needed.
- ServicesMenu.strings translate the title (Filee로 변환, 用 Filee 转换); the package copies the .lproj folders.
- The settings switch turns the service on and off the way System Settings does (pbs NSServicesStatus) and
removes the Automator workflow earlier builds installed.
Co-Authored-By: Claude Opus 5.5
---
build/build-portable.ps1 | 4 +
installer/macos/Info.plist | 13 ++
installer/macos/en.lproj/ServicesMenu.strings | 2 +
installer/macos/ko.lproj/ServicesMenu.strings | 2 +
.../macos/zh-Hans.lproj/ServicesMenu.strings | 2 +
src/Filee.App/App.axaml.cs | 5 +
src/Filee.App/Assets/i18n/en.json | 4 +-
src/Filee.App/Assets/i18n/ko.json | 4 +-
src/Filee.App/Assets/i18n/zh-CN.json | 4 +-
.../MacOSIntegrationFiles.cs | 64 ++-------
.../MacOSPlatformServices.cs | 33 ++---
.../MacOSServicesProvider.cs | 122 ++++++++++++++++++
.../MacOSIntegrationTests.cs | 45 +++----
13 files changed, 203 insertions(+), 101 deletions(-)
create mode 100644 installer/macos/en.lproj/ServicesMenu.strings
create mode 100644 installer/macos/ko.lproj/ServicesMenu.strings
create mode 100644 installer/macos/zh-Hans.lproj/ServicesMenu.strings
create mode 100644 src/Filee.Platform.MacOS/MacOSServicesProvider.cs
diff --git a/build/build-portable.ps1 b/build/build-portable.ps1
index b56f639..3aa2c00 100644
--- a/build/build-portable.ps1
+++ b/build/build-portable.ps1
@@ -84,6 +84,10 @@ if ($Runtime.StartsWith('osx-')) {
}
}
Invoke-Checked 'Create app icon' { iconutil -c icns $iconset -o (Join-Path $resources 'filee.icns') }
+ # Translations of the service title ("Convert with Filee") in Finder's right-click menu.
+ foreach ($localization in Get-ChildItem -LiteralPath (Join-Path $root 'installer/macos') -Directory -Filter '*.lproj') {
+ Copy-Item -LiteralPath $localization.FullName -Destination $resources -Recurse
+ }
$entitlements = Join-Path $root 'installer/macos/entitlements.plist'
$signOptions = @('--force', '--sign', $SigningIdentity)
if ($SigningIdentity -ne '-') { $signOptions += @('--timestamp', '--options', 'runtime') }
diff --git a/installer/macos/Info.plist b/installer/macos/Info.plist
index 6b069a3..9b8e46e 100644
--- a/installer/macos/Info.plist
+++ b/installer/macos/Info.plist
@@ -14,6 +14,19 @@
CFBundleIconFilefilee.icnsLSMinimumSystemVersion14.0NSHighResolutionCapable
+
+ NSServices
+
+
+ NSMenuItemdefaultConvert with Filee
+ NSMessageconvertFiles
+ NSPortNameFilee
+ NSRequiredContext
+ NSSendFileTypespublic.item
+
+ NSAppleEventsUsageDescriptionFilee reads selected Finder files when you use a conversion shortcut.NSInputMonitoringUsageDescriptionFilee detects your conversion shortcut and drag gesture to show the conversion menu.
diff --git a/installer/macos/en.lproj/ServicesMenu.strings b/installer/macos/en.lproj/ServicesMenu.strings
new file mode 100644
index 0000000..57716f5
--- /dev/null
+++ b/installer/macos/en.lproj/ServicesMenu.strings
@@ -0,0 +1,2 @@
+/* Finder's right-click menu: the title of Filee's service (NSServices in Info.plist). */
+"Convert with Filee" = "Convert with Filee";
diff --git a/installer/macos/ko.lproj/ServicesMenu.strings b/installer/macos/ko.lproj/ServicesMenu.strings
new file mode 100644
index 0000000..8d95541
--- /dev/null
+++ b/installer/macos/ko.lproj/ServicesMenu.strings
@@ -0,0 +1,2 @@
+/* Finder's right-click menu: the title of Filee's service (NSServices in Info.plist). */
+"Convert with Filee" = "Filee로 변환";
diff --git a/installer/macos/zh-Hans.lproj/ServicesMenu.strings b/installer/macos/zh-Hans.lproj/ServicesMenu.strings
new file mode 100644
index 0000000..e4e712f
--- /dev/null
+++ b/installer/macos/zh-Hans.lproj/ServicesMenu.strings
@@ -0,0 +1,2 @@
+/* Finder's right-click menu: the title of Filee's service (NSServices in Info.plist). */
+"Convert with Filee" = "用 Filee 转换";
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index f420dde..1d06c2c 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -36,6 +36,11 @@ public override void OnFrameworkInitializationCompleted()
private void Start(IClassicDesktopStyleApplicationLifetime desktop)
{
+ // macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while
+ // Filee wasn't running is delivered as soon as it starts.
+ if (OperatingSystem.IsMacOS())
+ Filee.Platform.MacOS.MacOSServicesProvider.Register(files =>
+ Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files]))));
var log = AppHost.Get>();
var store = AppHost.Get();
store.Load();
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index cb93018..a6eed63 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -128,9 +128,9 @@
"general.start_with_system": "Start Filee when I sign in",
"general.context_menu": "Show “Convert with Filee” in the Explorer context menu",
"general.context_menu_label": "Convert with Filee",
- "general.context_menu_macos": "Add “Convert with Filee” to Finder Quick Actions",
+ "general.context_menu_macos": "Show “Convert with Filee” when you right-click files in Finder",
"general.context_menu_linux": "Add “Convert with Filee” to the file manager",
- "general.integration_macos": "Finder shows the action under Quick Actions or Services. Enable it in System Settings > Keyboard > Keyboard Shortcuts > Services if it is hidden.",
+ "general.integration_macos": "Finder shows it in the right-click menu (under Quick Actions or Services when the menu is long). Keep Filee in Applications.",
"general.integration_linux": "File actions are installed for Thunar, Nautilus, Nemo and Dolphin. Close Thunar before changing or pausing its shortcut, then reopen it to load the change.",
"general.integration_failed": "Could not apply system integration: {0}",
"general.modern_menu_hint": "On Windows 11 this entry is under “Show more options”. Filee can also add it to the main menu; Windows asks for administrator permission once.",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index fdd406f..9ede875 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -128,9 +128,9 @@
"general.start_with_system": "로그인할 때 Filee 시작",
"general.context_menu": "탐색기 우클릭 메뉴에 “Filee로 변환” 표시",
"general.context_menu_label": "Filee로 변환",
- "general.context_menu_macos": "Finder 빠른 동작에 “Filee로 변환” 추가",
+ "general.context_menu_macos": "Finder에서 파일을 우클릭하면 “Filee로 변환” 표시",
"general.context_menu_linux": "파일 관리자 메뉴에 “Filee로 변환” 추가",
- "general.integration_macos": "Finder의 빠른 동작 또는 서비스에서 사용할 수 있어요. 보이지 않으면 시스템 설정 > 키보드 > 키보드 단축키 > 서비스에서 켜 주세요.",
+ "general.integration_macos": "Finder 우클릭 메뉴에 나타나요(메뉴가 길면 빠른 동작 또는 서비스 안에). Filee는 응용 프로그램 폴더에 두세요.",
"general.integration_linux": "Thunar, Nautilus, Nemo, Dolphin에 파일 동작을 추가해요. Thunar 단축키를 변경하거나 일시정지할 때는 Thunar를 닫은 상태에서 적용한 뒤 다시 열어 주세요.",
"general.integration_failed": "시스템 연동을 적용하지 못했어요: {0}",
"general.modern_menu_hint": "Windows 11에서는 이 항목이 “추가 옵션 표시” 안에 있어요. 기본 메뉴에도 추가할 수 있고, 이때 Windows가 관리자 권한을 한 번 요청해요.",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 73229bb..8172a87 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -128,9 +128,9 @@
"general.start_with_system": "登录时启动 Filee",
"general.context_menu": "在资源管理器右键菜单中显示“用 Filee 转换”",
"general.context_menu_label": "用 Filee 转换",
- "general.context_menu_macos": "在 Finder 快速操作中添加“用 Filee 转换”",
+ "general.context_menu_macos": "在 Finder 中右键点按文件时显示“用 Filee 转换”",
"general.context_menu_linux": "在文件管理器菜单中添加“用 Filee 转换”",
- "general.integration_macos": "可在 Finder 的快速操作或服务中找到此操作。若未显示,请在系统设置 > 键盘 > 键盘快捷键 > 服务中启用。",
+ "general.integration_macos": "Finder 的右键菜单中会出现此项(菜单较长时在“快速操作”或“服务”中)。请将 Filee 保留在“应用程序”文件夹中。",
"general.integration_linux": "为 Thunar、Nautilus、Nemo 和 Dolphin 安装文件操作。修改或暂停 Thunar 快捷键前请关闭 Thunar,然后重新打开以加载更改。",
"general.integration_failed": "无法应用系统集成:{0}",
"general.modern_menu_hint": "在 Windows 11 中,此项位于“显示更多选项”里。Filee 也可以把它添加到主菜单,Windows 会请求一次管理员权限。",
diff --git a/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs b/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
index de5673d..94f5a20 100644
--- a/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
+++ b/src/Filee.Platform.MacOS/MacOSIntegrationFiles.cs
@@ -12,61 +12,21 @@ internal static string LaunchAgent(string executablePath) => Plist(Dict(
("RunAtLoad", true),
("KeepAlive", false)));
- internal static string ServiceInfo(string label) => Plist(Dict(
- ("CFBundleName", label),
- ("CFBundleIdentifier", "com.filee.app.convert-service"),
- ("NSServices", Array(Dict(
- ("NSMenuItem", Dict(("default", label))),
- ("NSMessage", "runWorkflowAsService"),
- ("NSRequiredContext", Dict(("NSApplicationIdentifier", "com.apple.finder"))),
- ("NSSendFileTypes", Array("public.item")))))));
-
- internal static string Workflow(string executablePath) => Plist(Dict(
- ("AMDocumentVersion", "2"),
- ("actions", Array(Dict(("action", Dict(
- ("AMAccepts", Dict(("Container", "List"), ("Optional", false), ("Types", Array("com.apple.cocoa.path")))),
- ("AMProvides", Dict(("Container", "List"), ("Types", Array("com.apple.cocoa.string")))),
- ("AMApplication", Array("Automator")),
- ("AMActionVersion", "2.0.3"),
- ("AMParameterProperties", Dict(("COMMAND_STRING", Dict()), ("inputMethod", Dict()), ("shell", Dict()), ("source", Dict()))),
- ("ActionBundlePath", "/System/Library/Automator/Run Shell Script.action"),
- ("ActionName", "Run Shell Script"),
- ("ActionParameters", Dict(
- ("COMMAND_STRING", ConversionCommand(executablePath)),
- ("CheckedForUserDefaultShell", true),
- ("inputMethod", 1),
- ("shell", "/bin/sh"),
- ("source", ""))),
- ("BundleIdentifier", "com.apple.RunShellScript"),
- ("Class Name", "RunShellScriptAction"),
- ("CanShowWhenRun", false),
- ("InputUUID", "E79B8709-D276-4DB2-A0D9-07FDF599774E"),
- ("OutputUUID", "09A1D308-CB6C-4EC8-9545-68272C47BA52"),
- ("UUID", "ED186538-5D03-4A64-88BD-52FC9A905E4F"),
- ("arguments", Dict())))))),
- ("connectors", Dict()),
- ("workflowMetaData", Dict(
- ("serviceApplicationBundleID", "com.apple.finder"),
- ("serviceApplicationPath", "/System/Library/CoreServices/Finder.app"),
- ("serviceInputTypeIdentifier", "com.apple.Automator.fileSystemObject"),
- ("serviceOutputTypeIdentifier", "com.apple.Automator.nothing"),
- ("serviceProcessesInput", true),
- ("workflowTypeIdentifier", "com.apple.Automator.servicesMenu")))));
-
- internal static string ConversionCommand(string executablePath)
+ ///
+ /// The service's entry in the pbs NSServicesStatus preferences: "bundle id - menu title - message", quoted for
+ /// defaults, which reads the key as a property list string.
+ ///
+ internal static string ServiceStatusKey =>
+ $"\"com.filee.app - {MacOSServicesProvider.MenuItem} - {MacOSServicesProvider.Message}\"";
+
+ /// On or off in the right-click menu and the Services menu, as System Settings writes it.
+ internal static string ServiceStatus(bool enabled)
{
- const string contents = "/Contents/MacOS/";
- var index = executablePath.LastIndexOf(contents, StringComparison.Ordinal);
- if (index > 0 && executablePath[..index].EndsWith(".app", StringComparison.OrdinalIgnoreCase))
- {
- // A fresh process forwards arguments to Filee's running instance; open otherwise discards them.
- return $"exec /usr/bin/open -n -a {ShellQuote(executablePath[..index])} --args --convert \"$@\"";
- }
- return $"exec {ShellQuote(executablePath)} --convert \"$@\"";
+ var on = enabled ? 1 : 0;
+ return $"{{ \"enabled_context_menu\" = {on}; \"enabled_services_menu\" = {on}; " +
+ $"\"presentation_modes\" = {{ \"ContextMenu\" = {on}; \"ServicesMenu\" = {on}; }}; }}";
}
- private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'";
-
private static string Plist(XElement dictionary) =>
"\n" +
new XDocument(
diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
index 6ed44d3..4b838c5 100644
--- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
+++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
@@ -69,30 +69,25 @@ public void SetStartWithSystem(bool enabled, string executablePath)
File.WriteAllText(path, MacOSIntegrationFiles.LaunchAgent(executablePath), new UTF8Encoding(false));
}
+ ///
+ /// Shows or hides Filee's own service in Finder's right-click menu (the same switch as System Settings >
+ /// Keyboard > Keyboard Shortcuts > Services), and removes the Automator Quick Action earlier builds installed.
+ ///
public void SetContextMenu(bool enabled, string executablePath, string label)
{
- var workflow = Path.Combine(UserLibrary, "Services", "Filee.workflow");
- var contents = Path.Combine(workflow, "Contents");
- if (enabled)
- {
- RequireExecutable(executablePath);
- Directory.CreateDirectory(contents);
- File.WriteAllText(Path.Combine(contents, "Info.plist"), MacOSIntegrationFiles.ServiceInfo(label), new UTF8Encoding(false));
- File.WriteAllText(Path.Combine(contents, "document.wflow"), MacOSIntegrationFiles.Workflow(executablePath), new UTF8Encoding(false));
- }
- else if (Directory.Exists(contents))
- {
- File.Delete(Path.Combine(contents, "Info.plist"));
- File.Delete(Path.Combine(contents, "document.wflow"));
- if (!Directory.EnumerateFileSystemEntries(contents).Any())
- Directory.Delete(contents);
- if (!Directory.EnumerateFileSystemEntries(workflow).Any())
- Directory.Delete(workflow);
- }
-
+ RemoveLegacyWorkflow();
+ Run("/usr/bin/defaults", ["write", "pbs", "NSServicesStatus", "-dict-add",
+ MacOSIntegrationFiles.ServiceStatusKey, MacOSIntegrationFiles.ServiceStatus(enabled)], 10_000);
Run("/System/Library/CoreServices/pbs", ["-update"], 10_000);
}
+ private static void RemoveLegacyWorkflow()
+ {
+ var workflow = Path.Combine(UserLibrary, "Services", "Filee.workflow");
+ if (Directory.Exists(workflow))
+ Directory.Delete(workflow, recursive: true);
+ }
+
public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported;
public Task SetModernContextMenuAsync(bool enabled, string executablePath) =>
diff --git a/src/Filee.Platform.MacOS/MacOSServicesProvider.cs b/src/Filee.Platform.MacOS/MacOSServicesProvider.cs
new file mode 100644
index 0000000..36fa706
--- /dev/null
+++ b/src/Filee.Platform.MacOS/MacOSServicesProvider.cs
@@ -0,0 +1,122 @@
+// "Convert with Filee" in Finder's right-click menu, as a service Filee.app provides itself (NSServices in its
+// Info.plist). macOS hands the selected files straight to the running Filee: no Automator workflow, no shell, no
+// second process. Info.plist names the message (convertFiles) and the port (Filee); this registers the object
+// that answers it, an NSObject subclass made at run time with one method, convertFiles:userData:error:.
+
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace Filee.Platform.MacOS;
+
+[SupportedOSPlatform("macos")]
+public static class MacOSServicesProvider
+{
+ /// The service's menu title in Info.plist; ServicesMenu.strings in each .lproj translates it.
+ public const string MenuItem = "Convert with Filee";
+
+ /// The selector macOS sends, without its argument labels (NSMessage in Info.plist).
+ public const string Message = "convertFiles";
+
+ private const string ObjC = "/usr/lib/libobjc.dylib";
+ private const string AppKit = "/System/Library/Frameworks/AppKit.framework/AppKit";
+
+ private delegate void ServiceMethod(nint self, nint selector, nint pasteboard, nint userData, nint error);
+
+ // Kept alive for as long as Objective-C may call it.
+ private static readonly ServiceMethod Method = ConvertFiles;
+ private static Action>? _handler;
+ private static nint _provider;
+
+ ///
+ /// Answers the service from now on; gets the files (on the main thread). Call it on the
+ /// main thread early at start: a service picked while Filee wasn't running is delivered once this is set.
+ ///
+ public static void Register(Action> handler)
+ {
+ _handler = handler;
+ if (_provider != 0)
+ return;
+ NativeLibrary.Load(AppKit);
+ var type = objc_getClass("FileeServicesProvider");
+ if (type == 0)
+ {
+ type = objc_allocateClassPair(objc_getClass("NSObject"), "FileeServicesProvider", 0);
+ class_addMethod(type, sel_registerName(Message + ":userData:error:"), Marshal.GetFunctionPointerForDelegate(Method), "v@:@@^@");
+ objc_registerClassPair(type);
+ }
+ _provider = Send(Send(type, sel_registerName("alloc")), sel_registerName("init"));
+ var application = Send(objc_getClass("NSApplication"), sel_registerName("sharedApplication"));
+ SendVoid(application, sel_registerName("setServicesProvider:"), _provider);
+ NSUpdateDynamicServices();
+ }
+
+ private static void ConvertFiles(nint self, nint selector, nint pasteboard, nint userData, nint error)
+ {
+ try
+ {
+ var files = FilesOn(pasteboard);
+ if (files.Count > 0)
+ _handler?.Invoke(files);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // An exception must not unwind into Objective-C; the menu item simply does nothing then.
+ }
+ }
+
+ /// The file paths on a pasteboard (Finder puts file URLs there).
+ private static List FilesOn(nint pasteboard)
+ {
+ var files = new List();
+ var classes = Send(objc_getClass("NSArray"), sel_registerName("arrayWithObject:"), objc_getClass("NSURL"));
+ var urls = Send(pasteboard, sel_registerName("readObjectsForClasses:options:"), classes, 0);
+ if (urls == 0)
+ return files;
+ var count = Send(urls, sel_registerName("count"));
+ for (nint index = 0; index < count; index++)
+ {
+ var url = Send(urls, sel_registerName("objectAtIndex:"), index);
+ if (!SendBool(url, sel_registerName("isFileURL")))
+ continue;
+ var path = Marshal.PtrToStringUTF8(Send(Send(url, sel_registerName("path")), sel_registerName("UTF8String")));
+ if (!string.IsNullOrEmpty(path))
+ files.Add(path);
+ }
+ return files;
+ }
+
+ [DllImport(ObjC)]
+ private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
+
+ [DllImport(ObjC)]
+ private static extern nint objc_allocateClassPair(nint superclass, [MarshalAs(UnmanagedType.LPUTF8Str)] string name, nint extraBytes);
+
+ [DllImport(ObjC)]
+ private static extern void objc_registerClassPair(nint type);
+
+ [DllImport(ObjC)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool class_addMethod(nint type, nint selector, nint implementation, [MarshalAs(UnmanagedType.LPUTF8Str)] string types);
+
+ [DllImport(ObjC)]
+ private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern nint Send(nint receiver, nint selector);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern nint Send(nint receiver, nint selector, nint argument);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern nint Send(nint receiver, nint selector, nint first, nint second);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern void SendVoid(nint receiver, nint selector, nint argument);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private static extern bool SendBool(nint receiver, nint selector);
+
+ [DllImport(AppKit)]
+ private static extern void NSUpdateDynamicServices();
+}
diff --git a/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs b/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
index 00df686..693000d 100644
--- a/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
+++ b/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
@@ -17,38 +17,35 @@ public void LaunchAgentKeepsExecutableAndArgumentsSeparate()
}
[Fact]
- public void ServiceMenuEscapesLocalizedLabelAndOnlyAcceptsFinderFiles()
+ public void ServiceSwitchWritesTheEntrySystemSettingsUses()
{
- const string label = "Filee로 변환 <빠르게> & 'convert'";
- var root = ReadPlist(MacOSIntegrationFiles.ServiceInfo(label));
- var service = Property(root, "NSServices").Element("dict")!;
- Assert.Equal(label, Property(Property(service, "NSMenuItem"), "default").Value);
- Assert.Equal(label, Property(root, "CFBundleName").Value);
- Assert.Equal("com.apple.finder", Property(Property(service, "NSRequiredContext"), "NSApplicationIdentifier").Value);
- Assert.Equal("public.item", Property(service, "NSSendFileTypes").Element("string")!.Value);
+ Assert.Equal("\"com.filee.app - Convert with Filee - convertFiles\"", MacOSIntegrationFiles.ServiceStatusKey);
+ Assert.Contains("\"enabled_context_menu\" = 0;", MacOSIntegrationFiles.ServiceStatus(false));
+ Assert.Contains("\"ContextMenu\" = 1;", MacOSIntegrationFiles.ServiceStatus(true));
}
[Fact]
- public void QuickActionLaunchesFreshBundleInstanceToForwardArguments()
+ public void AppBundleDeclaresTheServiceFileeAnswers()
{
- const string executable = "/Applications/O'Brien & Friends/Filee.app/Contents/MacOS/Filee";
- var workflow = ReadPlist(MacOSIntegrationFiles.Workflow(executable));
- var action = Property(Property(workflow, "actions").Element("dict")!, "action");
- var parameters = Property(action, "ActionParameters");
- Assert.Equal("1", Property(parameters, "inputMethod").Value);
- Assert.Equal("/bin/sh", Property(parameters, "shell").Value);
- Assert.Equal("exec /usr/bin/open -n -a '/Applications/O'\"'\"'Brien & Friends/Filee.app' --args --convert \"$@\"",
- Property(parameters, "COMMAND_STRING").Value);
- var metadata = Property(workflow, "workflowMetaData");
- Assert.Equal("com.apple.Automator.fileSystemObject", Property(metadata, "serviceInputTypeIdentifier").Value);
- Assert.Equal("com.apple.finder", Property(metadata, "serviceApplicationBundleID").Value);
+ var installer = Path.Combine(RepositoryRoot(), "installer", "macos");
+ var root = ReadPlist(File.ReadAllText(Path.Combine(installer, "Info.plist")));
+ var service = Property(root, "NSServices").Element("dict")!;
+ Assert.Equal(MacOSServicesProvider.MenuItem, Property(Property(service, "NSMenuItem"), "default").Value);
+ Assert.Equal(MacOSServicesProvider.Message, Property(service, "NSMessage").Value);
+ Assert.Equal("Filee", Property(service, "NSPortName").Value);
+ Assert.Equal("public.item", Property(service, "NSSendFileTypes").Element("string")!.Value);
+ // Each translation names the same menu title.
+ foreach (var strings in Directory.GetFiles(installer, "ServicesMenu.strings", SearchOption.AllDirectories))
+ Assert.StartsWith($"\"{MacOSServicesProvider.MenuItem}\" = ", File.ReadAllLines(strings).Single(l => l.StartsWith('"')));
+ Assert.Equal(3, Directory.GetFiles(installer, "ServicesMenu.strings", SearchOption.AllDirectories).Length);
}
- [Fact]
- public void RawExecutableIsQuotedWithoutEvaluatingShellMetacharacters()
+ private static string RepositoryRoot()
{
- Assert.Equal("exec '/tmp/$(touch nope) `echo bad` \"quote\"' --convert \"$@\"",
- MacOSIntegrationFiles.ConversionCommand("/tmp/$(touch nope) `echo bad` \"quote\""));
+ var folder = new DirectoryInfo(AppContext.BaseDirectory);
+ while (folder is not null && !File.Exists(Path.Combine(folder.FullName, "Filee.slnx")))
+ folder = folder.Parent;
+ return folder?.FullName ?? throw new DirectoryNotFoundException("Filee.slnx not found above the test output.");
}
[Fact]
From 91a3115b81fc85dfb84fd4578164e4f737a7ee23 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 06:00:32 +0900
Subject: [PATCH 12/16] Watch folders on macOS and Linux wait for a paused copy
to finish
Windows tells a file still being written by refusing exclusive access; macOS and Linux don't lock files, so a
copy that paused for two seconds was converted half-written (and again when finished). There files must stay
unchanged for five seconds, and on macOS a file with Finder's copy-in-progress creation date (24 January 1984)
waits too.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.Core/Watching/FolderWatcher.cs | 30 ++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/src/Filee.Core/Watching/FolderWatcher.cs b/src/Filee.Core/Watching/FolderWatcher.cs
index 6d6c420..2ef23c4 100644
--- a/src/Filee.Core/Watching/FolderWatcher.cs
+++ b/src/Filee.Core/Watching/FolderWatcher.cs
@@ -1,6 +1,7 @@
// Watches one folder (WatchRule) and converts files that arrive in it. Used by the tray app (Settings → Watch folders)
// and by "filee watch". A file is converted only once it is complete: it must keep the same size and time for
-// SettleTime and open without sharing, so downloads and copies still in progress are left alone. Temporary files,
+// SettleTime and open without sharing (on Windows; macOS and Linux don't lock files being written, so there only
+// the longer wait and Finder's copy-in-progress date count), so downloads and copies still in progress are left alone. Temporary files,
// the output folder and the originals folder are never picked up, so converted files can't loop back in.
using System.Collections.Concurrent;
@@ -18,8 +19,15 @@ public sealed class FolderWatcher : IAsyncDisposable
/// Converts a batch of complete files and returns the finished job (null when it could not start).
public delegate Task ConvertFiles(IReadOnlyList files, CancellationToken cancellationToken);
- /// Default time a file must stay unchanged before it is converted.
- public static readonly TimeSpan DefaultSettleTime = TimeSpan.FromSeconds(2);
+ ///
+ /// Default time a file must stay unchanged before it is converted. Longer on macOS and Linux: there a file
+ /// still being written can be opened exclusively all the same, so only the wait tells a paused copy from a
+ /// finished one.
+ ///
+ public static readonly TimeSpan DefaultSettleTime = TimeSpan.FromSeconds(OperatingSystem.IsWindows() ? 2 : 5);
+
+ /// The creation date Finder gives a file while it is still copying it (24 January 1984).
+ private static readonly DateTime FinderCopyInProgress = new(1984, 1, 24);
private static readonly HashSet TemporaryExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -134,7 +142,7 @@ public async Task ProcessPendingAsync()
continue;
}
_pending[path] = current;
- if (now - current.StableSince < _settleTime || !CanOpenExclusively(path))
+ if (now - current.StableSince < _settleTime || !CanOpenExclusively(path) || IsBeingCopiedByFinder(path))
continue;
_pending.TryRemove(path, out _);
// A file that was converted before (same size and time) is not converted again.
@@ -225,6 +233,20 @@ private void MoveToOriginals(string source)
}
/// A writer still holding the file (download, copy) makes an exclusive open fail.
+ private static bool IsBeingCopiedByFinder(string path)
+ {
+ if (!OperatingSystem.IsMacOS())
+ return false;
+ try
+ {
+ return File.GetCreationTime(path).Date == FinderCopyInProgress;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return true;
+ }
+ }
+
private static bool CanOpenExclusively(string path)
{
try
From 36681bef1fd14a007d7d063ab17ea320d01c91e5 Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 07:25:47 +0900
Subject: [PATCH 13/16] Updates on the About page, no engine pre-ticked, search
above the lists, excluded apps as tags
- About shows the automatic update check and "Check now" (and the update button when one is out); General no
longer does.
- The first-run engine choice starts with nothing ticked.
- Search boxes above the profiles, the available presets (Donut toolbar) and the preset list filter as you type;
a profile is found by its name or by an extension it handles.
- "Never trigger in these apps" holds tags: type a name (running apps are suggested) or pick from the apps that
have a window open now. Windows lists processes with a main window, macOS the app windows on screen, Linux
(X11) the window manager's client list.
Co-Authored-By: Claude Opus 5.5
---
src/Filee.App/Assets/i18n/en.json | 7 +-
src/Filee.App/Assets/i18n/ko.json | 7 +-
src/Filee.App/Assets/i18n/zh-CN.json | 7 +-
src/Filee.App/Styles/Icons.axaml | 1 +
.../ViewModels/EngineSetupViewModel.cs | 3 +-
src/Filee.App/ViewModels/FilteredList.cs | 61 ++++++++
.../ViewModels/Pages/AboutPageViewModel.cs | 61 +++++++-
.../ViewModels/Pages/ExcludedAppsViewModel.cs | 129 +++++++++++++++++
.../ViewModels/Pages/GeneralPageViewModel.cs | 44 +-----
.../ViewModels/Pages/PresetsPageViewModel.cs | 8 ++
.../ViewModels/Pages/ToolbarPageViewModel.cs | 13 ++
.../ViewModels/Pages/TriggersPageViewModel.cs | 13 +-
src/Filee.App/Views/Pages/AboutPage.axaml | 16 +++
src/Filee.App/Views/Pages/GeneralPage.axaml | 16 ---
src/Filee.App/Views/Pages/PresetsPage.axaml | 7 +-
src/Filee.App/Views/Pages/ToolbarPage.axaml | 14 +-
src/Filee.App/Views/Pages/TriggersPage.axaml | 38 ++++-
src/Filee.Core/Platform/IPlatformServices.cs | 16 +++
.../LinuxPlatformServices.cs | 2 +
src/Filee.Platform.Linux/X11WindowSystem.cs | 39 ++++++
.../MacOSPlatformServices.cs | 2 +
src/Filee.Platform.MacOS/NativeMethods.cs | 36 +++++
tests/Filee.App.Tests/ListSearchTests.cs | 131 ++++++++++++++++++
tests/Filee.App.Tests/RenderTests.cs | 42 ++++--
24 files changed, 627 insertions(+), 86 deletions(-)
create mode 100644 src/Filee.App/ViewModels/FilteredList.cs
create mode 100644 src/Filee.App/ViewModels/Pages/ExcludedAppsViewModel.cs
create mode 100644 tests/Filee.App.Tests/ListSearchTests.cs
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index a6eed63..18e834f 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -215,7 +215,10 @@
"triggers.scope.FileManager_linux": "File manager and desktop",
"triggers.scope.Anywhere": "Everywhere",
"triggers.excluded": "Never trigger in these apps",
- "triggers.excluded_hint": "Process names separated by commas, e.g. photoshop, code",
+ "triggers.excluded_hint": "Type an app name and press Enter, e.g. photoshop",
+ "triggers.excluded_pick": "Pick a running app",
+ "triggers.excluded_none_running": "No other app has a window open.",
+ "triggers.excluded_note": "Gestures and shortcuts do nothing while the pointer is over these apps.",
"triggers.paused": "Shortcuts are paused",
"toolbar.title": "Donut toolbar",
@@ -423,6 +426,8 @@
"engines.state.failed": "Failed: {0}",
"about.title": "About",
+ "about.updates": "Updates",
+ "common.search": "Search",
"about.version": "Version {0}",
"about.license": "Filee is free software under the MIT license.",
"about.notices": "Third-party notices",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 9ede875..54ba5f1 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -215,7 +215,10 @@
"triggers.scope.FileManager_linux": "파일 관리자와 바탕화면",
"triggers.scope.Anywhere": "모든 곳",
"triggers.excluded": "이 앱에서는 동작하지 않기",
- "triggers.excluded_hint": "프로세스 이름을 쉼표로 구분, 예: photoshop, code",
+ "triggers.excluded_hint": "앱 이름을 입력하고 Enter, 예: photoshop",
+ "triggers.excluded_pick": "실행 중인 앱에서 선택",
+ "triggers.excluded_none_running": "창이 열린 다른 앱이 없어요.",
+ "triggers.excluded_note": "마우스가 이 앱 위에 있으면 제스처와 단축키가 동작하지 않아요.",
"triggers.paused": "단축키가 일시 중지되었어요",
"toolbar.title": "원형 툴바",
@@ -423,6 +426,8 @@
"engines.state.failed": "실패: {0}",
"about.title": "정보",
+ "about.updates": "업데이트",
+ "common.search": "검색",
"about.version": "버전 {0}",
"about.license": "Filee는 MIT 라이선스의 무료 소프트웨어예요.",
"about.notices": "서드파티 고지",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 8172a87..eb3125f 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -215,7 +215,10 @@
"triggers.scope.FileManager_linux": "文件管理器和桌面",
"triggers.scope.Anywhere": "所有位置",
"triggers.excluded": "在这些应用中不触发",
- "triggers.excluded_hint": "进程名,用逗号分隔,例如 photoshop, code",
+ "triggers.excluded_hint": "输入应用名称后按 Enter,例如 photoshop",
+ "triggers.excluded_pick": "从正在运行的应用中选择",
+ "triggers.excluded_none_running": "没有其他打开窗口的应用。",
+ "triggers.excluded_note": "指针位于这些应用上时,手势和快捷键不起作用。",
"triggers.paused": "快捷操作已暂停",
"toolbar.title": "环形工具栏",
@@ -423,6 +426,8 @@
"engines.state.failed": "失败:{0}",
"about.title": "关于",
+ "about.updates": "更新",
+ "common.search": "搜索",
"about.version": "版本 {0}",
"about.license": "Filee 是基于 MIT 许可证的免费软件。",
"about.notices": "第三方声明",
diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml
index c0ef6b5..a7429d0 100644
--- a/src/Filee.App/Styles/Icons.axaml
+++ b/src/Filee.App/Styles/Icons.axaml
@@ -13,6 +13,7 @@
M17,17H7V7H17M21,11V9H19V7C19,5.89 18.1,5 17,5H15V3H13V5H11V3H9V5H7C5.89,5 5,5.89 5,7V9H3V11H5V13H3V15H5V17A2,2 0 0,0 7,19H9V21H11V19H13V21H15V19H17A2,2 0 0,0 19,17V15H21V13H19V11M13,13H11V11H13M15,9H9V15H15V9ZM13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2ZM10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z
+ M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5ZM19,13H13V19H11V13H5V11H11V5H13V11H19V13ZM19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19ZM19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z
diff --git a/src/Filee.App/ViewModels/EngineSetupViewModel.cs b/src/Filee.App/ViewModels/EngineSetupViewModel.cs
index bb8c9e6..1cd418f 100644
--- a/src/Filee.App/ViewModels/EngineSetupViewModel.cs
+++ b/src/Filee.App/ViewModels/EngineSetupViewModel.cs
@@ -18,9 +18,10 @@ public EngineSetupViewModel(EngineDownloadService downloads, ILocalizer loc)
{
_downloads = downloads;
_loc = loc;
+ // Nothing is ticked: the user picks what they need (every engine is a download of its own).
foreach (var package in Packages)
{
- package.Selected = package.CanInstall && Filee.Engines.Infrastructure.EngineDownloads.IsSuggested(package.Package);
+ package.Selected = false;
package.PropertyChanged += OnPackageChanged;
}
}
diff --git a/src/Filee.App/ViewModels/FilteredList.cs b/src/Filee.App/ViewModels/FilteredList.cs
new file mode 100644
index 0000000..0b45a1b
--- /dev/null
+++ b/src/Filee.App/ViewModels/FilteredList.cs
@@ -0,0 +1,61 @@
+// The items of a list that match the search box above it, kept in step with the list as it changes. Items are added
+// and removed one by one (not cleared and refilled), so a ListBox keeps its selection while it stays visible.
+
+using System.Collections.ObjectModel;
+
+namespace Filee.App.ViewModels;
+
+public sealed class FilteredList : ObservableCollection where T : class
+{
+ private readonly ObservableCollection _source;
+ private readonly Func> _texts;
+ private string _query = "";
+
+ /// The full list.
+ /// The texts of an item the query is looked for in (name, format, extensions …).
+ public FilteredList(ObservableCollection source, Func> texts)
+ {
+ _source = source;
+ _texts = texts;
+ source.CollectionChanged += (_, _) => Refresh();
+ Refresh();
+ }
+
+ /// What was typed; empty shows everything. Matches any part of a text, ignoring case.
+ public string Query
+ {
+ get => _query;
+ set
+ {
+ var query = value?.Trim() ?? "";
+ if (query == _query)
+ return;
+ _query = query;
+ Refresh();
+ }
+ }
+
+ public bool Matches(T item) =>
+ _query.Length == 0 || _texts(item).Any(text => text?.Contains(_query, StringComparison.CurrentCultureIgnoreCase) == true);
+
+ /// Brings the list in line with the source and the query (also after an item's text changed).
+ public void Refresh()
+ {
+ var wanted = _source.Where(Matches).ToList();
+ for (var i = Count - 1; i >= 0; i--)
+ {
+ if (!wanted.Contains(this[i]))
+ RemoveAt(i);
+ }
+ for (var i = 0; i < wanted.Count; i++)
+ {
+ if (i < Count && ReferenceEquals(this[i], wanted[i]))
+ continue;
+ var at = IndexOf(wanted[i]);
+ if (at >= 0)
+ Move(at, i);
+ else
+ Insert(i, wanted[i]);
+ }
+ }
+}
diff --git a/src/Filee.App/ViewModels/Pages/AboutPageViewModel.cs b/src/Filee.App/ViewModels/Pages/AboutPageViewModel.cs
index eb55e14..22a84c4 100644
--- a/src/Filee.App/ViewModels/Pages/AboutPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/AboutPageViewModel.cs
@@ -2,16 +2,73 @@
using Avalonia.Platform;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using Filee.App.Services;
using Filee.Core.Localization;
+using Filee.Core.Settings;
namespace Filee.App.ViewModels.Pages;
-public sealed partial class AboutPageViewModel(ILocalizer loc) : ObservableObject
+public sealed partial class AboutPageViewModel : ObservableObject, IDisposable
{
- public string Version { get; } = loc.Format("about.version", UpdateService.CurrentVersion);
+ private readonly ILocalizer _loc;
+ private readonly UserDataStore _store;
+ private readonly UpdateService _updates;
+
+ public AboutPageViewModel(ILocalizer loc, UserDataStore store, UpdateService updates)
+ {
+ _loc = loc;
+ _store = store;
+ _updates = updates;
+ Version = loc.Format("about.version", UpdateService.CurrentVersion);
+ _checkForUpdates = store.Settings.CheckForUpdates;
+ ShowUpdateState(updates.LatestVersion);
+ updates.PropertyChanged += OnUpdatesChanged;
+ }
+
+ public string Version { get; }
public string RepositoryUrl { get; } = UpdateService.RepositoryUrl;
+ /// The page is rebuilt on every visit; the update service lives on.
+ public void Dispose() => _updates.PropertyChanged -= OnUpdatesChanged;
+
+ // ── Updates: the automatic check and "Check now" ──
+
+ [ObservableProperty] private bool _checkForUpdates;
+ [ObservableProperty] private bool _updateAvailable;
+ [ObservableProperty] private string _updateStatus = "";
+
+ /// "Update" in an installed copy, "Download" in a portable one.
+ public string UpdateButtonText => _loc[UpdateTexts.ButtonKey];
+
+ partial void OnCheckForUpdatesChanged(bool value)
+ {
+ _store.Settings.CheckForUpdates = value;
+ _store.SaveSettings();
+ }
+
+ private void OnUpdatesChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (UpdateTexts.Affects(e.PropertyName))
+ ShowUpdateState(_updates.LatestVersion);
+ }
+
+ [RelayCommand]
+ private async Task CheckNow() => ShowUpdateState(await _updates.CheckAsync(), afterCheck: true);
+
+ /// Downloads and runs the new installer, which updates Filee in place (a portable copy opens the download page).
+ [RelayCommand]
+ private Task DownloadUpdate() => _updates.UpdateAsync();
+
+ private void ShowUpdateState(string? newer, bool afterCheck = false)
+ {
+ UpdateAvailable = newer is not null && !_updates.IsBusy;
+ UpdateStatus = UpdateTexts.Status(_updates, _loc) is { } progress ? progress
+ : newer is not null ? _loc.Format("general.update_available", newer)
+ : afterCheck ? _loc.Format("general.update_none", UpdateService.CurrentVersion)
+ : "";
+ }
+
public string Notices { get; } = ReadNotices();
private static string ReadNotices()
diff --git a/src/Filee.App/ViewModels/Pages/ExcludedAppsViewModel.cs b/src/Filee.App/ViewModels/Pages/ExcludedAppsViewModel.cs
new file mode 100644
index 0000000..bedb4f6
--- /dev/null
+++ b/src/Filee.App/ViewModels/Pages/ExcludedAppsViewModel.cs
@@ -0,0 +1,129 @@
+// "Never trigger in these apps" on the Shortcuts page: the apps as tags. A name is typed (the box suggests the apps
+// running now) or picked from the list of apps that have a window open; a tag's ✕ removes it. The names are the ones
+// TriggerService compares with the window under the cursor (process name, without ".exe").
+
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Filee.App.Controls;
+using Filee.Core.Platform;
+
+namespace Filee.App.ViewModels.Pages;
+
+public sealed partial class ExcludedAppsViewModel : ObservableObject
+{
+ private readonly List _excluded;
+ private readonly IPlatformServices? _platform;
+ private readonly Action _save;
+ private IReadOnlyList _running = [];
+ private bool _loaded;
+
+ /// The settings' list, changed in place.
+ /// Persists the settings after every change.
+ public ExcludedAppsViewModel(List excluded, IPlatformServices? platform, Action save)
+ {
+ _excluded = excluded;
+ _platform = platform;
+ _save = save;
+ VisibleRunningApps = new FilteredList(RunningApps, name => [name]);
+ Refresh();
+ }
+
+ [ObservableProperty] private IReadOnlyList _tags = [];
+ [ObservableProperty] private IReadOnlyList _suggestions = [];
+ [ObservableProperty] private string _text = "";
+
+ /// Apps with a window open now, not excluded yet (the "pick a running app" list).
+ public ObservableCollection RunningApps { get; } = [];
+
+ public FilteredList VisibleRunningApps { get; }
+
+ /// The list was read and has nothing to offer.
+ [ObservableProperty] private bool _noRunningApps;
+
+ [ObservableProperty] private string _runningSearch = "";
+
+ partial void OnRunningSearchChanged(string value) => VisibleRunningApps.Query = value;
+
+ partial void OnTextChanged(string value) => UpdateSuggestions();
+
+ /// Reads the running apps again (when the list opens, and when typing starts).
+ [RelayCommand]
+ public void LoadRunningApps()
+ {
+ _loaded = true;
+ try
+ {
+ _running = _platform?.RunningAppNames() ?? [];
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException)
+ {
+ _running = [];
+ }
+ RefreshRunning();
+ }
+
+ /// Adds a typed or picked app name.
+ [RelayCommand]
+ private void Add(string? name)
+ {
+ var app = Normalize(name);
+ if (app is null || _excluded.Contains(app, StringComparer.OrdinalIgnoreCase))
+ {
+ Text = "";
+ return;
+ }
+ _excluded.Add(app);
+ _save();
+ Text = "";
+ Refresh();
+ }
+
+ [RelayCommand]
+ private void Remove(TagChip? chip)
+ {
+ if (chip is null || _excluded.RemoveAll(n => n.Equals(chip.Value, StringComparison.OrdinalIgnoreCase)) == 0)
+ return;
+ _save();
+ Refresh();
+ }
+
+ /// "Photoshop.exe " → "Photoshop": the name TriggerService compares with.
+ internal static string? Normalize(string? name)
+ {
+ var app = name?.Trim();
+ if (string.IsNullOrEmpty(app))
+ return null;
+ if (app.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ app = app[..^4];
+ return app.Length == 0 ? null : app;
+ }
+
+ private void Refresh()
+ {
+ Tags = [.. _excluded.Select(name => new TagChip(name, name))];
+ RefreshRunning();
+ }
+
+ private void RefreshRunning()
+ {
+ var available = _running.Where(n => !_excluded.Contains(n, StringComparer.OrdinalIgnoreCase)).ToList();
+ if (!available.SequenceEqual(RunningApps))
+ {
+ RunningApps.Clear();
+ foreach (var name in available)
+ RunningApps.Add(name);
+ }
+ NoRunningApps = _loaded && RunningApps.Count == 0;
+ UpdateSuggestions();
+ }
+
+ private void UpdateSuggestions()
+ {
+ var typed = Text.Trim();
+ if (typed.Length > 0 && !_loaded)
+ LoadRunningApps(); // first keystroke: read the running apps once
+ Suggestions = typed.Length == 0 ? []
+ : [.. RunningApps.Where(n => n.Contains(typed, StringComparison.CurrentCultureIgnoreCase)).Take(12).Select(n => new TagSuggestion(n, n))];
+ }
+}
diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
index 60592c0..cd03b1f 100644
--- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
@@ -1,4 +1,4 @@
-// General settings: language, start-up, Explorer integration, updates, preset library import/export.
+// General settings: language, start-up, Explorer integration, preset library import/export. Updates are on the About page.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -10,21 +10,18 @@
namespace Filee.App.ViewModels.Pages;
-public sealed partial class GeneralPageViewModel : ObservableObject, IDisposable
+public sealed partial class GeneralPageViewModel : ObservableObject
{
private readonly UserDataStore _store;
private readonly ILocalizer _loc;
private readonly IPlatformServices _platform;
- private readonly UpdateService _updates;
private readonly SystemIntegrationService? _integration;
- public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, UpdateService updates,
- SystemIntegrationService? integration = null)
+ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, SystemIntegrationService? integration = null)
{
_store = store;
_loc = loc;
_platform = platform;
- _updates = updates;
_integration = integration;
Languages =
@@ -35,7 +32,6 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic
_language = Languages.FirstOrDefault(l => l.Value == store.Settings.Language) ?? Languages[0];
_startWithSystem = store.Settings.StartWithSystem;
_contextMenu = store.Settings.ContextMenuEnabled;
- _checkForUpdates = store.Settings.CheckForUpdates;
_noHistory = !store.Settings.KeepHistory;
OutputLocations = new[] { OutputLocation.SameFolder, OutputLocation.Subfolder, OutputLocation.CustomFolder }
.Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList();
@@ -45,23 +41,9 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic
_defaultFolder = output.CustomFolder;
_keepFileDates = store.Settings.KeepFileDates;
_historyCount = store.History.Count;
- ShowUpdateState(updates.LatestVersion);
- updates.PropertyChanged += OnUpdatesChanged;
RefreshModernMenu();
}
- /// The page is rebuilt on every visit; the update service lives on.
- public void Dispose() => _updates.PropertyChanged -= OnUpdatesChanged;
-
- private void OnUpdatesChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
- {
- if (UpdateTexts.Affects(e.PropertyName))
- ShowUpdateState(_updates.LatestVersion);
- }
-
- /// "Update" in an installed copy, "Download" in a portable one.
- public string UpdateButtonText => _loc[UpdateTexts.ButtonKey];
-
public IReadOnlyList> Languages { get; }
public string DataFolder => _store.Directory;
public bool IsWindows => OperatingSystem.IsWindows();
@@ -97,9 +79,6 @@ partial void OnDefaultSubfolderChanged(string value) =>
[ObservableProperty] private Choice _language;
[ObservableProperty] private bool _startWithSystem;
[ObservableProperty] private bool _contextMenu;
- [ObservableProperty] private bool _checkForUpdates;
- [ObservableProperty] private string? _updateStatus;
- [ObservableProperty] private bool _updateAvailable;
[ObservableProperty] private string? _libraryMessage;
// Windows 11 top-level Explorer menu entry (see ExplorerMenuRegistration). Only shown when this build ships it.
@@ -169,7 +148,6 @@ private void RefreshModernMenu()
_ => "general.modern_menu_hint",
}];
}
- partial void OnCheckForUpdatesChanged(bool value) => Save(s => s.CheckForUpdates = value);
// Conversion history (home page): don't record it, or clear what is there.
[ObservableProperty] private bool _noHistory;
@@ -207,22 +185,6 @@ private void Save(Action change)
[RelayCommand]
private void OpenDataFolder() => _platform.RevealInFileManager(_store.Directory);
- [RelayCommand]
- private async Task CheckNow() => ShowUpdateState(await _updates.CheckAsync(), afterCheck: true);
-
- /// Downloads and runs the new installer, which updates Filee in place (a portable copy opens the download page).
- [RelayCommand]
- private Task DownloadUpdate() => _updates.UpdateAsync();
-
- private void ShowUpdateState(string? newer, bool afterCheck = false)
- {
- UpdateAvailable = newer is not null && !_updates.IsBusy;
- UpdateStatus = UpdateTexts.Status(_updates, _loc) is { } progress ? progress
- : newer is not null ? _loc.Format("general.update_available", newer)
- : afterCheck ? _loc.Format("general.update_none", UpdateService.CurrentVersion)
- : _loc.Format("about.version", UpdateService.CurrentVersion);
- }
-
public void Export(string path)
{
_store.Export(path);
diff --git a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs
index b786b7d..a0eb939 100644
--- a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs
@@ -42,6 +42,7 @@ public PresetsPageViewModel(UserDataStore store, ILocalizer loc, WindowService w
_store = store;
_loc = loc;
_windows = windows;
+ VisiblePresets = new FilteredList(Presets, p => [p.Name, p.Target, p.Preset.TargetFormat]);
foreach (var preset in store.Presets)
Presets.Add(new PresetListItem(preset, loc));
SelectedPreset = Presets.FirstOrDefault();
@@ -49,6 +50,13 @@ public PresetsPageViewModel(UserDataStore store, ILocalizer loc, WindowService w
public ObservableCollection Presets { get; } = [];
+ /// The presets the search box above the list lets through (name or target format).
+ public FilteredList VisiblePresets { get; }
+
+ [ObservableProperty] private string _search = "";
+
+ partial void OnSearchChanged(string value) => VisiblePresets.Query = value;
+
[ObservableProperty] private PresetListItem? _selectedPreset;
[ObservableProperty] private PresetEditorViewModel? _editor;
[ObservableProperty] private string? _savedMessage;
diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
index d8263e3..3b651bd 100644
--- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
@@ -51,6 +51,9 @@ public ToolbarPageViewModel(UserDataStore store, ILocalizer loc, WindowService w
_store = store;
_loc = loc;
_windows = windows;
+ // A profile is found by its name or by an extension it handles ("png" finds the photo donut).
+ VisibleProfiles = new FilteredList(Profiles, p => [p.Name, .. p.Profile.Extensions]);
+ VisiblePalette = new FilteredList(Palette, c => [c.Label, c.Caption]);
foreach (var profile in store.Profiles)
Profiles.Add(new ProfileItemViewModel(profile, loc));
@@ -66,6 +69,16 @@ public ToolbarPageViewModel(UserDataStore store, ILocalizer loc, WindowService w
public ObservableCollection Profiles { get; } = [];
public ObservableCollection Palette { get; } = [];
+ /// The profiles and the presets the search boxes above the two lists let through.
+ public FilteredList VisibleProfiles { get; }
+ public FilteredList VisiblePalette { get; }
+
+ [ObservableProperty] private string _profileSearch = "";
+ [ObservableProperty] private string _paletteSearch = "";
+
+ partial void OnProfileSearchChanged(string value) => VisibleProfiles.Query = value;
+ partial void OnPaletteSearchChanged(string value) => VisiblePalette.Query = value;
+
[ObservableProperty] private ProfileItemViewModel? _selectedProfile;
[ObservableProperty] private IReadOnlyList _donutItems = [];
[ObservableProperty] private string _centerTitle = "";
diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
index 5408ae9..cf1c8bd 100644
--- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
@@ -157,7 +157,7 @@ public TriggersPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServi
triggers.StatusChanged += OnTriggerStatusChanged;
foreach (var gesture in store.Settings.Triggers)
Gestures.Add(new TriggerGestureViewModel(gesture, loc, Save));
- _excluded = string.Join(", ", store.Settings.ExcludedProcesses);
+ ExcludedApps = new ExcludedAppsViewModel(store.Settings.ExcludedProcesses, platform, Save);
}
public ObservableCollection Gestures { get; } = [];
@@ -209,15 +209,8 @@ public void Dispose()
_triggers.StatusChanged -= OnTriggerStatusChanged;
}
- [ObservableProperty] private string _excluded;
-
- partial void OnExcludedChanged(string value)
- {
- _store.Settings.ExcludedProcesses = value
- .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .ToList();
- Save();
- }
+ /// "Never trigger in these apps" as tags.
+ public ExcludedAppsViewModel ExcludedApps { get; }
[RelayCommand]
private void Add()
diff --git a/src/Filee.App/Views/Pages/AboutPage.axaml b/src/Filee.App/Views/Pages/AboutPage.axaml
index e8a3641..a0d2101 100644
--- a/src/Filee.App/Views/Pages/AboutPage.axaml
+++ b/src/Filee.App/Views/Pages/AboutPage.axaml
@@ -22,6 +22,22 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/Pages/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml
index 6317b9a..1ab3a96 100644
--- a/src/Filee.App/Views/Pages/GeneralPage.axaml
+++ b/src/Filee.App/Views/Pages/GeneralPage.axaml
@@ -49,22 +49,6 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Filee.App/Views/Pages/PresetsPage.axaml b/src/Filee.App/Views/Pages/PresetsPage.axaml
index 3903e6a..3da5b6d 100644
--- a/src/Filee.App/Views/Pages/PresetsPage.axaml
+++ b/src/Filee.App/Views/Pages/PresetsPage.axaml
@@ -23,7 +23,12 @@
-
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml
index 51ec06c..bb5c001 100644
--- a/src/Filee.App/Views/Pages/ToolbarPage.axaml
+++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml
@@ -106,7 +106,12 @@
-
+
+
+
+
+
+
@@ -265,9 +270,14 @@
+
+
+
+
+
-
+
diff --git a/src/Filee.App/Views/Pages/TriggersPage.axaml b/src/Filee.App/Views/Pages/TriggersPage.axaml
index 70001b8..dbe420e 100644
--- a/src/Filee.App/Views/Pages/TriggersPage.axaml
+++ b/src/Filee.App/Views/Pages/TriggersPage.axaml
@@ -1,6 +1,7 @@
@@ -119,9 +120,42 @@
-
+
-
+
+
+
+
diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs
index 21bd05f..7a440a4 100644
--- a/src/Filee.Core/Platform/IPlatformServices.cs
+++ b/src/Filee.Core/Platform/IPlatformServices.cs
@@ -12,6 +12,22 @@ public interface IPlatformServices
/// Process name (without extension) owning the window under the point, or null.
string? ProcessNameAt(int x, int y);
+ ///
+ /// Names of the apps that have a window open now, as reports them, sorted, without
+ /// Filee. Windows reports a main window per process; macOS and Linux list the windows on screen.
+ ///
+ IReadOnlyList RunningAppNames() => AppNames(System.Diagnostics.Process.GetProcesses()
+ .Where(p => { try { return p.MainWindowHandle != 0; } catch (InvalidOperationException) { return false; } })
+ .Select(p => { using (p) return p.ProcessName; }));
+
+ /// Distinct, sorted, Filee itself left out.
+ static IReadOnlyList AppNames(IEnumerable names)
+ {
+ var own = System.Diagnostics.Process.GetCurrentProcess().ProcessName;
+ return [.. names.OfType().Where(n => n.Length > 0 && !n.Equals(own, StringComparison.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.CurrentCultureIgnoreCase)];
+ }
+
/// Files currently selected in the foreground file manager window (empty if none).
IReadOnlyList GetFileManagerSelection();
diff --git a/src/Filee.Platform.Linux/LinuxPlatformServices.cs b/src/Filee.Platform.Linux/LinuxPlatformServices.cs
index 7ee2d01..4c62ffb 100644
--- a/src/Filee.Platform.Linux/LinuxPlatformServices.cs
+++ b/src/Filee.Platform.Linux/LinuxPlatformServices.cs
@@ -33,6 +33,8 @@ public sealed class LinuxPlatformServices : IPlatformServices
public bool IsFileManagerAt(int x, int y) => ProcessNameAt(x, y) is { } name && FileManagers.Contains(name);
public string? ProcessNameAt(int x, int y) => X11WindowSystem.ProcessNameAt(x, y);
+
+ public IReadOnlyList RunningAppNames() => IPlatformServices.AppNames(X11WindowSystem.ClientProcessNames());
public IReadOnlyList GetFileManagerSelection() => throw new NotSupportedException(SelectionShortcutUnavailableReason);
public int SystemDragThreshold => 8;
diff --git a/src/Filee.Platform.Linux/X11WindowSystem.cs b/src/Filee.Platform.Linux/X11WindowSystem.cs
index 96cfaac..4d97c51 100644
--- a/src/Filee.Platform.Linux/X11WindowSystem.cs
+++ b/src/Filee.Platform.Linux/X11WindowSystem.cs
@@ -43,6 +43,45 @@ internal static class X11WindowSystem
}
}
+ /// Process names of the windows the window manager lists (_NET_CLIENT_LIST).
+ internal static IReadOnlyList ClientProcessNames()
+ {
+ if (!IsAvailable)
+ return [];
+ var display = OpenDisplay();
+ if (display == 0)
+ return [];
+ try
+ {
+ var list = XInternAtom(display, "_NET_CLIENT_LIST", 1);
+ if (list == 0 || XGetWindowProperty(display, XDefaultRootWindow(display), list, 0, 4096, 0, 0,
+ out _, out var format, out var count, out _, out var data) != 0)
+ return [];
+ try
+ {
+ if (data == 0 || format != 32 || count == 0)
+ return [];
+ var names = new List();
+ for (var i = 0; i < (int)count; i++)
+ {
+ // Format-32 properties come back as C longs, one per window.
+ var window = (nuint)(ulong)Marshal.ReadIntPtr(data, i * IntPtr.Size);
+ if (ReadProcessName(display, window) is { } name)
+ names.Add(name);
+ }
+ return names;
+ }
+ finally
+ {
+ XFree(data);
+ }
+ }
+ finally
+ {
+ XCloseDisplay(display);
+ }
+ }
+
internal static void RaiseTopmost(nint window)
{
if (window == 0 || !IsAvailable)
diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
index 4b838c5..894ea02 100644
--- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
+++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
@@ -31,6 +31,8 @@ public bool IsFileManagerAt(int x, int y) =>
public string? ProcessNameAt(int x, int y) => NativeMethods.ProcessNameAt(x, y);
+ public IReadOnlyList RunningAppNames() => IPlatformServices.AppNames(NativeMethods.WindowOwnerNames().ToList());
+
public IReadOnlyList GetFileManagerSelection()
{
LastSelectionError = null;
diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs
index 3a7af6c..79ce688 100644
--- a/src/Filee.Platform.MacOS/NativeMethods.cs
+++ b/src/Filee.Platform.MacOS/NativeMethods.cs
@@ -17,6 +17,42 @@ internal static class NativeMethods
private static readonly nint AlphaKey = CFStringCreateWithCString(0, "kCGWindowAlpha", 0x08000100);
private static readonly nint LayerKey = CFStringCreateWithCString(0, "kCGWindowLayer", 0x08000100);
+ /// Process names of the app windows on screen (layer 0), front to back.
+ internal static IEnumerable WindowOwnerNames()
+ {
+ var windows = CGWindowListCopyWindowInfo(1, 0);
+ if (windows == 0)
+ yield break;
+ try
+ {
+ for (nint index = 0; index < CFArrayGetCount(windows); index++)
+ {
+ var window = CFArrayGetValueAtIndex(windows, index);
+ var layerValue = CFDictionaryGetValue(window, LayerKey);
+ if (layerValue == 0 || !CFNumberGetInt(layerValue, 3, out var layer) || layer != 0)
+ continue;
+ var pidValue = CFDictionaryGetValue(window, PidKey);
+ if (pidValue == 0 || !CFNumberGetInt(pidValue, 3, out var pid))
+ continue;
+ string? name = null;
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ name = process.ProcessName;
+ }
+ catch (ArgumentException) { }
+ catch (InvalidOperationException) { }
+ catch (System.ComponentModel.Win32Exception) { }
+ if (name is not null)
+ yield return name;
+ }
+ }
+ finally
+ {
+ CFRelease(windows);
+ }
+ }
+
internal static string? ProcessNameAt(int x, int y)
{
var windows = CGWindowListCopyWindowInfo(1, 0);
diff --git a/tests/Filee.App.Tests/ListSearchTests.cs b/tests/Filee.App.Tests/ListSearchTests.cs
new file mode 100644
index 0000000..858634b
--- /dev/null
+++ b/tests/Filee.App.Tests/ListSearchTests.cs
@@ -0,0 +1,131 @@
+// The search boxes above the profile, palette and preset lists, and the excluded apps on the Shortcuts page.
+
+using System.Collections.ObjectModel;
+using Avalonia.Headless.XUnit;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.ViewModels.Pages;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+
+namespace Filee.App.Tests;
+
+public class ListSearchTests
+{
+ private sealed record Item(string Name, string Format);
+
+ [Fact]
+ public void Filtered_list_follows_the_query_and_the_source()
+ {
+ var photo = new Item("Photos", "jpg");
+ var docs = new Item("Documents", "pdf");
+ var source = new ObservableCollection { photo, docs };
+ var list = new FilteredList(source, i => [i.Name, i.Format]);
+ Assert.Equal([photo, docs], list);
+
+ list.Query = "PDF ";
+ Assert.Equal([docs], list); // any of the texts, ignoring case and spaces around the query
+
+ var scans = new Item("Scans", "pdf");
+ source.Insert(0, scans);
+ Assert.Equal([scans, docs], list); // in the source's order
+
+ source.Remove(docs);
+ Assert.Equal([scans], list);
+
+ list.Query = "";
+ Assert.Equal([scans, photo], list);
+ }
+
+ [Fact]
+ public void Filtered_list_keeps_the_items_that_still_match()
+ {
+ var a = new Item("Alpha", "a");
+ var b = new Item("Beta", "b");
+ var source = new ObservableCollection { a, b };
+ var list = new FilteredList(source, i => [i.Name]);
+ var removed = 0;
+ list.CollectionChanged += (_, e) => removed += e.OldItems?.Count ?? 0;
+
+ list.Query = "a"; // both match ("Alpha", "Beta")
+ Assert.Equal(0, removed);
+ list.Query = "al";
+ Assert.Equal(1, removed); // only Beta went: a ListBox keeps Alpha selected
+ }
+
+ private sealed class Apps(params string[] names) : IPlatformServices
+ {
+ public IReadOnlyList RunningAppNames() => IPlatformServices.AppNames(names);
+ public bool IsFileManagerAt(int x, int y) => false;
+ public string? ProcessNameAt(int x, int y) => null;
+ public IReadOnlyList GetFileManagerSelection() => [];
+ public int SystemDragThreshold => 4;
+ public bool PrefersReducedMotion => false;
+ public void SetStartWithSystem(bool enabled, string executablePath) { }
+ public void SetContextMenu(bool enabled, string executablePath, string label) { }
+ public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported;
+ public Task SetModernContextMenuAsync(bool enabled, string executablePath) => Task.FromResult(new ModernContextMenuResult(false));
+ public void RevealInFileManager(string path) { }
+ public void RaiseTopmost(nint windowHandle) { }
+ }
+
+ [Fact]
+ public void Excluded_apps_are_tags_picked_or_typed()
+ {
+ var excluded = new List { "code" };
+ var saves = 0;
+ var vm = new ExcludedAppsViewModel(excluded, new Apps("Photoshop", "code", "Finder", "photoshop"), () => saves++);
+ Assert.Equal(["code"], vm.Tags.Select(t => t.Value));
+
+ // Typing suggests the running apps that aren't excluded yet.
+ vm.Text = "sho";
+ Assert.Equal(["Photoshop"], vm.Suggestions.Select(s => s.Value));
+
+ // The list to pick from: apps with a window, sorted, without duplicates or excluded ones.
+ vm.LoadRunningApps();
+ Assert.Equal(["Finder", "Photoshop"], vm.RunningApps);
+ vm.RunningSearch = "fin";
+ Assert.Equal(["Finder"], vm.VisibleRunningApps);
+
+ vm.AddCommand.Execute("Finder");
+ vm.AddCommand.Execute(" Photoshop.exe "); // typed like Windows shows it
+ vm.AddCommand.Execute("CODE"); // already there
+ Assert.Equal(["code", "Finder", "Photoshop"], excluded);
+ Assert.Equal(2, saves);
+ Assert.Empty(vm.RunningApps);
+ Assert.True(vm.NoRunningApps);
+
+ vm.RemoveCommand.Execute(new TagChip("finder", "finder"));
+ Assert.Equal(["code", "Photoshop"], excluded);
+ Assert.Equal(["Finder"], vm.RunningApps);
+ }
+
+ [Fact]
+ public void Running_app_names_leave_out_filee_itself()
+ {
+ var own = System.Diagnostics.Process.GetCurrentProcess().ProcessName;
+ Assert.Equal(["a", "B"], IPlatformServices.AppNames(["B", own, "a", "", null, "b"]));
+ }
+
+ [AvaloniaFact]
+ public void Updates_are_set_on_the_about_page()
+ {
+ TestServices.EnsureInitialized("en");
+ var store = AppHost.Get();
+ var before = store.Settings.CheckForUpdates;
+ try
+ {
+ using var about = new AboutPageViewModel(AppHost.Get(), store, AppHost.Get());
+ Assert.Equal(before, about.CheckForUpdates);
+ about.CheckForUpdates = !before;
+ Assert.Equal(!before, store.Settings.CheckForUpdates);
+ Assert.True(about.CheckNowCommand.CanExecute(null));
+ }
+ finally
+ {
+ store.Settings.CheckForUpdates = before;
+ store.SaveSettings();
+ }
+ }
+}
diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs
index ca1d642..a10795c 100644
--- a/tests/Filee.App.Tests/RenderTests.cs
+++ b/tests/Filee.App.Tests/RenderTests.cs
@@ -174,14 +174,13 @@ public void First_run_engine_setup_renders(string language)
window.Show();
Pump();
- // Everything not installed is offered; small downloads are pre-selected, the large LibreOffice, FFmpeg and calibre and the
- // EPS-only Ghostscript not.
- // (On macOS FFmpeg and Ghostscript come from Homebrew: listed, but not offered for download.)
- Assert.All(vm.Packages.Where(p => !p.IsInstalled && p.CanInstall), p => Assert.Equal(
- 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));
+ // Everything not installed is offered, nothing is ticked: installing needs a choice first.
+ Assert.Contains(vm.Packages, p => !p.IsInstalled && p.CanInstall);
+ Assert.DoesNotContain(vm.Packages, p => p.Selected);
+ Assert.False(vm.InstallCommand.CanExecute(null));
+ vm.Packages.First(p => p.CanInstall && !p.IsInstalled).Selected = true;
+ Assert.True(vm.InstallCommand.CanExecute(null));
+ vm.Packages.First(p => p.Selected).Selected = false;
Save(window, $"engine-setup-{language}.png");
window.Close();
}
@@ -511,6 +510,33 @@ private static void WithProfiles(string language, bool dark, double height, Acti
}
}
+ [AvaloniaTheory]
+ [InlineData("en")]
+ [InlineData("ko")]
+ public void Excluded_apps_render_as_tags(string language)
+ {
+ TestServices.EnsureInitialized(language);
+ var store = AppHost.Get();
+ var before = store.Settings.ExcludedProcesses.ToList();
+ try
+ {
+ store.Settings.ExcludedProcesses.Clear();
+ store.Settings.ExcludedProcesses.AddRange(["Photoshop", "code"]);
+ var vm = new TriggersPageViewModel(store, AppHost.Get());
+ var window = new Window { Content = new Filee.App.Views.Pages.TriggersPage { DataContext = vm }, Width = 820, Height = 1500 };
+ window.Show();
+ Pump();
+ Assert.Equal(["Photoshop", "code"], vm.ExcludedApps.Tags.Select(t => t.Value));
+ Save(window, $"triggers-excluded-{language}.png");
+ window.Close();
+ }
+ finally
+ {
+ store.Settings.ExcludedProcesses.Clear();
+ store.Settings.ExcludedProcesses.AddRange(before);
+ }
+ }
+
private static void Pump()
{
// Let layout, bindings and a few animation frames run.
From 72c3ef6f9ba8c3f691c9787e36eaaa9d9a46889e Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 07:29:23 +0900
Subject: [PATCH 14/16] README: install for Windows, macOS and Linux, shorter
features, screenshots after the features
- Install has one part per system: Windows (Setup or portable ZIP, updates), macOS 14+ and Linux (preview packages,
install.sh, first-start and permission steps, X11 for gestures).
- Features lose the long explanations; the macOS modifier (Option), search, excluded apps and the new audio options
are in.
- Screenshots move up after the features; build-from-source covers macOS and Linux; the privacy note points to
Settings > About for the update check.
Co-Authored-By: Claude Opus 5.5
---
README.ko.md | 160 ++++++++++++++++++++++--------------------
README.md | 180 +++++++++++++++++++++++++-----------------------
README.zh-CN.md | 139 ++++++++++++++++++++-----------------
3 files changed, 258 insertions(+), 221 deletions(-)
diff --git a/README.ko.md b/README.ko.md
index 64088dd..948b223 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -3,8 +3,8 @@
- 드래그로 쓰는 무료 오픈소스 Windows 파일 변환기.
- 키(기본 Ctrl)를 누른 채 파일을 끌면 커서 위치에 도넛이 열립니다. 원하는 형식 위에 놓으면 끝.
+ 드래그로 쓰는 무료 오픈소스 파일 변환기.
+ 키(Ctrl, macOS는 Option)를 누른 채 파일을 끌면 커서 위치에 도넛이 열려요. 원하는 형식 위에 놓으면 끝.
@@ -20,6 +20,7 @@
+
@@ -29,7 +30,7 @@
커서 바로 옆에서 앱 창을 열 필요가 없어요. 끌고 있는 파일에 맞는 형식만 지금 있는 자리에 나타나요.
180가지 형식, 다른 프로그램 없이 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD를 오피스·한컴오피스·LibreOffice 없이 변환해요.
-
내 PC 안에서만 아무것도 업로드하지 않아요. 오프라인으로 동작하고 결과는 원본 바로 옆에 저장해요.
+
내 컴퓨터 안에서만 아무것도 업로드하지 않아요. 오프라인으로 동작하고 결과는 원본 옆에 저장해요.
@@ -42,52 +43,84 @@
## 설치
-[Releases](https://github.com/KnifeLemon/Filee/releases/latest)에서 최신 `Filee-<버전>-win-Setup.exe`를
-받아 실행하세요(Windows 10/11, 64비트). 관리자 권한을 한 번 요청하고, 모든 사용자를 위해 Program Files에 설치해요.
-설치 없이 쓰고 싶다면 `Filee-<버전>-win-Portable.zip`을 원하는 곳에 풀고 `Filee.exe`를 실행하면 돼요.
+[Releases](https://github.com/KnifeLemon/Filee/releases/latest)에서 받으세요.
-- **자주 쓰는 형식은 설치 파일에 다 들어 있어요.** 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD는
- 설치하자마자 변환돼요.
-- **설치하면서 골라요.** 탐색기 우클릭 메뉴의 “Filee로 변환”(Windows 11에서는 “추가 옵션 표시” 없이 기본 메뉴에도),
- 로그인할 때 시작, 그리고 추가 엔진을 크기를 보며 고를 수 있어요: 동영상·오디오(FFmpeg, 약 100MB)와 드문
- 형식(LibreOffice, calibre, Ghostscript, Pandoc). 고른 엔진은 설치가 끝나고 Filee가 시작되면서 내려받고, 설정 → 변환
- 엔진에서 다운로드 속도와 남은 시간을 보며 언제든 설치하거나 지울 수 있어요.
-- **업데이트는 알아서 설치돼요.** 새 버전이 나오면 메뉴 하단, 알림, 트레이 메뉴에 표시돼요. 업데이트를 누르면 새 설치
- 파일을 받아 릴리스의 SHA-256 값과 맞춰 본 뒤, 관리자 확인 한 번을 거쳐 설치해요. 설치 프로그램이 Filee를 닫고
- 업데이트한 다음 다시 켜 주고, 설정과 엔진은 그대로 남아요. 휴대용 버전은 대신 릴리스 페이지가 열려요. 사용자별로
- 설치된 Filee 1.1 이하도 새 설치 파일이 넘겨받아요.
+### Windows 10/11 (64비트)
-Linux와 macOS 버전을 개발 중이며 실제 데스크톱 검증을 진행하고 있어요. 현재 안정 버전 다운로드는 Windows용이에요.
-개발 빌드는 [Unix 빌드 안내](CONTRIBUTING.md#linux-and-macos-development)를 참고해 주세요.
+`Filee-<버전>-win-Setup.exe`를 실행하세요. 관리자 권한을 한 번 요청하고 모든 사용자를 위해 설치해요. 설치하면서
+탐색기 우클릭 메뉴, 로그인 시 시작, `filee` 명령, 추가 엔진(동영상·오디오용 FFmpeg 약 100MB, 드문 형식용 LibreOffice·
+calibre·Ghostscript·Pandoc)을 고를 수 있어요.
+
+설치 없이 쓰려면 `Filee-<버전>-win-Portable.zip`을 풀고 `Filee.exe`를 실행하면 돼요.
+
+새 버전이 나오면 메뉴와 트레이에 표시돼요. **업데이트**를 누르면 설치 파일을 받아 릴리스의 SHA-256 값과 맞춰 본 뒤
+설치하고, 설정과 엔진은 그대로 남아요. 휴대용 버전은 대신 릴리스 페이지가 열려요.
+
+### macOS 14 이상 (미리보기)
+
+1. `Filee-<버전>-osx-arm64.tar.gz`(Apple Silicon)나 `Filee-<버전>-osx-x64.tar.gz`(Intel)를 받아요.
+2. 압축을 푼 폴더에서 터미널을 열고 `./install.sh`를 실행해요. Filee.app을 `~/Applications`에 복사하고 `filee` 명령을
+ `~/.local/bin`에 추가해요.
+3. 아직 공증(notarization)을 받지 않은 빌드라 처음 실행할 때 macOS가 막아요. 시스템 설정 → 개인정보 보호 및 보안에서
+ **그래도 열기**를 눌러 주세요.
+4. Filee가 손쉬운 사용 권한을 요청해요. Option+드래그에 필요하고, Filee를 켜는 즉시 동작해요.
+
+Finder에서 파일을 우클릭하면 "Filee로 변환"이 나와요. FFmpeg와 Ghostscript는 Homebrew로 설치하면
+(`brew install ffmpeg ghostscript`) Filee가 알아서 찾아요.
+
+### Linux x64·ARM64 (미리보기)
+
+1. `Filee-<버전>-linux-x64.tar.gz`나 `Filee-<버전>-linux-arm64.tar.gz`를 받아요.
+2. 압축을 푼 폴더에서 `./install.sh`를 실행해요. 관리자 권한 없이 내 계정에만 설치하고, 앱 메뉴에 Filee를, `~/.local/bin`에
+ `filee` 명령을 추가해요. Python 3가 필요해요.
+
+드래그 제스처는 X11 세션에서 동작해요(Ubuntu는 로그인 화면에서 "Ubuntu on Xorg"). Wayland는 앱이 전역 입력을 볼 수 없게
+막아서, 그때는 드롭 영역과 Nautilus·Nemo·Thunar·Dolphin의 우클릭 메뉴로 쓰면 돼요. Ubuntu 24.04에서 테스트하고 있고,
+필요한 시스템 라이브러리는 패키지 안의 `INSTALL.txt`에 있어요.
## 기능
-- **커서 위치에 뜨는 도넛 툴바.** 수정키(기본 Ctrl)를 누른 채 파일을 끌면 돼요. 수정키 조합, 마우스 버튼, 드래그
- 거리, 길게 누르기, 선택한 파일용 키보드 단축키까지 모두 바꿀 수 있어요.
+- **커서 위치에 뜨는 도넛 툴바.** 수정키(Ctrl, macOS는 Option)를 누른 채 파일을 끌면 돼요. 수정키,
+ 마우스 버튼, 드래그 거리, 길게 누르기, 선택한 파일용 단축키를 모두 바꿀 수 있고, 제스처가 동작하지 않을 앱은 실행 중인
+ 앱 목록에서 고를 수 있어요.
- **파일 종류별 툴바.** 이미지, PDF, 문서, 스프레드시트, 프레젠테이션, HWP, 텍스트, 전자책, 동영상, 오디오, 벡터 그래픽,
- 압축 파일, CAD, 글꼴, 그리고 여러 종류를 함께 골랐을 때의 툴바. 실제 도넛 위에서 드래그 앤 드롭으로 프리셋을 배치하고(놓일 자리가 미리 벌어져요),
- 확장자는 태그로 추가하고, 우클릭이나 ✎로 프리셋을 편집해요.
-- **프리셋.** 품질, 크기 조정, DPI, 메타데이터, PDF 합치기·나누기, 페이지 범위, 동영상 화질·해상도, 오디오 비트레이트,
- 압축 수준, 저장 위치, 파일 이름 패턴, 이름 충돌 처리, 원본 파일 날짜 유지(프리셋마다, 또는 설정 > 일반에서 모든 변환에). TIFF 프리셋은 모든 파일을
- 여러 페이지짜리 TIFF 하나로 합칠 수 있어요(이미지·PDF 페이지마다 한 장). 변환된 파일은 기본으로 원본 옆에
- 저장되고, 설정 > 일반의 기본 저장 위치(하위 폴더나 지정한 폴더 하나)를 바꾸면 저장 위치를 따로 정하지 않은
- 프리셋이 모두 따라가요.
+ 압축 파일, CAD, 글꼴, 여러 종류를 섞은 선택. 실제 도넛 위에서 드래그 앤 드롭으로 프리셋을 배치하고, 프로필과 프리셋을
+ 검색하고, 확장자는 태그로 추가해요.
+- **프리셋.** 품질, 크기, DPI, 메타데이터, PDF 합치기·나누기·페이지 범위, 동영상 화질·해상도, 오디오 비트레이트·채널·WAV
+ 형식, 압축 수준, 저장 위치, 파일 이름, 이름 충돌 처리, 원본 파일 날짜 유지. TIFF 프리셋은 모든 파일을 여러 페이지짜리
+ TIFF 하나로 합칠 수 있어요.
- **일괄 변환.** 1개든 100개든 동시에 변환하고, 하나가 실패해도 나머지는 계속돼요. "ZIP 하나로"는 어떤 파일이든 압축 파일
하나로, "PDF 병합"은 PDF 하나로 묶어요.
-- **경로 자동 계획.** 여러 단계 변환도 알아서 찾아요(예: HWP → HWPX → DOCX, EPUB → HWPX → PDF → PNG). 단계마다 가장 잘하는
- 엔진을 써요.
-- **네 가지 사용 방법.** 드래그 동작, 탐색기 우클릭 메뉴(Windows 11은 기본 메뉴에도)와 보내기, 탐색기에서 선택한 파일 +
- 단축키, 메인 창 드롭 영역.
-- **자동 변환 폴더.** 정해 둔 폴더에 들어오는 파일을 Filee가 실행 중일 때 자동으로 변환해요. 다운로드나 복사가 끝난 뒤에
- 변환하고, 원본은 그대로 두거나 따로 옮겨서 폴더를 받은편지함처럼 쓸 수 있어요.
-- **명령줄.** `filee convert *.heic --to jpg`, `filee watch D:\Inbox --to pdf`처럼 쓰고, 앱의 프리셋, 스크립트용 JSON 출력,
- 종료 코드를 지원해요. 설치할 때 `filee` 명령을 PATH에 추가할 수 있어요.
-- **보이는 엔진.** 설정 → 변환 엔진에서 엔진마다 버전과 변환 범위를 보여 주고, 추가 엔진은 다운로드 속도와 남은 시간을
- 보며 받을 수 있어요. FFmpeg, calibre, Pandoc, Ghostscript, LibreOffice가 PC에 이미 있으면(PATH, Scoop, Program Files)
- 알아서 찾아 쓰고 따로 내려받지 않아요. Filee가 테스트한 버전보다 오래됐으면 업데이트를 권해요. 특정 프로그램을 쓰려면 "내
- 프로그램 쓰기…"로 고르세요.
-- **말랑한 애니메이션 UI.** 라이트·다크·시스템 테마, 강조색, 모서리 둥글기, 저사양 PC를 위한 *애니메이션 줄이기*.
-- **한국어, English, 简体中文.** 기본값은 Windows 언어를 따라요.
+- **여러 단계 변환.** 여러 단계가 필요한 변환도 알아서 찾아요(HWP → HWPX → DOCX, EPUB → HWPX → PDF → PNG). 단계마다 가장
+ 잘하는 엔진을 써요.
+- **네 가지 사용 방법.** 드래그 동작, 파일 관리자 우클릭 메뉴(Windows는 보내기도), 선택한 파일 + 단축키, 메인 창 드롭 영역.
+- **자동 변환 폴더.** 정해 둔 폴더에 들어온 파일을 다운로드나 복사가 끝난 뒤 변환해요. 원본은 그대로 두거나 따로 옮겨서
+ 폴더를 받은편지함처럼 쓸 수 있어요.
+- **보이는 엔진.** 설정 → 변환 엔진에서 엔진마다 버전과 변환 범위를 보여 줘요. PC에 이미 있는 FFmpeg, calibre, Pandoc,
+ Ghostscript, LibreOffice는 찾아서 쓰고 따로 내려받지 않아요.
+- **라이트·다크·시스템 테마**, 강조색, *애니메이션 줄이기*.
+- **한국어, English, 简体中文.** 기본값은 시스템 언어를 따라요.
+
+## 스크린샷
+
+
+
+
+
+
+
+
파일 종류마다 도넛을 드래그 앤 드롭으로 배치
+
홈: 드롭 영역과 최근 변환 기록
+
+
+
+
+
+
+
프리셋 편집: 품질, 크기, 저장 위치, 파일 이름
+
큰 엔진은 처음 실행할 때 골라서 설치
+
+
## 지원 형식
@@ -108,14 +141,14 @@ Linux와 macOS 버전을 개발 중이며 실제 데스크톱 검증을 진행
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT | 서로 변환 |
-표시가 없는 것은 설치하자마자 변환돼요. MS 오피스·한컴오피스·LibreOffice가 없어도 되고, MS 오피스나 한컴오피스가 설치되어 있어도 쓰지 않아요. 아래 추가 엔진은 PC에 없을 때만 내려받아요.
-설치할 때 고르거나 나중에 앱에서 받는 추가 엔진: ¹ Ghostscript, ² LibreOffice, ³ Pandoc, ⁴ calibre, ⁵ FFmpeg. 엔진마다 무엇을
-유지하고 무엇을 빼는지는 [docs/ENGINES.md](docs/ENGINES.md)에 있어요.
+표시가 없는 형식은 설치하자마자 변환돼요. MS 오피스·한컴오피스·LibreOffice가 없어도 되고, 설치되어 있어도 MS 오피스와
+한컴오피스는 쓰지 않아요. 표시가 있는 형식은 추가 엔진이 필요하고, 컴퓨터에 없을 때만 내려받아요: ¹ Ghostscript,
+² LibreOffice, ³ Pandoc, ⁴ calibre, ⁵ FFmpeg. 엔진마다 무엇을 유지하고 무엇을 빼는지는 [docs/ENGINES.md](docs/ENGINES.md)에 있어요.
## 명령줄
`filee` 명령으로 앱을 열지 않고도 파일을 변환하고 폴더를 감시할 수 있어요. 앱과 같은 엔진과 프리셋을 써요.
-설치할 때 **"filee" 명령어를 PATH에 추가**를 체크한 뒤 새 터미널을 열면 돼요.
+Windows는 설치할 때 **"filee" 명령어를 PATH에 추가**를 체크하고, macOS와 Linux는 `install.sh`가 추가해요.
```
filee convert photo.heic --to jpg
@@ -137,27 +170,6 @@ filee presets
`1` 일부 실패, `2` 잘못된 인자나 없는 파일, `3` 변환할 파일 없음. 모든 옵션은 [명령줄 가이드](https://filee.sh/ko/cli/)와
`filee help`에 있어요.
-## 스크린샷
-
-
-
-
-
-
-
-
파일 종류마다 도넛을 드래그 앤 드롭으로 배치
-
홈: 드롭 영역과 최근 변환 기록
-
-
-
-
-
-
-
프리셋 편집: 품질, 크기, 저장 위치, 파일 이름
-
큰 엔진은 처음 실행할 때 골라서 설치
-
-
-
## 문서
| 하고 싶은 일 | 여기서 시작 |
@@ -171,7 +183,7 @@ filee presets
## 소스에서 빌드
-필요: .NET 10 SDK, Windows 10/11.
+필요: .NET 10 SDK, 그리고 Windows 10/11, macOS 14 이상 또는 Linux.
```bash
git clone https://github.com/KnifeLemon/Filee.git
@@ -182,23 +194,25 @@ dotnet run --project src/Filee.App
dotnet test
```
-설정은 `%APPDATA%\Filee`에 저장됩니다. 소스에서 빌드한 Filee는 탐색기 우클릭 메뉴나 자동 시작을 건드리지 않습니다.
-`pwsh build/build-installer.ps1`로 설치 프로그램을 `Releases/`에 만들 수 있어요(고정된 버전의 Inno Setup을 `build/.cache`에
-받아서 쓰고, 아무것도 설치하지 않아요).
+설정은 `%APPDATA%\Filee`(Windows), `~/Library/Application Support/Filee`(macOS), `~/.config/Filee`(Linux)에 저장돼요.
+소스에서 빌드한 Filee는 우클릭 메뉴나 자동 시작을 건드리지 않아요. `pwsh build/build-installer.ps1`은 Windows 설치 파일을
+`Releases/`에 만들고, Mac이나 Linux에서 `pwsh build/build-portable.ps1`을 실행하면 그 시스템용 패키지를 만들어요. 자세한 내용은
+[CONTRIBUTING.md](CONTRIBUTING.md#linux-and-macos-development)에 있어요.
| 프로젝트 | 설명 |
|---|---|
| `src/Filee.Core` | 형식, 프리셋, 툴바 프로필, 설정, 경로 계획, 작업 큐. UI·OS API 없음. |
| `src/Filee.Engines` | 변환 엔진마다 클래스 하나(`IConverter`). HWPX 작성기 포함. |
| `src/Filee.Platform.Windows` | 탐색기 연동, 우클릭 메뉴, 자동 시작. |
+| `src/Filee.Platform.MacOS`, `src/Filee.Platform.Linux` | macOS·Linux의 Finder 서비스, 파일 관리자 메뉴, 제스처, 자동 시작. |
| `src/Filee.App` | Avalonia UI: 도넛 툴바, 설정 창, 트레이, 알림. |
| `src/Filee.Cli` | `filee` 명령줄(convert, watch, formats, presets). 앱과 같은 엔진과 프리셋을 써요. |
| `tests/*` | xUnit v3 테스트(헤드리스 UI 렌더링 포함). |
## 기여
-PR 환영합니다. 시작하기 좋은 작업은 [변환기 추가](docs/ADDING-A-CONVERTER.md)와 [언어 추가](docs/ADDING-A-LANGUAGE.md)입니다.
-Filee가 쓸만하다면 ⭐ 하나가 다른 사람들이 찾는 데 큰 도움이 됩니다.
+PR 환영해요. 시작하기 좋은 작업은 [변환기 추가](docs/ADDING-A-CONVERTER.md)와 [언어 추가](docs/ADDING-A-LANGUAGE.md)예요.
+Filee가 쓸만하다면 ⭐ 하나가 다른 사람들이 Filee를 찾는 데 도움이 돼요.
## 코드 서명 정책
@@ -207,7 +221,7 @@ Filee의 코드 서명 정책(Code signing policy)과 개인정보 처리 방침
## 라이선스
-MIT. 포함된 서드파티 구성요소는 각자의 라이선스를 따릅니다: [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
+MIT. 포함된 서드파티 구성요소는 각자의 라이선스를 따르고, [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)에 정리되어 있어요.
- A free, open-source file converter for Windows that you use by dragging.
- Hold a key (Ctrl by default), drag files and drop them on a format in the donut that opens right at your cursor.
+ A free, open-source file converter you use by dragging.
+ Hold a key (Ctrl, Option on macOS), drag files and drop them on a format in the donut that opens at your cursor.
@@ -20,6 +20,7 @@
+
@@ -29,7 +30,7 @@
Right at your cursor No app window to open. The formats appear where you already are, only the ones that make sense for the files you drag.
180 formats, no other software Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and CAD work without Office, Hancom Office or LibreOffice.
-
Stays on your PC Nothing is uploaded. Filee works offline and saves the result right next to the original.
+
Stays on your computer Nothing is uploaded. Filee works offline and saves the result next to the original.
@@ -42,58 +43,87 @@
## Install
-Download the latest `Filee--win-Setup.exe` from
-[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.
-- **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.
-- **Updates install themselves.** When a new release is out, Filee says so at the bottom of the menu, in a notice and
- in the tray menu. Press Update and Filee downloads the new installer, checks it against the release's SHA-256 file
- and runs it after one administrator prompt. Setup closes Filee, updates it, keeps your settings and engines, and
- starts it again. The portable copy opens the release page instead. Filee 1.1 and earlier (installed per user) are
- taken over by the new installer too.
-
-Linux and macOS ports are in development. Native desktop validation is still in progress; the Windows release
-remains the stable download. See [the Unix build instructions](CONTRIBUTING.md#linux-and-macos-development).
+Download from [Releases](https://github.com/KnifeLemon/Filee/releases/latest).
+
+### Windows 10/11 (64-bit)
+
+Run `Filee--win-Setup.exe`. It asks for administrator rights once and installs Filee for all users. Setup
+also offers the File Explorer menu entry, starting at sign-in, the `filee` command and the optional engines
+(video and audio with FFmpeg, about 100 MB; rare formats with LibreOffice, calibre, Ghostscript or Pandoc).
+
+Without installing: unzip `Filee--win-Portable.zip` and run `Filee.exe`.
+
+When a new release is out, Filee shows it in the menu and the tray. **Update** downloads the installer, checks its
+SHA-256 against the release and installs it, keeping your settings and engines. The portable copy opens the release
+page instead.
+
+### macOS 14 or later (preview)
+
+1. Download `Filee--osx-arm64.tar.gz` (Apple silicon) or `Filee--osx-x64.tar.gz` (Intel).
+2. Open Terminal in the extracted folder and run `./install.sh`. It copies Filee.app to `~/Applications` and adds
+ the `filee` command to `~/.local/bin`.
+3. The build is not notarized yet, so macOS blocks the first start. Allow it in System Settings → Privacy & Security
+ → **Open Anyway**.
+4. Filee asks for Accessibility permission, which Option+drag needs. It starts working as soon as you
+ switch Filee on.
+
+"Convert with Filee" appears when you right-click files in Finder. FFmpeg and Ghostscript come from Homebrew
+(`brew install ffmpeg ghostscript`); Filee finds them by itself.
+
+### Linux x64 and ARM64 (preview)
+
+1. Download `Filee--linux-x64.tar.gz` or `Filee--linux-arm64.tar.gz`.
+2. Run `./install.sh` in the extracted folder. It installs for your user only (no root), adds Filee to the app menu
+ and the `filee` command to `~/.local/bin`. Python 3 is required.
+
+Drag gestures need an X11 session (on Ubuntu, "Ubuntu on Xorg" on the login screen). Wayland doesn't let apps watch
+global input, so there Filee works through the drop zone and the right-click action in Nautilus, Nemo, Thunar or
+Dolphin. Ubuntu 24.04 is the tested baseline; `INSTALL.txt` in the package lists the system libraries.
## Features
-- **Donut toolbar at the cursor.** Hold a modifier key (Ctrl by default) and drag files. Everything is
- configurable: any modifier combination, mouse button, drag distance, a hold gesture or a keyboard shortcut for the
- selected files.
-- **One toolbar per file type.** Images, PDF, documents, spreadsheets, presentations, HWP, text, e-books, video, audio,
- vector graphics, archives, CAD and fonts, plus toolbars for mixed selections. Arrange presets on a live donut (it opens a gap where a dragged preset will land) by
- drag & drop, add extensions as tags, right-click or ✎ to edit a preset.
-- **Presets.** Quality, resizing, DPI, metadata, PDF merge and split, page ranges, video quality and resolution, audio
- bitrate, archive compression, output folder, file name pattern, conflict handling and keeping the original file dates
- (one preset at a time, or for every conversion in Settings → General). TIFF presets can put every file into one
- multi-page TIFF (one page per image or PDF page).
- Converted files go next to the original by default. Settings → General sets another default save location (a
- subfolder or one fixed folder) for every preset that has no location of its own.
-- **Batch conversion.** 1 file or 100, converted in parallel; one failure never stops the rest. "One ZIP" packs any
- files into a single archive, "Merge PDF" makes one PDF.
-- **Planned routes.** Multi-step conversions are found automatically (e.g. HWP → HWPX → DOCX, EPUB → HWPX → PDF → PNG),
+- **Donut toolbar at the cursor.** Hold a modifier (Ctrl, Option on macOS) and drag files.
+ Modifiers, mouse button, drag distance, a hold gesture and a keyboard shortcut for the selected files are all
+ configurable, and apps where gestures should never fire can be picked from the running apps.
+- **One toolbar per file type.** Images, PDF, documents, spreadsheets, presentations, HWP, text, e-books, video,
+ audio, vector graphics, archives, CAD, fonts and mixed selections. Arrange presets on a live donut by drag and drop,
+ search the profiles and presets, add extensions as tags.
+- **Presets.** Quality, size, DPI, metadata, PDF merge, split and page ranges, video quality and resolution, audio
+ bitrate, channels and WAV format, archive level, output folder, file names, conflict handling and keeping the
+ original file dates. TIFF presets can join every file into one multi-page TIFF.
+- **Batch conversion.** One file or a hundred, in parallel; one failure never stops the rest. "One ZIP" packs any
+ files into an archive, "Merge PDF" makes one PDF.
+- **Multi-step routes.** Filee finds conversions that need several steps (HWP → HWPX → DOCX, EPUB → HWPX → PDF → PNG),
each step with the engine that does it best.
-- **Four ways in.** The drag gesture, Explorer's right-click menu (on Windows 11 also in the main menu) and Send To, a
- keyboard shortcut on the Explorer selection, and the drop zone in the main window.
-- **Watch folders.** Files that land in a folder you choose are converted automatically while Filee runs, once
- downloads and copies are complete. Keep the originals or move them aside so the folder works like an inbox.
-- **Command line.** `filee convert *.heic --to jpg`, `filee watch D:\Inbox --to pdf`, with your presets, JSON output
- for scripts and exit codes. Setup can put the `filee` command on PATH.
-- **Engines you can see.** Settings → Engines lists every engine with its version and what it converts; optional
- engines download with speed and time left. FFmpeg, calibre, Pandoc, Ghostscript or LibreOffice already on your PC
- (on the PATH, Scoop included, or in Program Files) are found and used instead of a download, with a note when one is
- older than the version Filee is tested with. "Use my copy…" picks a specific one.
-- **Soft, animated UI.** Light, dark or system theme, your accent colour, adjustable roundness and a *Reduce animations*
- switch for older PCs.
-- **English, 한국어, 简体中文**, following your Windows language by default.
+- **Four ways in.** The drag gesture, the file manager's right-click menu (and Send To on Windows), a keyboard shortcut
+ on the selected files, and the drop zone in the main window.
+- **Watch folders.** Files that land in a folder you choose are converted once their download or copy is complete.
+ Keep the originals or move them aside, so the folder works like an inbox.
+- **Engines you can see.** Settings → Engines lists every engine with its version and what it converts. FFmpeg,
+ calibre, Pandoc, Ghostscript and LibreOffice already on your computer are found and used instead of a download.
+- **Light, dark or system theme**, your accent colour and a *Reduce animations* switch.
+- **English, 한국어, 简体中文**, following the system language.
+
+## Screenshots
+
+
+
+
+
+
+
+
Arrange the donut by drag and drop, one profile per file type
+
Home: drop zone and recent conversions
+
+
+
+
+
+
+
Presets: quality, size, PDF pages, output folder and file names
+
Dark theme
+
+
## Formats
@@ -114,16 +144,15 @@ remains the stable download. See [the Unix build instructions](CONTRIBUTING.md#l
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT | each other |
-Everything without a mark works out of the box: no Microsoft Office, Hancom Office or LibreOffice needed, and Filee
-doesn't use Microsoft Office or Hancom Office even when they are installed. The optional engines below are
-downloaded only if your PC doesn't already have them. Optional engines Filee offers to download: ¹ Ghostscript, ² LibreOffice,
-³ Pandoc, ⁴ calibre, ⁵ FFmpeg. Details, including what each engine keeps and leaves out:
-[docs/ENGINES.md](docs/ENGINES.md).
+Everything without a mark works out of the box, without Microsoft Office, Hancom Office or LibreOffice (Filee
+doesn't use Office or Hancom Office even when they are installed). Marked formats need an optional engine, downloaded
+only when your computer doesn't have it: ¹ Ghostscript, ² LibreOffice, ³ Pandoc, ⁴ calibre, ⁵ FFmpeg.
+[docs/ENGINES.md](docs/ENGINES.md) lists what each engine keeps and leaves out.
## Command line
The `filee` command converts files and watches folders without opening the app, with the same engines and presets.
-Tick **Add the "filee" command to PATH** in Setup, then open a new terminal.
+On Windows, tick **Add the "filee" command to PATH** in Setup; on macOS and Linux, `install.sh` adds it.
```
filee convert photo.heic --to jpg
@@ -145,27 +174,6 @@ filee presets
`2` wrong arguments or a missing input, `3` nothing to convert. Every option is in the
[command line guide](https://filee.sh/cli/) and in `filee help`.
-## Screenshots
-
-
-
-
-
-
-
-
Arrange the donut by drag & drop, one profile per file type
-
Home: drop zone and recent conversions
-
-
-
-
-
-
-
Presets: quality, size, PDF pages, output folder and file names
-
Dark theme
-
-
-
## Documentation
| I want to… | Start here |
@@ -179,7 +187,7 @@ filee presets
## Build from source
-Requirements: .NET 10 SDK, Windows 10/11.
+Requirements: .NET 10 SDK on Windows 10/11, macOS 14+ or Linux.
```bash
git clone https://github.com/KnifeLemon/Filee.git
@@ -190,15 +198,17 @@ dotnet run --project src/Filee.App
dotnet test
```
-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).
+Settings live in `%APPDATA%\Filee` (Windows), `~/Library/Application Support/Filee` (macOS) or `~/.config/Filee`
+(Linux). Builds from source never touch the context menu or auto-start. `pwsh build/build-installer.ps1` builds the
+Windows installer into `Releases/`; `pwsh build/build-portable.ps1`, run on a Mac or Linux, builds that system's
+package. More in [CONTRIBUTING.md](CONTRIBUTING.md#linux-and-macos-development).
| Project | What it is |
|---|---|
| `src/Filee.Core` | Formats, presets, toolbar profiles, settings, route planner, job queue. No UI, no OS APIs. |
| `src/Filee.Engines` | One class per conversion engine (`IConverter`), including the HWPX writer. |
| `src/Filee.Platform.Windows` | Explorer integration, context menu, auto-start. |
+| `src/Filee.Platform.MacOS`, `src/Filee.Platform.Linux` | Finder service, file manager actions, gestures and auto-start on macOS and Linux. |
| `src/Filee.App` | Avalonia UI: donut toolbar, settings window, tray, toasts. |
| `src/Filee.Cli` | The `filee` command line (convert, watch, formats, presets) on the same engines and presets. |
| `tests/*` | xUnit v3 tests, including headless UI rendering. |
@@ -214,7 +224,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi
[SignPath Foundation](https://signpath.org/).
> Filee has applied for this program. Until it is approved, releases are not code-signed and Windows SmartScreen may
-> warn when you run the installer ("More info" → "Run anyway").
+> warn when you run the installer ("More info" → "Run anyway"). macOS packages are ad-hoc signed and not notarized.
- Committers and reviewers: [KnifeLemon](https://github.com/KnifeLemon)
- Approvers: [KnifeLemon](https://github.com/KnifeLemon)
@@ -228,14 +238,14 @@ Filee converts files on your own computer and never uploads them. It collects no
It connects to the internet only:
- to ask GitHub (`api.github.com`) whether a newer release exists, shortly after start and then every few hours.
- Turn this off in Settings → General ("Check for updates").
+ Turn this off in Settings → About ("Check for updates automatically").
- to download the conversion engines you choose to install, from the official locations pinned in
[`engines.json`](src/Filee.Engines/Infrastructure/engines.json).
- when you click a link, which opens your web browser.
## License
-MIT. Bundled third-party components keep their own licenses: see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
+MIT. Bundled third-party components keep their own licenses, listed in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
-
## 文档
| 我想要… | 从这里开始 |
@@ -164,7 +174,7 @@ filee presets
## 从源码构建
-需要:.NET 10 SDK、Windows 10/11。
+需要:.NET 10 SDK,以及 Windows 10/11、macOS 14 以上或 Linux。
```bash
git clone https://github.com/KnifeLemon/Filee.git
@@ -175,13 +185,16 @@ dotnet run --project src/Filee.App
dotnet test
```
-设置保存在 `%APPDATA%\Filee`。调试版本不会修改资源管理器右键菜单和开机自启动。
+设置保存在 `%APPDATA%\Filee`(Windows)、`~/Library/Application Support/Filee`(macOS)或 `~/.config/Filee`(Linux)。
+从源码构建的版本不会修改右键菜单和开机自启动。`pwsh build/build-installer.ps1` 生成 Windows 安装程序;在 Mac 或 Linux 上运行
+`pwsh build/build-portable.ps1` 生成该系统的安装包。详见 [CONTRIBUTING.md](CONTRIBUTING.md#linux-and-macos-development)。
| 项目 | 说明 |
|---|---|
| `src/Filee.Core` | 格式、预设、工具栏配置、设置、路径规划、任务队列。不依赖 UI 和系统 API。 |
| `src/Filee.Engines` | 每个转换引擎一个类(`IConverter`),包括 HWPX 写入器。 |
| `src/Filee.Platform.Windows` | 资源管理器集成、右键菜单、开机自启动。 |
+| `src/Filee.Platform.MacOS`、`src/Filee.Platform.Linux` | macOS 和 Linux 的访达服务、文件管理器菜单、手势和开机自启动。 |
| `src/Filee.App` | Avalonia 界面:圆环工具栏、设置窗口、托盘、通知。 |
| `src/Filee.Cli` | `filee` 命令行(convert、watch、formats、presets),与应用共用引擎和预设。 |
| `tests/*` | xUnit v3 测试,包括无头 UI 渲染。 |
From 9bb5425fddb4a7a26e9a6a9f2ea31e9944cb180a Mon Sep 17 00:00:00 2001
From: KnifeLemon
Date: Wed, 7 Oct 2026 07:33:10 +0900
Subject: [PATCH 15/16] Version 1.7.0
- CHANGELOG: macOS and Linux previews, search, excluded apps as tags, updates on About, nothing pre-ticked.
- README screenshots rendered again (search boxes, version 1.7.0, nothing ticked in the engine choice).
- The Linux and macOS packages workflow runs on pull requests.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/portable.yml | 5 ++---
CHANGELOG.md | 27 +++++++++++++++++++++++++++
Directory.Build.props | 2 +-
docs/media/engine-setup-en.png | Bin 123147 -> 122398 bytes
docs/media/engine-setup-ko.png | Bin 126067 -> 125778 bytes
docs/media/page-home-en.png | Bin 37529 -> 37494 bytes
docs/media/page-home-ko.png | Bin 32857 -> 32811 bytes
docs/media/page-home-zh-CN.png | Bin 42136 -> 42080 bytes
docs/media/page-presets-en.png | Bin 79471 -> 80174 bytes
docs/media/page-presets-zh-CN.png | Bin 84652 -> 85081 bytes
docs/media/page-toolbar-dark.png | Bin 155909 -> 155145 bytes
docs/media/page-toolbar-en.png | Bin 150052 -> 149189 bytes
docs/media/page-toolbar-ko.png | Bin 134884 -> 133292 bytes
docs/media/page-toolbar-zh-CN.png | Bin 150764 -> 149414 bytes
14 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml
index 4092663..63cbda0 100644
--- a/.github/workflows/portable.yml
+++ b/.github/workflows/portable.yml
@@ -13,9 +13,8 @@ on:
description: 'Package version (empty uses Directory.Build.props)'
type: string
required: false
- # The Linux and macOS work in progress: every push builds, tests and launches the app on real Macs.
- push:
- branches: ['wip/linux-macos']
+ # Every pull request builds, tests and launches the app on Linux and on real Macs (screenshots in the results).
+ pull_request:
permissions:
contents: read
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2fb0ce..e711c39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,33 @@ version it publishes under "What's Changed" in the GitHub release notes, above t
Add a `## ` section before tagging a release. Write what someone using Filee notices: what's new, where to
find it, what it does and doesn't do.
+## 1.7.0
+
+### macOS and Linux (preview)
+
+- Filee runs on macOS 14 or later (Apple silicon and Intel) and on Linux (x64 and ARM64). Download the package for
+ your system from this release and run `install.sh` in it. These are previews: the macOS app isn't notarized yet,
+ so macOS asks you to allow it once under System Settings → Privacy & Security.
+- macOS: hold Option and drag files to open the donut. Filee asks for the Accessibility permission the gesture needs
+ and starts it as soon as you switch Filee on. "Convert with Filee" is in Finder's right-click menu.
+- Linux: the drag gesture works in an X11 session (on Ubuntu, "Ubuntu on Xorg" on the login screen). On Wayland, use
+ the drop zone and the right-click action in Nautilus, Nemo, Thunar or Dolphin.
+- FFmpeg and Ghostscript come from Homebrew on macOS and from your distribution's packages on Linux. Filee finds them
+ by itself.
+
+### New
+
+- Search boxes above the profiles and the available presets (Donut toolbar) and above the preset list. A profile is
+ also found by an extension it handles: type "png" to find the image donut.
+- "Never trigger in these apps" (Shortcuts) holds the apps as tags. Type a name, with the running apps suggested, or
+ pick one from the apps that have a window open.
+- The update settings are on the About page now: the automatic check, Check now and the update button.
+- The first-run engine choice starts with nothing ticked, so only what you pick is downloaded.
+
+### Fixes
+
+- On macOS and Linux, watch folders wait for a copy that pauses to finish instead of converting it half-written.
+
## 1.6.0
### New in the preset editor for audio
diff --git a/Directory.Build.props b/Directory.Build.props
index f6b112d..8b79436 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
parameters of a member is fine (CS1573). -->
$(NoWarn);CS1591;CS1573
- 1.6.0
+ 1.7.0FileeFilee contributorsCopyright (c) Filee contributors
diff --git a/docs/media/engine-setup-en.png b/docs/media/engine-setup-en.png
index 15faf274678461ce1abc59bf466854a3cde8f633..83b6552c137f252b5bdc6314ec833f21ad55ca51 100644
GIT binary patch
literal 122398
zcmdSBRajh2&@DW;GZ2EiySqbhclY4#?hrh9a1HM68a%kWySw{8dB5ko`k!-mZqD4y
z?3wOe)z!7TyH?ej2t|1bBzPQn004j_B`K;50DvHombb2oM%yFdY5@vO4;X1?ELhN2z8?fkx-mUv;n`BW6A>xt
zH2g}IN{%h1YG(ZiRGR2TJo!m7Q8?wjU)f{OC%DoHptL1+9N?J9RZ+ItYr&aS^PCXU
zA7it4c`=zJD^6dpfoodcmFE1`=l5rg4=b;n!iCCU;DJI!D7)j(HhaeOW=vbX2%C;d;hzXIv>BK`Jf}O%2k@%#vFwcpG56s6
z%g3-_3J9bITGy#*F(x0>C9;-fl80QM@Ue_vrPw73Nq*;236h)nnTbuNqDOnQruJ?PiYtRrb;Tdc
zzIf=cRuO3s>081YC)##X*$>Rv#2<8jH7H_)0su@Zd)2W4m>k@w*
z+|I3{QJs-W+~uPJxDqzghtdu$ch(q;wwqF)e}gQdt*LsK%-3VPmefgL>A4-_Itnt1
zrSiprhFjX-*^_?$gvef7N7}sJPFLzEX)Dvg*!CJkcHrQK7GCpv3mmWGc&n}A2yaw=
z^Vva)%-8&yRt}&y3ud+k0w8iQ;f>y^y!WtrMM;xkUVFpC=l22gMvN1wiZ@M@^+VE+
zbIcPpbz*A9l`gkq-lgjL!>-GIn8MjhjYWH+!o8JdR9zupTo^qz&$
z>dnaNnJ;ToP_!K8+2w33!!;xCGdI2XCp6yU`gPph1xkS_)mKkujg*^OnNA(b&`gT0
z-X4{Xddy{CDy_P<35q)FHQ#?NYNrsRa2N+k7uGo@M-O2#jQz|nFQ3K>*
zu6Ta@?pbu2B8y8zv|6|^jf9-VOOLvB6LuHFq3`hw2{Q%cedpkS+XBPj=5^WF%*#n!ya`bWd91W1JQ)0Dxvp4jYy4lIIFcRI9E9Q$H-^M?1`A>Fz8(%l)@qojx!oCfkgI!E=JM%++M~f5_
zdIeH%7uVBd3u(1`E>RP0UG0r2)@`bNtcX%=9vbX?eQM_96xLpkYcyHr@H#Ip-FzLZ
zh0mMeG$m!j^KBEnY`;aVr`MxS8-~yr%g_BiJfjW_2a#-#eUsYxqoXg|D^&7~V2Yndprg);9tsNr~hG)3Ob4R=)UacSA#hv-+G*p)Q>ds#x
z*kdkRf6G*8SnF#1tm#K(Gmy(ZxH~s(Ltg%@DSQiUx+F$njB)g5R?qpXY4^j7dN{sE
zyMUVkVjN$uJiJ;fBs}`iYHXS>F>)+x;U^B`~i(#v?Gdo|o?6GaK
z=bWweAFidxXSpP-{q%8uIEWlxmyYlR(xSEr*CU5r{_P41gQAKEG7r!Gfn-oYr6J42}p=kKUl7$K4SenG(?GCQ>wo>VJ4`;a;q`K8ic
z00t(S5$aiMh0+$8!Cp*UQ`1MBXb`*|UJ|wIB??lNW|T`muSWsi_xMT!m^Mvf7C{pDed8|X@cfIk5xy2j`!IZxWz`_r){M`YxJH-UcaI22c1Fs*bak
zJu0_jFXU6$+k8Hz*NNIo424;KNq|Vn#Zi<7ce*)16WX{;4DoUc53eEi_!=5HDgqxI
zjX^a_YEkx!(1Lb)Rs$O!8&5u-`INT+6|(xdC!rD)(hxNoHp_wI9BOI9w>jVxW0~Tn
ztjZ21bI#)cQ(fOxhSkuYC;PG}q_y;<4mLWUfeZME`DdJu#U5cDl@6&%SnuiEYhAqu
zv0oN&Bkipk)d&47?6-85V$(76^}%KX=p1=KOk#b$CVyarG}7+?A!q;*MmTwGpz*!M
z+*J@Z)|9~M5H@C|3?Dhk!x565MrDBi8%lM9TIf1gRZPB*Gcj>Lk;-~Tm$>4vheULZ
zlgN2EjRkrP>Y0a^UlEqjAZA3fcz~H>*q71BKcjcgC#7ca&f~SGYYg!Lle{jvrwgeq
z)R+upOck)>;uknrQ}Q)bO;|h8zgbdM*KCF-^_HRtb}Ov}Zgiq%Ch1mO+B<%^^L3Ye
zg9i|ypFPR3w6+<>@3-SY_J;oKiK=yh4g-H?0RW619ErV~&o=_Ic1EWld4)}_CrQd4
zin=7|=IA7*uvJIG(M}ywzx2w(8d08={nf@UyZ_Uht#v~&n2Bv88)HaH?~Fs8F&4IgrZ~~Vp?M`u-`7K`p`u)pw(#mvY@H8nJn^EqOV*G>i-}k&czAg$liTXt!
zvIuX{{ryV*m)D?Trchs*pA%CR9ucf{u|RL!S_6`Emd^U6#GV-svCJHevTnoqP4Y6;
zLykQ77-$6w7*-MW?3`-9du97sZuOw5?6l>ffkB0@FNFyVw3qkjjr-`$-gjs!Sg7hy
zH`KCCW(c8y?PTQ7E9*0vo`^b7G1P4f+k_)uuox*p$4eRU=e!d8@FIdgqleanR7i6-
zo(EY3PPgA9O#nnA`gKBo3U~mBl8rg`g$^p*f%%erMD*HOc=_G>q}J$G;=mY2
zx0F+qS~JEBLJb;-fXJ~;$ZMAM>DH3UXtZm6dw36j>dYPd%zZY=+asTTk@5C&o0g+{Bt|hF!t&LfPxRdy
z3+OxfdLI>9r~Vt!eDch~AwpKr*-$8}UX^UFyR5Xr7wXt+lL$-ed%$)?jr$v?B}^A}
z1%x@W#VI>qtri}Mauks2J6$H9Q-%a=T5C9h&$+(IRt4S%G}-=o@Bj=RU78hti{1MV
zQ$IhnmID(<4PB=$MUDy>rPNv&