Skip to content

Commit

Permalink
Implement TopLevel.Launcher (#14320)
Browse files Browse the repository at this point in the history
* Implement ILauncher

* Update dialogs page to include Launcher buttons

* Fix control catalog

* Add return comments
  • Loading branch information
maxkatz6 authored Feb 7, 2024
1 parent b7d4efa commit df4189c
Show file tree
Hide file tree
Showing 18 changed files with 445 additions and 49 deletions.
9 changes: 9 additions & 0 deletions samples/ControlCatalog/Pages/DialogsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
31 changes: 31 additions & 0 deletions samples/ControlCatalog/Pages/DialogsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public DialogsPage()
this.InitializeComponent();

IStorageFolder? lastSelectedDirectory = null;
IStorageItem? lastSelectedItem = null;
bool ignoreTextChanged = false;

var results = this.Get<ItemsControl>("PickerLastResults");
Expand Down Expand Up @@ -290,11 +291,40 @@ List<FileDialogFilter> GetFilters()
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;
}
Expand Down Expand Up @@ -344,6 +374,7 @@ async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items)
}
}
}
lastSelectedItem = item;
}

results.ItemsSource = mappedResults;
Expand Down
56 changes: 56 additions & 0 deletions src/Android/Avalonia.Android/Platform/AndroidLauncher.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurface.IEglWindo
private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager;
private readonly AndroidInsetsManager _insetsManager;
private readonly ClipboardImpl _clipboard;
private readonly AndroidLauncher _launcher;
private ViewImpl _view;
private WindowTransparencyLevel _transparencyLevel;

Expand All @@ -70,6 +71,7 @@ public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false)
_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);

Expand Down Expand Up @@ -404,6 +406,11 @@ public virtual object TryGetFeature(Type featureType)
return _clipboard;
}

if (featureType == typeof(ILauncher))
{
return _launcher;
}

return null;
}

Expand Down
90 changes: 90 additions & 0 deletions src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 69 additions & 0 deletions src/Avalonia.Base/Platform/Storage/ILauncher.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
3 changes: 2 additions & 1 deletion src/Avalonia.Controls/TopLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ public static bool GetAutoSafeAreaPadding(Control control)

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
Expand All @@ -560,7 +561,7 @@ public static bool GetAutoSafeAreaPadding(Control control)

/// <inheritdoc />
public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService<IPlatformSettings>();

/// <inheritdoc/>
Point IRenderRoot.PointToClient(PixelPoint p)
{
Expand Down
50 changes: 3 additions & 47 deletions src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,51 +21,10 @@ public AboutAvaloniaDialog()
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);
}
}
}
Loading

0 comments on commit df4189c

Please sign in to comment.