Browse Source

Implement TopLevel.Launcher (#14320)

* Implement ILauncher

* Update dialogs page to include Launcher buttons

* Fix control catalog

* Add return comments
Max Katz 1 year ago
parent
commit
df4189ce0e

+ 9 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml

@@ -51,6 +51,15 @@
         <Button Name="OpenBoth">Select _Both</Button>
       </StackPanel>
     </Expander>
+    
+    <Expander Header="Launcher dialogs">
+      <StackPanel Spacing="4">
+        <TextBox Name="UriToLaunch" Watermark="Uri to launch" Text="https://avaloniaui.net/" />
+        <Button Name="LaunchUri">Launch Uri</Button>
+        <Button Name="LaunchFile">Launch File</Button>
+        <TextBlock Name="LaunchStatus" />
+      </StackPanel>
+    </Expander>
 
     <AutoCompleteBox x:Name="CurrentFolderBox" Watermark="Write full path/uri or well known folder name">
       <AutoCompleteBox.ItemsSource>

+ 31 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@@ -25,6 +25,7 @@ namespace ControlCatalog.Pages
             this.InitializeComponent();
 
             IStorageFolder? lastSelectedDirectory = null;
+            IStorageItem? lastSelectedItem = null;
             bool ignoreTextChanged = false;
 
             var results = this.Get<ItemsControl>("PickerLastResults");
@@ -290,11 +291,40 @@ namespace ControlCatalog.Pages
                 await SetPickerResult(folder is null ? null : new[] { folder });
                 SetFolder(folder);
             };
+            
+            this.Get<Button>("LaunchUri").Click += async delegate
+            {
+                var statusBlock = this.Get<TextBlock>("LaunchStatus");
+                if (Uri.TryCreate(this.Get<TextBox>("UriToLaunch").Text, UriKind.Absolute, out var uri))
+                {
+                    var result = await TopLevel.GetTopLevel(this)!.Launcher.LaunchUriAsync(uri);
+                    statusBlock.Text = "LaunchUriAsync returned " + result;
+                }
+                else
+                {
+                    statusBlock.Text = "Can't parse the Uri";
+                }
+            };
+
+            this.Get<Button>("LaunchFile").Click += async delegate
+            {
+                var statusBlock = this.Get<TextBlock>("LaunchStatus");
+                if (lastSelectedItem is not null)
+                {
+                    var result = await TopLevel.GetTopLevel(this)!.Launcher.LaunchFileAsync(lastSelectedItem);
+                    statusBlock.Text = "LaunchFileAsync returned " + result;
+                }
+                else
+                {
+                    statusBlock.Text = "Please select any file or folder first";
+                }
+            };
 
             void SetFolder(IStorageFolder? folder)
             {
                 ignoreTextChanged = true;
                 lastSelectedDirectory = folder;
+                lastSelectedItem = folder;
                 currentFolderBox.Text = folder?.Path is { IsAbsoluteUri: true } abs ? abs.LocalPath : folder?.Path?.ToString();
                 ignoreTextChanged = false;
             }
@@ -344,6 +374,7 @@ namespace ControlCatalog.Pages
                             }
                         }
                     }
+                    lastSelectedItem = item;
                 }
 
                 results.ItemsSource = mappedResults;

+ 56 - 0
src/Android/Avalonia.Android/Platform/AndroidLauncher.cs

@@ -0,0 +1,56 @@
+using System;
+using System.Threading.Tasks;
+using Android.Content;
+using Avalonia.Android.Platform.Storage;
+using Avalonia.Platform.Storage;
+using AndroidUri = Android.Net.Uri;
+
+namespace Avalonia.Android.Platform;
+
+internal class AndroidLauncher : ILauncher
+{
+    private readonly Context _context;
+
+    public AndroidLauncher(Context context)
+    {
+        _context = context;
+    }
+    
+    public Task<bool> LaunchUriAsync(Uri uri)
+    {
+        _ = uri ?? throw new ArgumentNullException(nameof(uri));
+        if (uri.IsAbsoluteUri && _context.PackageManager is { } packageManager)
+        {
+            var intent = new Intent(Intent.ActionView, AndroidUri.Parse(uri.OriginalString));
+            if (intent.ResolveActivity(packageManager) is not null)
+            {
+                var flags = ActivityFlags.ClearTop | ActivityFlags.NewTask;
+                intent.SetFlags(flags);
+                _context.StartActivity(intent);
+            }
+        }
+        return Task.FromResult(false);
+    }
+
+    public Task<bool> LaunchFileAsync(IStorageItem storageItem)
+    {
+        _ = storageItem ?? throw new ArgumentNullException(nameof(storageItem));
+        var androidUri = (storageItem as AndroidStorageItem)?.Uri
+            ?? (storageItem.TryGetLocalPath() is { } localPath ? AndroidUri.Parse(localPath) : null);
+
+        if (androidUri is not null && _context.PackageManager is { } packageManager)
+        {
+            var intent = new Intent(Intent.ActionView, androidUri);
+            // intent.SetDataAndType(contentUri, request.File.ContentType);
+            intent.SetFlags(ActivityFlags.GrantReadUriPermission);
+            if (intent.ResolveActivity(packageManager) is not null
+                && Intent.CreateChooser(intent, string.Empty) is { } chooserIntent)
+            {
+                var flags = ActivityFlags.ClearTop | ActivityFlags.NewTask;
+                chooserIntent.SetFlags(flags);
+                _context.StartActivity(chooserIntent);
+            }
+        }
+        return Task.FromResult(false);
+    }
+}

+ 7 - 0
src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs

@@ -44,6 +44,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
         private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager;
         private readonly AndroidInsetsManager _insetsManager;
         private readonly ClipboardImpl _clipboard;
+        private readonly AndroidLauncher _launcher;
         private ViewImpl _view;
         private WindowTransparencyLevel _transparencyLevel;
 
@@ -70,6 +71,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
             _nativeControlHost = new AndroidNativeControlHostImpl(avaloniaView);
             _storageProvider = new AndroidStorageProvider((Activity)avaloniaView.Context);
             _transparencyLevel = WindowTransparencyLevel.None;
+            _launcher = new AndroidLauncher((Activity)avaloniaView.Context);
 
             _systemNavigationManager = new AndroidSystemNavigationManagerImpl(avaloniaView.Context as IActivityNavigationService);
 
@@ -404,6 +406,11 @@ namespace Avalonia.Android.Platform.SkiaPlatform
                 return _clipboard;
             }
 
+            if (featureType == typeof(ILauncher))
+            {
+                return _launcher;
+            }
+
             return null;
         }
 

+ 90 - 0
src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs

@@ -0,0 +1,90 @@
+using System;
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Avalonia.Compatibility;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage.FileIO;
+
+internal class BclLauncher : ILauncher
+{
+    public virtual Task<bool> LaunchUriAsync(Uri uri)
+    {
+        _ = uri ?? throw new ArgumentNullException(nameof(uri));
+        if (uri.IsAbsoluteUri)
+        {
+            return Task.FromResult(Exec(uri.AbsoluteUri));
+        }
+
+        return Task.FromResult(false);
+    }
+
+    /// <summary>
+    /// This Process based implementation doesn't handle the case, when there is no app to handle link.
+    /// It will still return true in this case.
+    /// </summary>
+    public virtual Task<bool> LaunchFileAsync(IStorageItem storageItem)
+    {
+        _ = storageItem ?? throw new ArgumentNullException(nameof(storageItem));
+        if (storageItem.TryGetLocalPath() is { } localPath
+            && CanOpenFileOrDirectory(localPath))
+        {
+            return Task.FromResult(Exec(localPath));
+        }
+
+        return Task.FromResult(false);
+    }
+
+    protected virtual bool CanOpenFileOrDirectory(string localPath) => true;
+    
+    private static bool Exec(string urlOrFile)
+    {
+        if (OperatingSystemEx.IsLinux())
+        {
+            // If no associated application/json MimeType is found xdg-open opens return error
+            // but it tries to open it anyway using the console editor (nano, vim, other..)
+            ShellExec($"xdg-open {urlOrFile}", waitForExit: false);
+            return true;
+        }
+        else if (OperatingSystemEx.IsWindows() || OperatingSystemEx.IsMacOS())
+        {
+            using var process = Process.Start(new ProcessStartInfo
+            {
+                FileName = OperatingSystemEx.IsWindows() ? urlOrFile : "open",
+                Arguments = OperatingSystemEx.IsMacOS() ? $"{urlOrFile}" : "",
+                CreateNoWindow = true,
+                UseShellExecute = OperatingSystemEx.IsWindows()
+            });
+            return true;
+        }
+        else
+        {
+            return false;
+        }
+    }
+
+    private static void ShellExec(string cmd, bool waitForExit = true)
+    {
+        var escapedArgs = Regex.Replace(cmd, "(?=[`~!#&*()|;'<>])", "\\")
+            .Replace("\"", "\\\\\\\"");
+
+        using (var process = Process.Start(
+                   new ProcessStartInfo
+                   {
+                       FileName = "/bin/sh",
+                       Arguments = $"-c \"{escapedArgs}\"",
+                       RedirectStandardOutput = true,
+                       UseShellExecute = false,
+                       CreateNoWindow = true,
+                       WindowStyle = ProcessWindowStyle.Hidden
+                   }
+               ))
+        {
+            if (waitForExit)
+            {
+                process?.WaitForExit();
+            }
+        }
+    }
+}

+ 69 - 0
src/Avalonia.Base/Platform/Storage/ILauncher.cs

@@ -0,0 +1,69 @@
+using System;
+using System.IO;
+using System.Runtime.Versioning;
+using System.Threading.Tasks;
+using Avalonia.Platform.Storage;
+using Avalonia.Platform.Storage.FileIO;
+
+namespace Avalonia.Platform.Storage;
+
+/// <summary>
+/// Starts the default app associated with the specified file or URI.
+/// </summary>
+public interface ILauncher
+{
+    /// <summary>
+    /// Starts the default app associated with the URI scheme name for the specified URI.
+    /// </summary>
+    /// <param name="uri">The URI.</param>
+    /// <returns>True, if launch operation was successful. False, if unsupported or failed.</returns>
+    Task<bool> LaunchUriAsync(Uri uri);
+
+    /// <summary>
+    /// Starts the default app associated with the specified storage file or folder.
+    /// </summary>
+    /// <param name="storageItem">The file or folder.</param>
+    /// <returns>True, if launch operation was successful. False, if unsupported or failed.</returns>
+    Task<bool> LaunchFileAsync(IStorageItem storageItem);
+}
+
+internal class NoopLauncher : ILauncher
+{
+    public Task<bool> LaunchUriAsync(Uri uri) => Task.FromResult(false); 
+    public Task<bool> LaunchFileAsync(IStorageItem storageItem) => Task.FromResult(false);
+} 
+
+public static class LauncherExtensions
+{
+    /// <summary>
+    /// Starts the default app associated with the specified storage file.
+    /// </summary>
+    /// <param name="launcher">ILauncher instance.</param>
+    /// <param name="fileInfo">The file.</param>
+    public static Task<bool> LaunchFileInfoAsync(this ILauncher launcher, FileInfo fileInfo)
+    {
+        _ = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo));
+        if (!fileInfo.Exists)
+        {
+            return Task.FromResult(false);
+        }
+
+        return launcher.LaunchFileAsync(new BclStorageFile(fileInfo));
+    }
+
+    /// <summary>
+    /// Starts the default app associated with the specified storage directory (folder).
+    /// </summary>
+    /// <param name="launcher">ILauncher instance.</param>
+    /// <param name="directoryInfo">The directory.</param>
+    public static Task<bool> LaunchDirectoryInfoAsync(this ILauncher launcher, DirectoryInfo directoryInfo)
+    {
+        _ = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
+        if (!directoryInfo.Exists)
+        {
+            return Task.FromResult(false);
+        }
+
+        return launcher.LaunchFileAsync(new BclStorageFolder(directoryInfo));
+    }
+}

