Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Cross-platform desktop

on:
workflow_call:
inputs:
configuration:
type: string
default: Release
required: false
push:
branches: [main, dev]
paths: &paths
- 'PCL.Core/**'
- 'PCL.Core.SourceGenerators/**'
- 'PCL.Launcher.Core/**'
- 'PCL.Avalonia/**'
- 'PCL.CrossPlatform.Test/**'
- 'PCL.CrossPlatform.slnx'
- 'Plain Craft Launcher 2/metadata.json'
- '.github/workflows/cross-platform.yml'
pull_request:
paths: *paths
workflow_dispatch:

permissions:
contents: read

concurrency:
group: desktop-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
AVALONIA_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
BUILD_CONFIGURATION: ${{ inputs.configuration || 'Release' }}

jobs:
desktop:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
rids: 'linux-x64 linux-arm64'
- os: windows-latest
rids: 'win-x64 win-arm64'
- os: macos-latest
rids: 'osx-x64 osx-arm64'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
10.0.x
11.0.x
- name: Test portable core and headless desktop
run: dotnet test PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj -c CI --logger "trx;LogFileName=desktop.trx" --results-directory TestResults
- name: Publish desktop targets
shell: bash
run: |
for rid in ${{ matrix.rids }}; do
dotnet publish PCL.Avalonia/PCL.Avalonia.csproj -c "$BUILD_CONFIGURATION" -r "$rid" --self-contained true -o "artifact/$rid"
if [[ "$rid" == osx-* ]]; then
app="artifact/$rid/PCL CE.app/Contents"
mkdir -p "$app/MacOS" "$app/Resources"
for item in artifact/"$rid"/*; do
[[ "$item" == *.app ]] || mv "$item" "$app/MacOS/"
done
cp PCL.Avalonia/Info.plist "$app/Info.plist"
fi
tar -czf "artifact/PCL-CE-$rid.tar.gz" -C "artifact/$rid" .
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-${{ matrix.os }}
path: artifact/*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: desktop-tests-${{ matrix.os }}
path: TestResults/*.trx
45 changes: 45 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
run: |
version="${INPUT_VERSION:-${REF_NAME#v}}"
configuration="${INPUT_CONFIGURATION:-Release}"
[[ "$version" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo "Invalid release version" >&2; exit 1; }
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "configuration=$configuration" >> "$GITHUB_OUTPUT"

Expand All @@ -60,6 +61,12 @@ jobs:
architecture: ${{ matrix.architecture }}
secrets: inherit

desktop:
needs: [prepare]
uses: ./.github/workflows/cross-platform.yml
with:
configuration: ${{ needs.prepare.outputs.configuration }}

changelog:
needs: [prepare]
runs-on: ubuntu-latest
Expand Down Expand Up @@ -145,3 +152,41 @@ jobs:
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: v${{ needs.prepare.outputs.version }}

publish_desktop:
needs: [prepare, desktop, changelog]
permissions:
actions: read
contents: write
id-token: write
runs-on: ubuntu-latest
steps:
- name: Download desktop packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: desktop-*
merge-multiple: true
path: artifact
- name: Add release version to package names
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
for path in artifact/PCL-CE-*.tar.gz; do
name=$(basename "$path" .tar.gz)
mv "$path" "$name-$VERSION.tar.gz"
done
- name: Sign desktop packages
uses: sigstore/gh-action-sigstore-python@790bc6befb9d733738f18d8f895854b453640ec9 # v3.5.0
with:
inputs: PCL-CE-*.tar.gz
verify: true
verify-cert-identity: https://github.com/${{ github.repository }}/.github/workflows/release-publish.yml@${{ github.ref }}
verify-oidc-issuer: https://token.actions.githubusercontent.com
- name: Attach desktop packages to release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
draft: true
tag_name: v${{ needs.prepare.outputs.version }}
files: |
PCL-CE-*.tar.gz
PCL-CE-*.sigstore.json
10 changes: 10 additions & 0 deletions PCL.Avalonia/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PCL.Avalonia.App" RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
<Style Selector="Button"><Setter Property="Padding" Value="16,9" /></Style>
<Style Selector="TextBox"><Setter Property="MinHeight" Value="38" /></Style>
<Style Selector="TabItem"><Setter Property="Padding" Value="16,12" /></Style>
</Application.Styles>
</Application>
23 changes: 23 additions & 0 deletions PCL.Avalonia/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using PCL.Core.IO.Net;
using PCL.Core.Platform;

namespace PCL.Avalonia;

public sealed partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);

public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
NetworkService.Initialize(useConfiguration: false);
PlatformServices.UiDispatcher = new AvaloniaUiDispatcher();
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}
27 changes: 27 additions & 0 deletions PCL.Avalonia/AvaloniaUiDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Avalonia.Threading;
using PCL.Core.Platform;

namespace PCL.Avalonia;

/// <summary>Shared core dispatcher adapter, including nested frames for existing synchronous dialogs.</summary>
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
public bool CheckAccess() => Dispatcher.UIThread.CheckAccess();
public void Post(Action action) => Dispatcher.UIThread.Post(action);
public void Invoke(Action action)
{
if (CheckAccess()) action();
else Dispatcher.UIThread.Invoke(action);
}
public T? Invoke<T>(Func<T> func) => CheckAccess() ? func() : Dispatcher.UIThread.Invoke(func);
public async Task InvokeAsync(Action action) => await Dispatcher.UIThread.InvokeAsync(action);
public async Task<T> InvokeAsync<T>(Func<T> func) => await Dispatcher.UIThread.InvokeAsync(func);
public IUiModalFrame CreateModalFrame() => new ModalFrame();

private sealed class ModalFrame : IUiModalFrame
{
private readonly DispatcherFrame _frame = new();
public void Exit() => Dispatcher.UIThread.Post(() => _frame.Continue = false);
public void Run() => Dispatcher.UIThread.PushFrame(_frame);
}
}
69 changes: 69 additions & 0 deletions PCL.Avalonia/CustomHomeRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Xml;
using System.Xml.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;

namespace PCL.Avalonia;

/// <summary>A declarative subset of custom home XAML. It does not instantiate CLR types or evaluate markup extensions.</summary>
public static class CustomHomeRenderer
{
public static Control Render(string xml, Action<Uri> openLink)
{
using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, MaxCharactersInDocument = 2_000_000, XmlResolver = null });
var document = XDocument.Load(reader);
return Build(document.Root ?? throw new InvalidDataException("主页内容为空。"), openLink, 0);
}
private static Control Build(XElement element, Action<Uri> openLink, int depth)
{
if (depth > 40) throw new InvalidDataException("主页嵌套层级过多。");
if (element.Attributes().Any(a => a.Name.LocalName is "Class" or "Code" || a.Value.Contains('{')))
throw new InvalidDataException("主页不允许执行代码、绑定或标记扩展。");
string Value(string name, string fallback = "") => element.Attribute(name)?.Value ?? fallback;
Control control;
switch (element.Name.LocalName)
{
case "StackPanel": case "MyCard":
var panel = new StackPanel { Spacing = 10, Orientation = Value("Orientation") == "Horizontal" ? Orientation.Horizontal : Orientation.Vertical };
foreach (var child in element.Elements()) panel.Children.Add(Build(child, openLink, depth + 1));
control = panel; break;
case "WrapPanel":
var wrap = new WrapPanel { Orientation = Value("Orientation") == "Vertical" ? Orientation.Vertical : Orientation.Horizontal };
foreach (var child in element.Elements()) wrap.Children.Add(Build(child, openLink, depth + 1));
control = wrap; break;
case "Grid":
var grid = new Grid();
foreach (var definition in element.Elements().Where(e => e.Name.LocalName == "Grid.RowDefinitions").Elements())
grid.RowDefinitions.Add(new RowDefinition(GridLength.Parse(definition.Attribute("Height")?.Value ?? "*")));
foreach (var definition in element.Elements().Where(e => e.Name.LocalName == "Grid.ColumnDefinitions").Elements())
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Parse(definition.Attribute("Width")?.Value ?? "*")));
foreach (var child in element.Elements().Where(e => !e.Name.LocalName.Contains('.')))
{
var item = Build(child, openLink, depth + 1);
int Index(string attribute, int fallback) => int.TryParse(child.Attribute(attribute)?.Value, out var value) ? Math.Clamp(value, fallback, 1000) : fallback;
Grid.SetRow(item, Index("Grid.Row", 0)); Grid.SetColumn(item, Index("Grid.Column", 0));
Grid.SetRowSpan(item, Index("Grid.RowSpan", 1)); Grid.SetColumnSpan(item, Index("Grid.ColumnSpan", 1));
grid.Children.Add(item);
}
control = grid; break;
case "TextBlock": case "Label": case "Run":
control = new TextBlock { Text = Value("Text", Value("Content", element.Value)), TextWrapping = TextWrapping.Wrap, FontSize = double.TryParse(Value("FontSize"), System.Globalization.CultureInfo.InvariantCulture, out var fontSize) ? Math.Clamp(fontSize, 8, 80) : 15 }; break;
case "Button": case "MyButton":
var address = Value("NavigateUri", Value("EventData"));
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) throw new InvalidDataException("主页按钮只允许打开 HTTP(S) 链接。");
var button = new Button { Content = Value("Text", Value("Content", element.Value)) };
button.Click += (_, _) => openLink(uri); control = button; break;
case "Border":
var content = element.Elements().FirstOrDefault(); control = new Border { Padding = Thickness.Parse(Value("Padding", "14")), Child = content is null ? null : Build(content, openLink, depth + 1) }; break;
case "ScrollViewer":
var scrollContent = element.Elements().FirstOrDefault();
control = new ScrollViewer { Content = scrollContent is null ? null : Build(scrollContent, openLink, depth + 1) }; break;
case "Separator": control = new Separator(); break;
default: throw new InvalidDataException("尚不支持的主页控件:" + element.Name.LocalName);
}
control.Margin = Thickness.Parse(Value("Margin", "0"));
return control;
}
}
40 changes: 40 additions & 0 deletions PCL.Avalonia/DesktopLocalization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Globalization;
using System.Reflection;
using System.Xml.Linq;
using Avalonia.Threading;
using PCL.Core.App.Localization;
using PCL.Core.Platform;

namespace PCL.Avalonia;

/// <summary>Reads the existing language dictionaries as plain XML strings, without loading WPF XAML.</summary>
public sealed class DesktopLocalization : ILocalizationBackend
{
private readonly Dictionary<string, string> _strings = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _phrases = new(StringComparer.Ordinal);
public bool CheckAccess() => Dispatcher.UIThread.CheckAccess();
public void Invoke(Action action) => Dispatcher.UIThread.Invoke(action);
public string? TryFindResource(string key) => _strings.GetValueOrDefault(key);
public string Translate(string text) => _phrases.GetValueOrDefault(text) ?? text;
public void ApplyLanguageResources(string baseLanguageCode, string? currentLanguageCode)
{
var chinese = Read("zh-CN"); _strings.Clear(); foreach (var pair in Read(baseLanguageCode)) _strings[pair.Key] = pair.Value;
if (!string.IsNullOrWhiteSpace(currentLanguageCode)) foreach (var pair in Read(currentLanguageCode)) _strings[pair.Key] = pair.Value;
_phrases.Clear();
// Accept every dictionary's text as input, so switching languages repeatedly is reversible.
foreach (var source in new[] { chinese, Read("zh-TW"), Read("en-US"), Read("en-GB"), Read("ja-JP"), Read("fr-FR"), Read("es-ES") })
foreach (var (key, value) in source)
if (_strings.TryGetValue(key, out var translation)) _phrases.TryAdd(value, translation);
var language = currentLanguageCode ?? baseLanguageCode;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(language); CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture;
}
public void ApplyLaunchFont(string? customFontName, LocalizationLanguage? language) { }
private static Dictionary<string, string> Read(string language)
{
using var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("PCL.Languages." + language + ".xml");
if (input is null) return [];
return XDocument.Load(input).Root!.Elements().Where(element => element.Name.LocalName == "String")
.Where(element => element.Attribute("{http://schemas.microsoft.com/winfx/2006/xaml}Key") is not null)
.ToDictionary(element => element.Attribute("{http://schemas.microsoft.com/winfx/2006/xaml}Key")!.Value, element => element.Value);
}
}
36 changes: 36 additions & 0 deletions PCL.Avalonia/DesktopMediaPlayer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using LibVLCSharp.Shared;

namespace PCL.Avalonia;

/// <summary>Native media is loaded only when playback is requested, keeping headless/startup paths independent.</summary>
public sealed class DesktopMediaPlayer : IDisposable
{
private LibVLC? _vlc;
private MediaPlayer? _player;
private Media? _media;
public MediaPlayer Player => _player ?? throw new InvalidOperationException("播放器尚未初始化。");
public void Initialize(string? libraryDirectory)
{
if (_player is not null) return;
try
{
if (string.IsNullOrWhiteSpace(libraryDirectory)) LibVLCSharp.Shared.Core.Initialize();
else LibVLCSharp.Shared.Core.Initialize(libraryDirectory);
_vlc = new LibVLC("--no-video-title-show", "--no-metadata-network-access");
_player = new MediaPlayer(_vlc);
}
catch (Exception ex) { Dispose(); throw new InvalidOperationException("无法载入 VLC 播放库。请安装 VLC,或在外观设置中指定 libvlc 所在目录。", ex); }
}
public void Play(string file, int volume, bool loop)
{
if (_vlc is null || _player is null) throw new InvalidOperationException("播放器尚未初始化。");
_player.Stop(); _media?.Dispose(); _media = new Media(_vlc, new Uri(Path.GetFullPath(file)));
if (loop) _media.AddOption(":input-repeat=65535");
_player.Volume = Math.Clamp(volume, 0, 100);
if (!_player.Play(_media)) throw new InvalidOperationException("无法播放所选媒体。");
}
public void Pause() { if (_player is not null) _player.Pause(); }
public void Stop() { if (_player is not null) _player.Stop(); }
public void Volume(int value) { if (_player is not null) _player.Volume = Math.Clamp(value, 0, 100); }
public void Dispose() { _player?.Stop(); _player?.Dispose(); _player = null; _media?.Dispose(); _media = null; _vlc?.Dispose(); _vlc = null; }
}
Loading
Loading