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/.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..e57f8ba
--- /dev/null
+++ b/.github/workflows/portable.yml
@@ -0,0 +1,198 @@
+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']
+ 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]
+ 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.')
+ 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:
+ 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: 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:
+ 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
+ ${{ runner.temp }}/filee-screens/
+ 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/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 `##
- 드래그로 쓰는 무료 오픈소스 Windows 파일 변환기.
- 키(기본 Ctrl)를 누른 채 파일을 끌면 커서 위치에 도넛이 열립니다. 원하는 형식 위에 놓으면 끝.
+ 드래그로 쓰는 무료 오픈소스 파일 변환기.
+ 키(Ctrl, macOS는 Option)를 누른 채 파일을 끌면 커서 위치에 도넛이 열려요. 원하는 형식 위에 놓으면 끝.
![]() |
+ ![]() |
+
| 파일 종류마다 도넛을 드래그 앤 드롭으로 배치 | +홈: 드롭 영역과 최근 변환 기록 | +
![]() |
+ ![]() |
+
| 프리셋 편집: 품질, 크기, 저장 위치, 파일 이름 | +큰 엔진은 처음 실행할 때 골라서 설치 | +
![]() |
- ![]() |
-
| 파일 종류마다 도넛을 드래그 앤 드롭으로 배치 | -홈: 드롭 영역과 최근 변환 기록 | -
![]() |
- ![]() |
-
| 프리셋 편집: 품질, 크기, 저장 위치, 파일 이름 | -큰 엔진은 처음 실행할 때 골라서 설치 | -
diff --git a/README.md b/README.md
index 585241a..8c9838f 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
- 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.
![]() |
+ ![]() |
+
| 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 | +
![]() |
- ![]() |
-
| 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 | -
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a85368b..65c66c1 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -3,8 +3,8 @@
- 用拖放操作的免费开源 Windows 文件格式转换器。
- 按住一个键(默认 Ctrl)拖动文件,光标处会弹出格式圆环,放到想要的格式上即可。
+ 用拖放操作的免费开源文件格式转换器。
+ 按住一个键(Ctrl,macOS 为 Option)拖动文件,光标处会弹出格式圆环,放到想要的格式上即可。
![]() |
+ ![]() |
+
| 拖放排列圆环,每种文件类型一个配置 | +主页:拖放区和最近转换记录 | +
![]() |
+ ![]() |
+
| 预设:质量、尺寸、PDF 页码、保存位置和文件名 | +深色主题 | +
![]() |
- ![]() |
-
| 拖放排列圆环,每种文件类型一个配置 | -主页:拖放区和最近转换记录 | -
![]() |
- ![]() |
-
| 预设:质量、尺寸、PDF 页码、保存位置和文件名 | -深色主题 | -
본문 <첫 줄>\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/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/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;
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/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/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);
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 me other echo 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..693000d
--- /dev/null
+++ b/tests/Filee.Platform.Tests/MacOSIntegrationTests.cs
@@ -0,0 +1,70 @@
+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 ServiceSwitchWritesTheEntrySystemSettingsUses()
+ {
+ 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 AppBundleDeclaresTheServiceFileeAnswers()
+ {
+ 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);
+ }
+
+ private static string RepositoryRoot()
+ {
+ 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]
+ 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);
+ }
+}