Skip to content

Commit db6c648

Browse files
committed
feat: add cross-platform GUI desktop app (Avalonia) with drag-drop, batch conversion, and NativeAOT release workflow
1 parent 6aecd83 commit db6c648

10 files changed

Lines changed: 567 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Release GUI AOT Binaries
2+
3+
on:
4+
release:
5+
types: [ published ]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build:
13+
if: startsWith(github.ref_name, 'gui-v') || github.event_name == 'workflow_dispatch'
14+
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
include:
19+
- { os: windows-latest, rid: win-x64, artifact: minipdf-gui-win-x64.zip }
20+
- { os: windows-latest, rid: win-arm64, artifact: minipdf-gui-win-arm64.zip }
21+
- { os: ubuntu-22.04, rid: linux-x64, artifact: minipdf-gui-linux-x64.tar.gz }
22+
- os: ubuntu-22.04
23+
rid: linux-arm64
24+
artifact: minipdf-gui-linux-arm64.tar.gz
25+
extra_args: -p:ObjCopyName=aarch64-linux-gnu-objcopy
26+
- { os: macos-latest, rid: osx-x64, artifact: minipdf-gui-osx-x64.tar.gz }
27+
- { os: macos-latest, rid: osx-arm64, artifact: minipdf-gui-osx-arm64.tar.gz }
28+
29+
runs-on: ${{ matrix.os }}
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: actions/setup-dotnet@v4
33+
with: { dotnet-version: '9.0.x' }
34+
35+
- name: Install cross-compilation toolchain (Linux ARM64)
36+
if: matrix.rid == 'linux-arm64'
37+
run: |
38+
sudo dpkg --add-architecture arm64
39+
sudo bash -c 'cat > /etc/apt/sources.list.d/arm64.list << EOF
40+
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs) main restricted universe multiverse
41+
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-updates main restricted universe multiverse
42+
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-security main restricted universe multiverse
43+
EOF'
44+
sudo sed -i 's/^deb \(http\|mirror\)/deb [arch=amd64] \1/g' /etc/apt/sources.list
45+
sudo sed -i 's/^deb \(http\|mirror\)/deb [arch=amd64] \1/g' /etc/apt/sources.list.d/*.list 2>/dev/null || true
46+
sudo apt-get update
47+
sudo apt-get install -y gcc-aarch64-linux-gnu zlib1g-dev:arm64
48+
49+
- name: Extract version from tag
50+
id: version
51+
shell: bash
52+
run: |
53+
VERSION="${GITHUB_REF_NAME#gui-v}"
54+
if [ -z "$VERSION" ] || [ "$VERSION" = "$GITHUB_REF_NAME" ]; then VERSION="0.0.1"; fi
55+
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
56+
57+
- name: Publish AOT
58+
run: >
59+
dotnet publish src/MiniPdf.Gui/MiniPdf.Gui.csproj
60+
-c Release -r ${{ matrix.rid }} -o publish
61+
-p:Version=${{ steps.version.outputs.VERSION }}
62+
${{ matrix.extra_args }}
63+
64+
- name: Package (Windows)
65+
if: runner.os == 'Windows'
66+
run: |
67+
Copy-Item publish/MiniPdf.Gui.exe publish/minipdf-gui.exe
68+
Compress-Archive -Path publish/minipdf-gui.exe -DestinationPath ${{ matrix.artifact }}
69+
70+
- name: Package (Linux/macOS)
71+
if: runner.os != 'Windows'
72+
run: |
73+
cp publish/MiniPdf.Gui publish/minipdf-gui
74+
chmod +x publish/minipdf-gui
75+
tar -czf ${{ matrix.artifact }} -C publish minipdf-gui
76+
77+
- name: Upload release asset
78+
if: github.event_name == 'release'
79+
uses: softprops/action-gh-release@v2
80+
with: { files: '${{ matrix.artifact }}' }
81+
82+
- name: Upload build artifact
83+
if: github.event_name == 'workflow_dispatch'
84+
uses: actions/upload-artifact@v4
85+
with: { name: '${{ matrix.artifact }}', path: '${{ matrix.artifact }}' }

MiniPdf.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniPdf.Tests", "tests\Mini
1313
EndProject
1414
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniPdf.Cli", "src\MiniPdf.Cli\MiniPdf.Cli.csproj", "{DCCAF065-1F7A-4525-AE0F-E508442E1D7C}"
1515
EndProject
16+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniPdf.Gui", "src\MiniPdf.Gui\MiniPdf.Gui.csproj", "{87E9EC72-E06F-423F-8103-B95050269F18}"
17+
EndProject
1618
Global
1719
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1820
Debug|Any CPU = Debug|Any CPU
@@ -59,6 +61,18 @@ Global
5961
{DCCAF065-1F7A-4525-AE0F-E508442E1D7C}.Release|x64.Build.0 = Release|Any CPU
6062
{DCCAF065-1F7A-4525-AE0F-E508442E1D7C}.Release|x86.ActiveCfg = Release|Any CPU
6163
{DCCAF065-1F7A-4525-AE0F-E508442E1D7C}.Release|x86.Build.0 = Release|Any CPU
64+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
65+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|Any CPU.Build.0 = Debug|Any CPU
66+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|x64.ActiveCfg = Debug|Any CPU
67+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|x64.Build.0 = Debug|Any CPU
68+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|x86.ActiveCfg = Debug|Any CPU
69+
{87E9EC72-E06F-423F-8103-B95050269F18}.Debug|x86.Build.0 = Debug|Any CPU
70+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|Any CPU.ActiveCfg = Release|Any CPU
71+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|Any CPU.Build.0 = Release|Any CPU
72+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|x64.ActiveCfg = Release|Any CPU
73+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|x64.Build.0 = Release|Any CPU
74+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|x86.ActiveCfg = Release|Any CPU
75+
{87E9EC72-E06F-423F-8103-B95050269F18}.Release|x86.Build.0 = Release|Any CPU
6276
EndGlobalSection
6377
GlobalSection(SolutionProperties) = preSolution
6478
HideSolutionNode = FALSE
@@ -67,5 +81,6 @@ Global
6781
{A8AA5506-CA13-4967-812B-9D74279B8977} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
6882
{2AEA72E4-ACB0-4C4F-B7BD-869D8258D379} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
6983
{DCCAF065-1F7A-4525-AE0F-E508442E1D7C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
84+
{87E9EC72-E06F-423F-8103-B95050269F18} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
7085
EndGlobalSection
7186
EndGlobal

src/MiniPdf.Gui/App.axaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Application xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
x:Class="MiniPdf.Gui.App"
4+
RequestedThemeVariant="Light">
5+
<Application.Styles>
6+
<FluentTheme />
7+
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
8+
</Application.Styles>
9+
</Application>

src/MiniPdf.Gui/App.axaml.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Avalonia;
2+
using Avalonia.Controls.ApplicationLifetimes;
3+
using Avalonia.Markup.Xaml;
4+
using MiniPdf.Gui.Views;
5+
6+
namespace MiniPdf.Gui;
7+
8+
public partial class App : Application
9+
{
10+
public override void Initialize()
11+
{
12+
AvaloniaXamlLoader.Load(this);
13+
}
14+
15+
public override void OnFrameworkInitializationCompleted()
16+
{
17+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
18+
{
19+
desktop.MainWindow = new MainWindow();
20+
}
21+
base.OnFrameworkInitializationCompleted();
22+
}
23+
}

src/MiniPdf.Gui/MiniPdf.Gui.csproj

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>WinExe</OutputType>
5+
<TargetFramework>net9.0</TargetFramework>
6+
<LangVersion>latest</LangVersion>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
<RollForward>LatestMajor</RollForward>
10+
<PublishAot>true</PublishAot>
11+
<StripSymbols>true</StripSymbols>
12+
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
13+
<ApplicationManifest>app.manifest</ApplicationManifest>
14+
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
15+
</PropertyGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="Avalonia" Version="11.2.7" />
19+
<PackageReference Include="Avalonia.Desktop" Version="11.2.7" />
20+
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7" />
21+
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.7" />
22+
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.7" />
23+
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
24+
</ItemGroup>
25+
26+
<ItemGroup>
27+
<ProjectReference Include="..\MiniPdf\MiniPdf.csproj" />
28+
</ItemGroup>
29+
30+
</Project>

src/MiniPdf.Gui/Program.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Avalonia;
2+
using System;
3+
4+
namespace MiniPdf.Gui;
5+
6+
sealed class Program
7+
{
8+
[STAThread]
9+
public static void Main(string[] args) => BuildAvaloniaApp()
10+
.StartWithClassicDesktopLifetime(args);
11+
12+
public static AppBuilder BuildAvaloniaApp()
13+
=> AppBuilder.Configure<App>()
14+
.UsePlatformDetect()
15+
.WithInterFont()
16+
.LogToTrace();
17+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using System;
2+
using System.Collections.ObjectModel;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using CommunityToolkit.Mvvm.ComponentModel;
7+
using CommunityToolkit.Mvvm.Input;
8+
using MiniSoftware;
9+
10+
namespace MiniPdf.Gui.ViewModels;
11+
12+
public partial class MainWindowViewModel : ObservableObject
13+
{
14+
[ObservableProperty]
15+
private string _statusText = "Ready";
16+
17+
[ObservableProperty]
18+
private bool _isConverting;
19+
20+
[ObservableProperty]
21+
private double _progress;
22+
23+
[ObservableProperty]
24+
private string _fontDirectory = string.Empty;
25+
26+
public ObservableCollection<FileItem> Files { get; } = new();
27+
28+
public MainWindowViewModel()
29+
{
30+
Files.CollectionChanged += (_, _) => ConvertAllCommand.NotifyCanExecuteChanged();
31+
}
32+
33+
public void AddFiles(params string[] paths)
34+
{
35+
foreach (var path in paths)
36+
{
37+
if (Directory.Exists(path))
38+
{
39+
var files = Directory.EnumerateFiles(path, "*.*", SearchOption.TopDirectoryOnly)
40+
.Where(f => IsSupportedFile(f));
41+
foreach (var file in files)
42+
AddFileIfNotExists(file);
43+
}
44+
else if (File.Exists(path) && IsSupportedFile(path))
45+
{
46+
AddFileIfNotExists(path);
47+
}
48+
}
49+
}
50+
51+
private void AddFileIfNotExists(string filePath)
52+
{
53+
if (Files.All(f => !string.Equals(f.InputPath, filePath, StringComparison.OrdinalIgnoreCase)))
54+
{
55+
Files.Add(new FileItem(filePath));
56+
}
57+
}
58+
59+
private static bool IsSupportedFile(string path)
60+
{
61+
var ext = Path.GetExtension(path);
62+
return ext.Equals(".docx", StringComparison.OrdinalIgnoreCase)
63+
|| ext.Equals(".xlsx", StringComparison.OrdinalIgnoreCase);
64+
}
65+
66+
[RelayCommand]
67+
private void RemoveFile(FileItem item)
68+
{
69+
Files.Remove(item);
70+
}
71+
72+
[RelayCommand]
73+
private void ClearFiles()
74+
{
75+
Files.Clear();
76+
StatusText = "Ready";
77+
Progress = 0;
78+
}
79+
80+
[RelayCommand(CanExecute = nameof(CanConvert))]
81+
private async Task ConvertAllAsync()
82+
{
83+
IsConverting = true;
84+
Progress = 0;
85+
86+
RegisterFonts();
87+
88+
var total = Files.Count;
89+
var completed = 0;
90+
91+
foreach (var file in Files)
92+
{
93+
if (file.Status == ConvertStatus.Done)
94+
{
95+
completed++;
96+
continue;
97+
}
98+
99+
file.Status = ConvertStatus.Converting;
100+
StatusText = $"Converting {file.FileName}...";
101+
102+
try
103+
{
104+
var outputPath = Path.ChangeExtension(file.InputPath, ".pdf");
105+
await Task.Run(() => MiniSoftware.MiniPdf.ConvertToPdf(file.InputPath, outputPath));
106+
file.OutputPath = outputPath;
107+
file.Status = ConvertStatus.Done;
108+
}
109+
catch (Exception ex)
110+
{
111+
file.Status = ConvertStatus.Error;
112+
file.ErrorMessage = ex.Message;
113+
}
114+
115+
completed++;
116+
Progress = (double)completed / total * 100;
117+
}
118+
119+
IsConverting = false;
120+
var errors = Files.Count(f => f.Status == ConvertStatus.Error);
121+
StatusText = errors > 0
122+
? $"Completed with {errors} error(s)"
123+
: $"All {total} file(s) converted successfully";
124+
}
125+
126+
private bool CanConvert() => Files.Count > 0 && !IsConverting;
127+
128+
partial void OnIsConvertingChanged(bool value) => ConvertAllCommand.NotifyCanExecuteChanged();
129+
130+
private void RegisterFonts()
131+
{
132+
if (string.IsNullOrWhiteSpace(FontDirectory) || !Directory.Exists(FontDirectory))
133+
return;
134+
135+
foreach (var fontFile in Directory.EnumerateFiles(FontDirectory, "*.*")
136+
.Where(f => f.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase)
137+
|| f.EndsWith(".ttc", StringComparison.OrdinalIgnoreCase)))
138+
{
139+
var name = Path.GetFileNameWithoutExtension(fontFile);
140+
MiniSoftware.MiniPdf.RegisterFont(name, File.ReadAllBytes(fontFile));
141+
}
142+
}
143+
}
144+
145+
public partial class FileItem : ObservableObject
146+
{
147+
public string InputPath { get; }
148+
public string FileName => Path.GetFileName(InputPath);
149+
public string FileType => Path.GetExtension(InputPath).TrimStart('.').ToUpperInvariant();
150+
151+
[ObservableProperty]
152+
private ConvertStatus _status = ConvertStatus.Pending;
153+
154+
[ObservableProperty]
155+
private string? _outputPath;
156+
157+
[ObservableProperty]
158+
private string? _errorMessage;
159+
160+
public FileItem(string inputPath) => InputPath = inputPath;
161+
}
162+
163+
public enum ConvertStatus
164+
{
165+
Pending,
166+
Converting,
167+
Done,
168+
Error
169+
}

0 commit comments

Comments
 (0)