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
6 changes: 6 additions & 0 deletions .agent/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` 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.
Expand Down
1 change: 0 additions & 1 deletion .agent/context/GENERAL.ctx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions OpenUtauMobile.Android/AndroidExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<MainActivity?> _getActivity;

public AndroidExternalUrlLauncher(Func<MainActivity?> getActivity)
{
_getActivity = getActivity;
}

public Task<ExternalUrlLaunchResult> LaunchAsync(Uri uri)
{
MainActivity? activity = _getActivity();
if (activity == null)
{
return Task.FromResult(ExternalUrlLaunchResult.Failed("The Android activity is unavailable."));
}

TaskCompletionSource<ExternalUrlLaunchResult> 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;
}
}
1 change: 1 addition & 0 deletions OpenUtauMobile.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions OpenUtauMobile.Browser/BrowserExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlLaunchResult> 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);
}
8 changes: 5 additions & 3 deletions OpenUtauMobile.Browser/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ private static async Task Main(string[] args)

try
{
await BuildAvaloniaApp()
AppBuilder appBuilder = BuildAvaloniaApp()
.WithInterFont()
.UseReactiveUI(reactiveUIBuilder =>
{
reactiveUIBuilder.WithExceptionHandler(Observer.Create<Exception>(HandleReactiveException));
})
.StartBrowserAppAsync("out");
});
await OpenUtauMobile.Browser.BrowserExternalUrlLauncher.InitializeAsync();
await appBuilder.StartBrowserAppAsync("out");
}
catch (Exception ex)
{
Expand Down Expand Up @@ -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<App>();
}
Expand Down
4 changes: 4 additions & 0 deletions OpenUtauMobile.Browser/wwwroot/external-url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function openExternalUrl(url) {
const openedWindow = globalThis.open(url, "_blank", "noopener,noreferrer");
return openedWindow !== null;
}
44 changes: 44 additions & 0 deletions OpenUtauMobile.Linux/LinuxExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlLaunchResult> 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));
}
}
1 change: 1 addition & 0 deletions OpenUtauMobile.Linux/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>()
Expand Down
30 changes: 30 additions & 0 deletions OpenUtauMobile.MacOS/MacOSExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlLaunchResult> 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));
}
}
}
1 change: 1 addition & 0 deletions OpenUtauMobile.MacOS/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>()
Expand Down
1 change: 1 addition & 0 deletions OpenUtauMobile.Windows/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions OpenUtauMobile.Windows/WindowsExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlLaunchResult> 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));
}
}
}
1 change: 1 addition & 0 deletions OpenUtauMobile.iOS/AppDelegate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_ =>
Expand Down
40 changes: 40 additions & 0 deletions OpenUtauMobile.iOS/IosExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlLaunchResult> LaunchAsync(Uri uri)
{
TaskCompletionSource<ExternalUrlLaunchResult> 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;
}
}
21 changes: 21 additions & 0 deletions OpenUtauMobile/Services/ExternalUrlLaunchResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace OpenUtauMobile.Services;

/// <summary>
/// 外部网页启动结果
/// </summary>
/// <param name="Succeeded">是否已交给平台处理</param>
/// <param name="ErrorMessage">失败原因</param>
public sealed record ExternalUrlLaunchResult(bool Succeeded, string? ErrorMessage)
{
public static ExternalUrlLaunchResult Success { get; } = new(true, null);

/// <summary>
/// 创建失败结果
/// </summary>
/// <param name="errorMessage">失败原因</param>
/// <returns>失败结果</returns>
public static ExternalUrlLaunchResult Failed(string errorMessage)
{
return new ExternalUrlLaunchResult(false, errorMessage);
}
}
32 changes: 32 additions & 0 deletions OpenUtauMobile/Services/ExternalUrlService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Threading.Tasks;

namespace OpenUtauMobile.Services;

/// <summary>
/// 外部网页链接服务
/// </summary>
public static class ExternalUrlService
{
/// <summary>
/// 验证并在平台默认浏览器中打开链接
/// </summary>
/// <param name="url">HTTP 或 HTTPS 链接</param>
/// <returns>启动结果</returns>
public static Task<ExternalUrlLaunchResult> 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);
}
}
17 changes: 17 additions & 0 deletions OpenUtauMobile/Services/IExternalUrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Threading.Tasks;

namespace OpenUtauMobile.Services;

/// <summary>
/// 打开外部网页链接
/// </summary>
public interface IExternalUrlLauncher
{
/// <summary>
/// 打开指定的网页链接
/// </summary>
/// <param name="uri">已验证的 HTTP 或 HTTPS 链接</param>
/// <returns>启动结果</returns>
Task<ExternalUrlLaunchResult> LaunchAsync(Uri uri);
}
1 change: 1 addition & 0 deletions OpenUtauMobile/Services/ServiceHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading