diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md index 126605fd..7b71440b 100644 --- a/.agent/DECISIONS.md +++ b/.agent/DECISIONS.md @@ -12,6 +12,12 @@ Record meaningful technical decisions here. Use one entry per decision. ## Entries +- Date: 2026-08-29 +- Decision: Introduce an asynchronous Mobile-layer external URL launcher that accepts only absolute HTTP/HTTPS URLs and is injected through `ServiceHub`; implement native launchers for Android, Windows, Linux, macOS, iOS, and browser WASM. +- Rationale: A typed service keeps MVVM callers independent of platform APIs, reports launch failure without exceptions reaching commands, and fits the existing ServiceHub host-injection pattern. Restricting schemes to web URLs prevents About-page links from unexpectedly opening mail, telephone, or custom-scheme handlers. +- Alternatives considered: Put `OperatingSystem` branches in AboutViewModel; expose a raw `Action` delegate; allow every absolute URI scheme; use one shell command on every desktop platform. +- Impacted areas: Shared services, platform bootstraps, browser JS module, and About-page homepage/feedback actions. OpenUtau.Core is unchanged. + - Date: 2026-08-29 - Decision: Treat parameter-curve sample positions as voice-part-relative ticks, add the active part position only when mapping them into the absolute piano-roll canvas, and clip all parameter rendering and pointer edits to the active `UVoicePart` interval. - Rationale: `UCurve.xs` and `SetCurveCommand` use part-relative ticks, while the parameter canvas scroll offset uses absolute project ticks. Mixing those spaces made both curve rendering/pruning and edits diverge from synthesized output whenever the part position was nonzero, and allowed default/reference lines to extend beyond the part. diff --git a/.agent/context/GENERAL.ctx.md b/.agent/context/GENERAL.ctx.md index 511de93b..8dd83d4d 100644 --- a/.agent/context/GENERAL.ctx.md +++ b/.agent/context/GENERAL.ctx.md @@ -33,7 +33,6 @@ OpenUtau Mobile is a cross-platform mobile singing voice synthesis editor based - Expression parameter display and editing. - Rendered waveform display. - Deleting singers. -- Opening external URL links. - Intent filter: opening project/audio files. - Help/tutorial system. - Log export. diff --git a/OpenUtauMobile.Android/AndroidExternalUrlLauncher.cs b/OpenUtauMobile.Android/AndroidExternalUrlLauncher.cs new file mode 100644 index 00000000..e56f64aa --- /dev/null +++ b/OpenUtauMobile.Android/AndroidExternalUrlLauncher.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Android.Content; +using OpenUtauMobile.Services; + +namespace OpenUtauMobile.Android; + +internal sealed class AndroidExternalUrlLauncher : IExternalUrlLauncher +{ + private readonly Func _getActivity; + + public AndroidExternalUrlLauncher(Func getActivity) + { + _getActivity = getActivity; + } + + public Task LaunchAsync(Uri uri) + { + MainActivity? activity = _getActivity(); + if (activity == null) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed("The Android activity is unavailable.")); + } + + TaskCompletionSource completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + activity.RunOnUiThread(() => + { + try + { + Intent intent = new(Intent.ActionView, global::Android.Net.Uri.Parse(uri.AbsoluteUri)); + activity.StartActivity(intent); + completionSource.SetResult(ExternalUrlLaunchResult.Success); + } + catch (Exception exception) + { + completionSource.SetResult(ExternalUrlLaunchResult.Failed(exception.Message)); + } + }); + return completionSource.Task; + } +} diff --git a/OpenUtauMobile.Android/MainActivity.cs b/OpenUtauMobile.Android/MainActivity.cs index 0e4bc298..ac83506c 100644 --- a/OpenUtauMobile.Android/MainActivity.cs +++ b/OpenUtauMobile.Android/MainActivity.cs @@ -58,6 +58,7 @@ internal static AppBuilder ConfigureAppBuilder(AppBuilder builder) InitLogging(); InitExceptionHandler(); ServiceHub.InitAudioOutput = InitAudioOutput; // 设置初始化音频输出的委托 + ServiceHub.ExternalUrlLauncher = new AndroidExternalUrlLauncher(() => CurrentActivity); ServiceHub.ExternalStorageService = new Storage.AndroidExternalStorageService(() => CurrentActivity); // 设置外部存储服务 ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; diff --git a/OpenUtauMobile.Browser/BrowserExternalUrlLauncher.cs b/OpenUtauMobile.Browser/BrowserExternalUrlLauncher.cs new file mode 100644 index 00000000..e1dae18e --- /dev/null +++ b/OpenUtauMobile.Browser/BrowserExternalUrlLauncher.cs @@ -0,0 +1,35 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using System.Threading.Tasks; +using OpenUtauMobile.Services; + +namespace OpenUtauMobile.Browser; + +internal sealed partial class BrowserExternalUrlLauncher : IExternalUrlLauncher +{ + private const string ModuleName = "OpenUtauMobileExternalUrl"; + private const string ModulePath = "./external-url.js"; + + public static async Task InitializeAsync() + { + await JSHost.ImportAsync(ModuleName, ModulePath); + } + + public Task LaunchAsync(Uri uri) + { + try + { + bool opened = OpenExternalUrl(uri.AbsoluteUri); + return Task.FromResult(opened + ? ExternalUrlLaunchResult.Success + : ExternalUrlLaunchResult.Failed("The browser blocked the new tab.")); + } + catch (Exception exception) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed(exception.Message)); + } + } + + [JSImport("openExternalUrl", ModuleName)] + private static partial bool OpenExternalUrl(string url); +} \ No newline at end of file diff --git a/OpenUtauMobile.Browser/Program.cs b/OpenUtauMobile.Browser/Program.cs index fe96da8d..cfd56c35 100644 --- a/OpenUtauMobile.Browser/Program.cs +++ b/OpenUtauMobile.Browser/Program.cs @@ -33,13 +33,14 @@ private static async Task Main(string[] args) try { - await BuildAvaloniaApp() + AppBuilder appBuilder = BuildAvaloniaApp() .WithInterFont() .UseReactiveUI(reactiveUIBuilder => { reactiveUIBuilder.WithExceptionHandler(Observer.Create(HandleReactiveException)); - }) - .StartBrowserAppAsync("out"); + }); + await OpenUtauMobile.Browser.BrowserExternalUrlLauncher.InitializeAsync(); + await appBuilder.StartBrowserAppAsync("out"); } catch (Exception ex) { @@ -67,6 +68,7 @@ public static AppBuilder BuildAvaloniaApp() InitLogging(); InitExceptionHandler(); ServiceHub.InitAudioOutput = InitAudioOutput; + ServiceHub.ExternalUrlLauncher = new OpenUtauMobile.Browser.BrowserExternalUrlLauncher(); ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; return AppBuilder.Configure(); } diff --git a/OpenUtauMobile.Browser/wwwroot/external-url.js b/OpenUtauMobile.Browser/wwwroot/external-url.js new file mode 100644 index 00000000..bafcef1c --- /dev/null +++ b/OpenUtauMobile.Browser/wwwroot/external-url.js @@ -0,0 +1,4 @@ +export function openExternalUrl(url) { + const openedWindow = globalThis.open(url, "_blank", "noopener,noreferrer"); + return openedWindow !== null; +} diff --git a/OpenUtauMobile.Linux/LinuxExternalUrlLauncher.cs b/OpenUtauMobile.Linux/LinuxExternalUrlLauncher.cs new file mode 100644 index 00000000..a9184a81 --- /dev/null +++ b/OpenUtauMobile.Linux/LinuxExternalUrlLauncher.cs @@ -0,0 +1,44 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using OpenUtauMobile.Services; + +namespace OpenUtauMobile.Linux; + +internal sealed class LinuxExternalUrlLauncher : IExternalUrlLauncher +{ + private static readonly string[] LauncherCommands = ["xdg-open", "gio"]; + + public Task LaunchAsync(Uri uri) + { + Exception? lastException = null; + foreach (string launcherCommand in LauncherCommands) + { + try + { + ProcessStartInfo startInfo = new() + { + FileName = launcherCommand, + UseShellExecute = false + }; + if (launcherCommand == "gio") + { + startInfo.ArgumentList.Add("open"); + } + startInfo.ArgumentList.Add(uri.AbsoluteUri); + + Process? process = Process.Start(startInfo); + return Task.FromResult(process == null + ? ExternalUrlLaunchResult.Failed($"{launcherCommand} did not start.") + : ExternalUrlLaunchResult.Success); + } + catch (Exception exception) + { + lastException = exception; + } + } + + string errorMessage = lastException?.Message ?? "No desktop URL launcher is available."; + return Task.FromResult(ExternalUrlLaunchResult.Failed(errorMessage)); + } +} diff --git a/OpenUtauMobile.Linux/Program.cs b/OpenUtauMobile.Linux/Program.cs index c4863528..49b63125 100644 --- a/OpenUtauMobile.Linux/Program.cs +++ b/OpenUtauMobile.Linux/Program.cs @@ -29,6 +29,7 @@ public static AppBuilder BuildAvaloniaApp() InitLogging(); InitExceptionHandler(); ServiceHub.InitAudioOutput = InitAudioOutput; + ServiceHub.ExternalUrlLauncher = new LinuxExternalUrlLauncher(); ServiceHub.ExternalStorageService = new Storage.LinuxExternalStorageService(); ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; return AppBuilder.Configure() diff --git a/OpenUtauMobile.MacOS/MacOSExternalUrlLauncher.cs b/OpenUtauMobile.MacOS/MacOSExternalUrlLauncher.cs new file mode 100644 index 00000000..d2c6b198 --- /dev/null +++ b/OpenUtauMobile.MacOS/MacOSExternalUrlLauncher.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using OpenUtauMobile.Services; + +namespace OpenUtauMobile.MacOS; + +internal sealed class MacOSExternalUrlLauncher : IExternalUrlLauncher +{ + public Task LaunchAsync(Uri uri) + { + try + { + ProcessStartInfo startInfo = new() + { + FileName = "open", + UseShellExecute = false + }; + startInfo.ArgumentList.Add(uri.AbsoluteUri); + Process? process = Process.Start(startInfo); + return Task.FromResult(process == null + ? ExternalUrlLaunchResult.Failed("macOS did not start a URL handler.") + : ExternalUrlLaunchResult.Success); + } + catch (Exception exception) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed(exception.Message)); + } + } +} diff --git a/OpenUtauMobile.MacOS/Program.cs b/OpenUtauMobile.MacOS/Program.cs index 6e873f62..1c0bb166 100644 --- a/OpenUtauMobile.MacOS/Program.cs +++ b/OpenUtauMobile.MacOS/Program.cs @@ -28,6 +28,7 @@ public static AppBuilder BuildAvaloniaApp() InitLogging(); InitExceptionHandler(); ServiceHub.InitAudioOutput = InitAudioOutput; + ServiceHub.ExternalUrlLauncher = new MacOSExternalUrlLauncher(); ServiceHub.ExternalStorageService = new Storage.MacOSExternalStorageService(); ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; return AppBuilder.Configure() diff --git a/OpenUtauMobile.Windows/Program.cs b/OpenUtauMobile.Windows/Program.cs index 6e4f83fb..505252a1 100644 --- a/OpenUtauMobile.Windows/Program.cs +++ b/OpenUtauMobile.Windows/Program.cs @@ -30,6 +30,7 @@ public static AppBuilder BuildAvaloniaApp() InitLogging(); InitExceptionHandler(); ServiceHub.InitAudioOutput = InitAudioOutput; + ServiceHub.ExternalUrlLauncher = new WindowsExternalUrlLauncher(); ServiceHub.ExternalStorageService = new Storage.WindowsExternalStorageService(); ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; ServiceHub.PlatformPerformanceProvider = new WindowsPerformanceProvider(); diff --git a/OpenUtauMobile.Windows/WindowsExternalUrlLauncher.cs b/OpenUtauMobile.Windows/WindowsExternalUrlLauncher.cs new file mode 100644 index 00000000..8fe3cfb4 --- /dev/null +++ b/OpenUtauMobile.Windows/WindowsExternalUrlLauncher.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using OpenUtauMobile.Services; + +namespace OpenUtauMobile.Windows; + +internal sealed class WindowsExternalUrlLauncher : IExternalUrlLauncher +{ + public Task LaunchAsync(Uri uri) + { + try + { + ProcessStartInfo startInfo = new() + { + FileName = uri.AbsoluteUri, + UseShellExecute = true + }; + Process? process = Process.Start(startInfo); + return Task.FromResult(process == null + ? ExternalUrlLaunchResult.Failed("The Windows shell did not start a URL handler.") + : ExternalUrlLaunchResult.Success); + } + catch (Exception exception) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed(exception.Message)); + } + } +} diff --git a/OpenUtauMobile.iOS/AppDelegate.cs b/OpenUtauMobile.iOS/AppDelegate.cs index b9602b4c..5197f6ba 100644 --- a/OpenUtauMobile.iOS/AppDelegate.cs +++ b/OpenUtauMobile.iOS/AppDelegate.cs @@ -27,6 +27,7 @@ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // 注册编码提供程序以支持更多编码格式 InitLogging(); // TODO: iOS尚未实现音频输出 + ServiceHub.ExternalUrlLauncher = new IosExternalUrlLauncher(); ServiceHub.TryGetPlatformAccentFallback = TryGetPlatformAccentFallback; return base.CustomizeAppBuilder(builder) .UseReactiveUI(_ => diff --git a/OpenUtauMobile.iOS/IosExternalUrlLauncher.cs b/OpenUtauMobile.iOS/IosExternalUrlLauncher.cs new file mode 100644 index 00000000..cf45c581 --- /dev/null +++ b/OpenUtauMobile.iOS/IosExternalUrlLauncher.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using Foundation; +using OpenUtauMobile.Services; +using UIKit; + +namespace OpenUtauMobile.iOS; + +internal sealed class IosExternalUrlLauncher : IExternalUrlLauncher +{ + public Task LaunchAsync(Uri uri) + { + TaskCompletionSource completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + UIApplication.SharedApplication.BeginInvokeOnMainThread(async () => + { + try + { + NSUrl? nativeUrl = NSUrl.FromString(uri.AbsoluteUri); + if (nativeUrl == null) + { + completionSource.SetResult(ExternalUrlLaunchResult.Failed("iOS could not create the URL.")); + return; + } + + bool opened = await UIApplication.SharedApplication.OpenUrlAsync( + nativeUrl, + new UIApplicationOpenUrlOptions()); + completionSource.SetResult(opened + ? ExternalUrlLaunchResult.Success + : ExternalUrlLaunchResult.Failed("iOS did not accept the URL.")); + } + catch (Exception exception) + { + completionSource.SetResult(ExternalUrlLaunchResult.Failed(exception.Message)); + } + }); + return completionSource.Task; + } +} diff --git a/OpenUtauMobile/Services/ExternalUrlLaunchResult.cs b/OpenUtauMobile/Services/ExternalUrlLaunchResult.cs new file mode 100644 index 00000000..bb06fe24 --- /dev/null +++ b/OpenUtauMobile/Services/ExternalUrlLaunchResult.cs @@ -0,0 +1,21 @@ +namespace OpenUtauMobile.Services; + +/// +/// 外部网页启动结果 +/// +/// 是否已交给平台处理 +/// 失败原因 +public sealed record ExternalUrlLaunchResult(bool Succeeded, string? ErrorMessage) +{ + public static ExternalUrlLaunchResult Success { get; } = new(true, null); + + /// + /// 创建失败结果 + /// + /// 失败原因 + /// 失败结果 + public static ExternalUrlLaunchResult Failed(string errorMessage) + { + return new ExternalUrlLaunchResult(false, errorMessage); + } +} diff --git a/OpenUtauMobile/Services/ExternalUrlService.cs b/OpenUtauMobile/Services/ExternalUrlService.cs new file mode 100644 index 00000000..b00de8fc --- /dev/null +++ b/OpenUtauMobile/Services/ExternalUrlService.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace OpenUtauMobile.Services; + +/// +/// 外部网页链接服务 +/// +public static class ExternalUrlService +{ + /// + /// 验证并在平台默认浏览器中打开链接 + /// + /// HTTP 或 HTTPS 链接 + /// 启动结果 + public static Task OpenAsync(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed("Only HTTP and HTTPS URLs are supported.")); + } + + IExternalUrlLauncher? launcher = ServiceHub.ExternalUrlLauncher; + if (launcher == null) + { + return Task.FromResult(ExternalUrlLaunchResult.Failed("External URL launching is unavailable on this platform.")); + } + + return launcher.LaunchAsync(uri); + } +} diff --git a/OpenUtauMobile/Services/IExternalUrlLauncher.cs b/OpenUtauMobile/Services/IExternalUrlLauncher.cs new file mode 100644 index 00000000..0203e396 --- /dev/null +++ b/OpenUtauMobile/Services/IExternalUrlLauncher.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace OpenUtauMobile.Services; + +/// +/// 打开外部网页链接 +/// +public interface IExternalUrlLauncher +{ + /// + /// 打开指定的网页链接 + /// + /// 已验证的 HTTP 或 HTTPS 链接 + /// 启动结果 + Task LaunchAsync(Uri uri); +} diff --git a/OpenUtauMobile/Services/ServiceHub.cs b/OpenUtauMobile/Services/ServiceHub.cs index 4953dda5..efc7c5fa 100644 --- a/OpenUtauMobile/Services/ServiceHub.cs +++ b/OpenUtauMobile/Services/ServiceHub.cs @@ -12,6 +12,7 @@ namespace OpenUtauMobile.Services; public static class ServiceHub { public static Action? InitAudioOutput { get; set; } + public static IExternalUrlLauncher? ExternalUrlLauncher { get; set; } public static IExternalStorageService? ExternalStorageService { get; set; } public static ISystemAccentColorProvider? SystemAccentColorProvider { get; set; } public static Func<(bool success, Color color, string source)>? TryGetPlatformAccentFallback { get; set; } diff --git a/OpenUtauMobile/ViewModels/AboutViewModel.cs b/OpenUtauMobile/ViewModels/AboutViewModel.cs index 0309fdc0..ef44ab76 100644 --- a/OpenUtauMobile/ViewModels/AboutViewModel.cs +++ b/OpenUtauMobile/ViewModels/AboutViewModel.cs @@ -25,11 +25,14 @@ public class AboutViewModel : NavigateViewModelBase public AboutViewModel(MainViewModel navigator) : base(navigator) { BackCommand = ReactiveCommand.Create(OnBack); - OpenHomepageCommand = ReactiveCommand.Create(() => OpenUrl("https://github.com/vocoder712/OpenUtauMobile")); - OpenLicenseCommand = ReactiveCommand.Create(() => ToastService.Enqueue(L.S("About.Toast.LicenseNotImpl"))); - OpenCreditsCommand = ReactiveCommand.Create(() => ToastService.Enqueue(L.S("About.Toast.CreditsNotImpl"))); - OpenFeedbackCommand = - ReactiveCommand.Create(() => OpenUrl("https://github.com/vocoder712/OpenUtauMobile/issues")); + OpenHomepageCommand = ReactiveCommand.CreateFromTask( + () => OpenUrlAsync("https://github.com/vocoder712/OpenUtauMobile")); + OpenLicenseCommand = ReactiveCommand.CreateFromTask( + () => OpenUrlAsync("https://github.com/vocoder712/OpenUtauMobile/blob/dev/LICENSE")); + OpenCreditsCommand = ReactiveCommand.CreateFromTask( + () => OpenUrlAsync("https://github.com/vocoder712/OpenUtauMobile/graphs/contributors?all=1")); + OpenFeedbackCommand = ReactiveCommand.CreateFromTask( + () => OpenUrlAsync("https://github.com/vocoder712/OpenUtauMobile/issues")); // 使用 typeof(...).Assembly 替代 GetEntryAssembly(), // 因为在 Android 等平台上 GetEntryAssembly() 可能无法正确识别入口程序集 @@ -58,14 +61,10 @@ private void OnBack() Navigator.NavigateBack(this); } - private static void OpenUrl(string url) + private static async System.Threading.Tasks.Task OpenUrlAsync(string url) { - try - { - // TODO: 打开外部链接功能尚未实现 - ToastService.Enqueue(L.S("About.Toast.OpenLinkNotImpl")); - } - catch + ExternalUrlLaunchResult result = await ExternalUrlService.OpenAsync(url); + if (!result.Succeeded) { ToastService.Enqueue(L.S("About.Toast.OpenLinkFailed")); } diff --git a/OpenUtauMobile/Views/AboutView.axaml b/OpenUtauMobile/Views/AboutView.axaml index 4d505a84..c6165506 100644 --- a/OpenUtauMobile/Views/AboutView.axaml +++ b/OpenUtauMobile/Views/AboutView.axaml @@ -9,10 +9,6 @@ x:Class="OpenUtauMobile.Views.AboutView" x:DataType="vm:AboutViewModel"> - - M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z - - @@ -137,10 +133,17 @@ - + + + - + + + - + + + - + + +