+ 2 - 1
src/Avalonia.Controls/TopLevel.cs

@@ -549,6 +549,7 @@ namespace Avalonia.Controls
 
         public IInsetsManager? InsetsManager => PlatformImpl?.TryGetFeature<IInsetsManager>();
         public IInputPane? InputPane => PlatformImpl?.TryGetFeature<IInputPane>();
+        public ILauncher Launcher => PlatformImpl?.TryGetFeature<ILauncher>() ?? new NoopLauncher();
 
         /// <summary>
         /// Gets the platform's clipboard implementation
@@ -560,7 +561,7 @@ namespace Avalonia.Controls
 
         /// <inheritdoc />
         public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService<IPlatformSettings>();
-        
+
         /// <inheritdoc/>
         Point IRenderRoot.PointToClient(PixelPoint p)
         {

+ 3 - 47
src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs

@@ -1,7 +1,4 @@
 using System;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Text.RegularExpressions;
 using Avalonia.Controls;
 using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
@@ -24,51 +21,10 @@ namespace Avalonia.Dialogs
             DataContext = this;
         }
 
-        
-
-        private static void ShellExec(string cmd, bool waitForExit = true)
-        {
-            var escapedArgs = Regex.Replace(cmd, "(?=[`~!#&*()|;'<>])", "\\")
-                .Replace("\"", "\\\\\\\"");
-
-            using (var process = Process.Start(
-                new ProcessStartInfo
-                {
-                    FileName = "/bin/sh",
-                    Arguments = $"-c \"{escapedArgs}\"",
-                    RedirectStandardOutput = true,
-                    UseShellExecute = false,
-                    CreateNoWindow = true,
-                    WindowStyle = ProcessWindowStyle.Hidden
-                }
-            ))
-            {
-                if (waitForExit)
-                {
-                    process?.WaitForExit();
-                }
-            }
-        }
-
-        private void Button_OnClick(object sender, RoutedEventArgs e)
+        private async void Button_OnClick(object sender, RoutedEventArgs e)
         {
-            var url = "https://www.avaloniaui.net/";
-            if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
-            {
-                // If no associated application/json MimeType is found xdg-open opens retrun error
-                // but it tries to open it anyway using the console editor (nano, vim, other..)
-                ShellExec($"xdg-open {url}", waitForExit: false);
-            }
-            else
-            {
-                using Process? process = Process.Start(new ProcessStartInfo
-                {
-                    FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? url : "open",
-                    Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? $"{url}" : "",
-                    CreateNoWindow = true,
-                    UseShellExecute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
-                });
-            }
+            var url = new Uri("https://www.avaloniaui.net/");
+            await Launcher.LaunchUriAsync(url);
         }
     }
 }

+ 6 - 0
src/Avalonia.Native/WindowImplBase.cs

@@ -12,6 +12,7 @@ using Avalonia.Input.Raw;
 using Avalonia.Native.Interop;
 using Avalonia.Platform;
 using Avalonia.Platform.Storage;
+using Avalonia.Platform.Storage.FileIO;
 using Avalonia.Rendering.Composition;
 using Avalonia.Threading;
 
@@ -581,6 +582,11 @@ namespace Avalonia.Native
                 return AvaloniaLocator.Current.GetRequiredService<IClipboard>();
             }
 
+            if (featureType == typeof(ILauncher))
+            {
+                return new BclLauncher();
+            }
+
             return null;
         }
 

+ 7 - 0
src/Avalonia.X11/X11Window.cs

@@ -24,6 +24,8 @@ using Avalonia.X11.NativeDialogs;
 using static Avalonia.X11.XLib;
 using Avalonia.Input.Platform;
 using System.Runtime.InteropServices;
+using Avalonia.Platform.Storage.FileIO;
+
 // ReSharper disable IdentifierTypo
 // ReSharper disable StringLiteralTypo
 
