diff --git a/packages/experimental/cua-windows/native/entry/Applications.cs b/packages/experimental/cua-windows/native/entry/Applications.cs index aaf794d9d0..b1030bffe7 100644 --- a/packages/experimental/cua-windows/native/entry/Applications.cs +++ b/packages/experimental/cua-windows/native/entry/Applications.cs @@ -94,6 +94,39 @@ internal static class Applications ? @"\\" + resolved[8..] : resolved.StartsWith(@"\\?\", StringComparison.Ordinal) ? resolved[4..] : resolved; } + internal static string ResolveExecutable(string path) + { + try + { + if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\.\", StringComparison.Ordinal) + || path.StartsWith(@"\\?\", StringComparison.Ordinal) + || !string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase)) + throw new NativeFailure("INVALID_ARGUMENT", "Use an absolute .exe file path, not a command line or device path"); + if ((File.GetAttributes(path) & FileAttributes.Directory) != 0) + throw new NativeFailure("INVALID_ARGUMENT", "The selected executable path is a directory"); + var resolved = CanonicalPath(path); + if (!string.Equals(Path.GetExtension(resolved), ".exe", StringComparison.OrdinalIgnoreCase)) + throw new NativeFailure("INVALID_ARGUMENT", "The selected path must resolve to an .exe file"); + return resolved; + } + catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) + { + throw new NativeFailure("APP_NOT_FOUND", "The selected executable does not exist; provide an existing absolute .exe path"); + } + catch (UnauthorizedAccessException) + { + throw new NativeFailure("PERMISSION_DENIED", "Windows refused access to the selected executable"); + } + catch (Exception error) when (error is ArgumentException or NotSupportedException) + { + throw new NativeFailure("INVALID_ARGUMENT", "The executable path is invalid; provide an absolute .exe file path"); + } + catch (IOException) + { + throw new NativeFailure("APP_IDENTITY_UNAVAILABLE", "The selected executable could not be read; check the file before selecting it again"); + } + } + internal static string Digest(string path) { using var file = File.OpenRead(path); diff --git a/packages/experimental/cua-windows/native/entry/DesktopExecutor.cs b/packages/experimental/cua-windows/native/entry/DesktopExecutor.cs index 671dd91440..11b63019e0 100644 --- a/packages/experimental/cua-windows/native/entry/DesktopExecutor.cs +++ b/packages/experimental/cua-windows/native/entry/DesktopExecutor.cs @@ -101,7 +101,8 @@ internal sealed class DesktopExecutor(MtaDispatcher mta, StaDispatcher sta) : IA { var before = Windows.Refresh(app, context) .Select(window => (window.Handle, window.ProcessId, window.ProcessStarted)).ToHashSet(); - await sta.Invoke(() => ShellBroker.Launch(@"shell:AppsFolder\" + app.AppId), cancellation); + await sta.Invoke(() => ShellBroker.Launch(Path.IsPathFullyQualified(app.AppId) + ? app.AppId : @"shell:AppsFolder\" + app.AppId), cancellation); var elapsed = Stopwatch.StartNew(); WindowState[] windows; while (true) diff --git a/packages/experimental/cua-windows/native/entry/DesktopWindows.cs b/packages/experimental/cua-windows/native/entry/DesktopWindows.cs index 06b1dd68c7..48cfda6283 100644 --- a/packages/experimental/cua-windows/native/entry/DesktopWindows.cs +++ b/packages/experimental/cua-windows/native/entry/DesktopWindows.cs @@ -42,6 +42,16 @@ internal static class DesktopWindows internal static async Task Select(string selector, RequestContext context) { + context.Check(); + if (Path.IsPathFullyQualified(selector)) + { + var path = Applications.ResolveExecutable(selector); + var windows = Observe(context).Windows; + context.Check(); + // An unlisted executable discloses its full path in the consent question. + return new AppState(Guid.NewGuid().ToString("N"), path, Path.GetFileName(path), + ApplicationAuthorization.Create(path, path, path), windows.Any(window => string.Equals(window.AppId, path, StringComparison.Ordinal))); + } var applications = await Discover(context); var application = SelectApplication(applications, selector); context.Check(); @@ -107,10 +117,10 @@ internal static class DesktopWindows private static CatalogApplication SelectApplication(IReadOnlyList applications, string selector) { if (string.IsNullOrEmpty(selector)) - throw new NativeFailure("INVALID_ARGUMENT", "An exact application id from listApps is required"); + throw new NativeFailure("INVALID_ARGUMENT", "An exact application id from listApps or an absolute .exe file path is required"); foreach (var application in applications) if (string.Equals(application.AppId, selector, StringComparison.Ordinal)) return application; - throw new NativeFailure("APP_NOT_FOUND", "Use an exact application id from listApps"); + throw new NativeFailure("APP_NOT_FOUND", "Use an exact application id from listApps or an existing absolute .exe file path"); } private static CatalogApplication[] Merge(ShellApplication[] shellApplications, ResolvedWindow[] windows) diff --git a/packages/experimental/cua-windows/native/tests/Program.cs b/packages/experimental/cua-windows/native/tests/Program.cs index 48c7bb6005..695af5799a 100644 --- a/packages/experimental/cua-windows/native/tests/Program.cs +++ b/packages/experimental/cua-windows/native/tests/Program.cs @@ -21,6 +21,14 @@ Refuses(() => NativeRequest.Parse(WireJson.Parse("""{"id":"one","method":"getSta Refuses(() => NativeRequest.Parse(WireJson.Parse("""{"id":"one","method":"typeText","params":{"appHandle":"app","text":"hello","unowned":1}}""")), "INVALID_ARGUMENT"); Refuses(() => WireJson.Parse("""{"id":"one","id":"two"}"""), "INVALID_REQUEST"); Refuses(() => WireJson.Parse("""{"params":{"x":1,"x":2}}"""), "INVALID_REQUEST"); +foreach (var path in new[] { @"C:\Tools\Editor.exe", @"D:\Portable Apps\编辑器.exe", @"\\server\tools\Editor.exe" }) +{ + var selection = NativeRequest.Parse(WireJson.Parse(System.Text.Json.JsonSerializer.Serialize(new + { + id = "select", method = "getApp", @params = new { selector = path }, + }))); + Check(selection.Params.GetProperty("selector").GetString() == path, "Executable selection preserves the exact path on the wire"); +} var launch = NativeRequest.Parse(WireJson.Parse("""{"id":"launch","method":"launchApp","params":{"appHandle":"pending"}}""")); Check(launch.Method == "launchApp" && launch.Params.GetProperty("appHandle").GetString() == "pending", "Host launch continuation retains the selected application handle");