@@ -904,6 +906,11 @@ namespace Avalonia.X11
                 return AvaloniaLocator.Current.GetRequiredService<IClipboard>();
             }
 
+            if (featureType == typeof(ILauncher))
+            {
+                return new BclLauncher();
+            }
+
             return null;
         }
 

+ 3 - 0
src/Browser/Avalonia.Browser/Interop/NavigationHelper.cs

@@ -7,4 +7,7 @@ internal static partial class NavigationHelper
 {
     [JSImport("NavigationHelper.addBackHandler", AvaloniaModule.MainModuleName)]
     public static partial void AddBackHandler([JSMarshalAs<JSType.Function<JSType.Boolean>>] Func<bool> backHandlerCallback);
+
+    [JSImport("window.open")]
+    public static partial JSObject? WindowOpen(string uri, string target);
 }

+ 28 - 0
src/Browser/Avalonia.Browser/Storage/BrowserLauncher.cs

@@ -0,0 +1,28 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Browser.Interop;
+using Avalonia.Platform.Storage;
+
+namespace Avalonia.Browser.Storage;
+
+internal class BrowserLauncher : ILauncher
+{
+    public Task<bool> LaunchUriAsync(Uri uri)
+    {
+        _ = uri ?? throw new ArgumentNullException(nameof(uri));
+
+        if (uri.IsAbsoluteUri)
+        {
+            var window = NavigationHelper.WindowOpen(uri.AbsoluteUri, "_blank");
+            return Task.FromResult(window is not null);
+        }
+        return Task.FromResult(false);
+    }
+
+    public Task<bool> LaunchFileAsync(IStorageItem storageItem)
+    {
+        _ = storageItem ?? throw new ArgumentNullException(nameof(storageItem));
+
+        return Task.FromResult(false);
+    }
+}

+ 1 - 0
src/Tizen/Avalonia.Tizen/Platform/Permissions.cs

@@ -19,6 +19,7 @@ internal class Permissions
     public static readonly Privilege MediaStoragePrivilege = new("http://tizen.org/privilege/mediastorage", true);
     public static readonly Privilege RecorderPrivilege = new("http://tizen.org/privilege/recorder", false);
     public static readonly Privilege HapticPrivilege = new("http://tizen.org/privilege/haptic", false);
+    public static readonly Privilege LaunchPrivilege = new("http://tizen.org/privilege/appmanager.launch", false);
 
     public static readonly Privilege[] NetworkPrivileges = { InternetPrivilege, NetworkPrivilege };
     public static readonly Privilege[] MapsPrivileges = { InternetPrivilege, MapServicePrivilege, NetworkPrivilege };

+ 71 - 0
src/Tizen/Avalonia.Tizen/Platform/TizenLauncher.cs

@@ -0,0 +1,71 @@
+using Avalonia.Platform.Storage;
+using Avalonia.Tizen.Platform;
+using Tizen.Applications;
+
+namespace Avalonia.Tizen;
+
+internal class TizenLauncher : ILauncher
+{
+    public async Task<bool> LaunchUriAsync(Uri uri)
+    {
+        if (uri is null)
+        {
+            throw new ArgumentNullException(nameof(uri));
+        }
+
+        if (!uri.IsAbsoluteUri)
+        {
+            return false;
+        }
+
+        if (!await Permissions.RequestPrivilegeAsync(Permissions.LaunchPrivilege))
+        {
+            return false;
+        }
+
+        var appControl = new AppControl
+        {
+            Operation = AppControlOperations.ShareText,
+            Uri = uri.AbsoluteUri
+        };
+
+        if (uri.AbsoluteUri.StartsWith("geo:"))
+            appControl.Operation = AppControlOperations.Pick;
+        else if (uri.AbsoluteUri.StartsWith("http"))
+            appControl.Operation = AppControlOperations.View;
+        else if (uri.AbsoluteUri.StartsWith("mailto:"))
+            appControl.Operation = AppControlOperations.Compose;
+        else if (uri.AbsoluteUri.StartsWith("sms:"))
+            appControl.Operation = AppControlOperations.Compose;
+        else if (uri.AbsoluteUri.StartsWith("tel:"))
+            appControl.Operation = AppControlOperations.Dial;
+
+        AppControl.SendLaunchRequest(appControl);
+
+        return true;
+    }
+
+    public async Task<bool> LaunchFileAsync(IStorageItem storageItem)
+    {
+        if (storageItem is null)
+        {
+            throw new ArgumentNullException(nameof(storageItem));
+        }
+
+        if (!await Permissions.RequestPrivilegeAsync(Permissions.LaunchPrivilege))
+        {
+            return false;
+        }
+
+        var appControl = new AppControl
+        {
+            Operation = AppControlOperations.View,
+            Mime = "*/*",
+            Uri = "file://" + storageItem.Path,
+        };
+
+        AppControl.SendLaunchRequest(appControl);
+
+        return true;
+    }
+}

+ 5 - 0
src/Tizen/Avalonia.Tizen/TopLevelImpl.cs

@@ -104,6 +104,11 @@ internal class TopLevelImpl : ITopLevelImpl
             return _clipboard;
         }
 
+        if (featureType == typeof(ILauncher))
+        {
+            return new TizenLauncher();
+        }
+
         return null;
     }
 

+ 7 - 1
src/Windows/Avalonia.Win32/WindowImpl.cs

@@ -26,6 +26,7 @@ using Avalonia.Win32.WinRT;
 using static Avalonia.Win32.Interop.UnmanagedMethods;
 using Avalonia.Input.Platform;
 using System.Diagnostics;
+using Avalonia.Platform.Storage.FileIO;
 using Avalonia.Threading;
 using static Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl;
 using static Avalonia.Controls.Platform.Win32SpecificOptions;
@@ -344,12 +345,17 @@ namespace Avalonia.Win32
             {
                 return AvaloniaLocator.Current.GetRequiredService<IClipboard>();
             }
-            
+
             if (featureType == typeof(IInputPane))
             {
                 return _inputPane;
             }
 
+            if (featureType == typeof(ILauncher))
+            {
+                return new BclLauncher();
+            }
+
             return null;
         }
 

+ 5 - 0
src/iOS/Avalonia.iOS/AvaloniaView.cs

@@ -287,6 +287,11 @@ namespace Avalonia.iOS
                     return _inputPane;
                 }
 
+                if (featureType == typeof(ILauncher))
+                {
+                    return new IOSLauncher();
+                }
+
                 return null;
             }
         }

+ 45 - 0
src/iOS/Avalonia.iOS/IOSLauncher.cs

@@ -0,0 +1,45 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Platform.Storage;
+using Foundation;
+using UIKit;
+
+namespace Avalonia.iOS;
+
+internal class IOSLauncher : ILauncher
+{
+    public Task<bool> LaunchUriAsync(Uri uri)
+    {
+        _ = uri ?? throw new ArgumentNullException(nameof(uri));
+
+        if (uri.IsAbsoluteUri && UIApplication.SharedApplication.CanOpenUrl(uri))
+        {
+            return UIApplication.SharedApplication.OpenUrlAsync(uri!, new UIApplicationOpenUrlOptions());
+        }
+
+        return Task.FromResult(false);
+    }
+
+    public Task<bool> LaunchFileAsync(IStorageItem storageItem)
+    {
+        _ = storageItem ?? throw new ArgumentNullException(nameof(storageItem));
+
+#if !TVOS
+        var uri = (storageItem as Storage.IOSStorageItem)?.Url
+                  ?? (storageItem.TryGetLocalPath() is { } localPath ? NSUrl.FromFilename(localPath) : null);
+        if (uri is not null)
+        {
+            var documentController = new UIDocumentInteractionController()
+            {
+                Name = storageItem.Name,
+                Url = uri
+            };
+
+            var result = documentController.PresentPreview(true);
+            return Task.FromResult(result);
+        }
+#endif
+
+        return Task.FromResult(false);
+    }
+}