Pārlūkot izejas kodu

Implement Open With on macOS

Ruben 4 mēneši atpakaļ
vecāks
revīzija
f7c4c747fb

+ 9 - 2
src/PicView.Avalonia.MacOS/App.axaml.cs

@@ -133,8 +133,15 @@ public class App : Application, IPlatformSpecificService, IPlatformWindowService
 
     public void OpenWith(string path)
     {
-        // TODO: Implement OpenWith on macOS
-        ProcessHelper.OpenLink(path);
+        Dispatcher.UIThread.Post(() =>
+        {
+            var openWithView = new OpenWithView
+            {
+                DataContext = _vm
+            };
+            openWithView.Show();
+        }, DispatcherPriority.Input);
+        
     }
 
     public void LocateOnDisk(string path)

+ 54 - 0
src/PicView.Avalonia.MacOS/Views/OpenWithView.axaml

@@ -0,0 +1,54 @@
+<Window
+    CanResize="False"
+    ExtendClientAreaChromeHints="SystemChrome"
+    ExtendClientAreaTitleBarHeightHint="-1"
+    SizeToContent="WidthAndHeight"
+    SystemDecorations="BorderOnly"
+    d:DesignHeight="450"
+    d:DesignWidth="800"
+    mc:Ignorable="d"
+    x:Class="PicView.Avalonia.MacOS.Views.OpenWithView"
+    x:DataType="viewModels:MainViewModel"
+    xmlns="https://github.com/avaloniaui"
+    xmlns:customControls="clr-namespace:PicView.Avalonia.CustomControls;assembly=PicView.Avalonia"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:viewModels="clr-namespace:PicView.Avalonia.ViewModels;assembly=PicView.Avalonia"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+    <Design.DataContext>
+        <viewModels:MainViewModel />
+    </Design.DataContext>
+    <Panel
+        Background="#4D000000"
+        Height="400"
+        Width="300">
+        <TextBlock
+            Classes="txt"
+            FontFamily="avares://PicView.Avalonia/Assets/Fonts/Roboto-Medium.ttf#Roboto"
+            FontSize="13"
+            FontWeight="Medium"
+            Height="{CompiledBinding TitlebarHeight,
+                                     Mode=OneWay}"
+            Padding="7"
+            Text="{CompiledBinding Translation.OpenWith,
+                                   Mode=OneWay}"
+            TextAlignment="Center"
+            VerticalAlignment="Top" />
+        <customControls:AutoScrollViewer Margin="0,20,0,0" Theme="{StaticResource Inline}">
+            <StackPanel Margin="5,15,5,5" x:Name="ParentPanel" />
+        </customControls:AutoScrollViewer>
+        <Button
+            Classes=" altHover"
+            Height="{CompiledBinding TitlebarHeight,
+                                     Mode=OneWay}"
+            Padding="7"
+            VerticalAlignment="Bottom"
+            x:Name="CancelButton">
+            <TextBlock
+                Classes="txt"
+                Text="{CompiledBinding Translation.Cancel,
+                                       Mode=OneWay}"
+                TextAlignment="Center" />
+        </Button>
+    </Panel>
+</Window>

+ 105 - 0
src/PicView.Avalonia.MacOS/Views/OpenWithView.axaml.cs

@@ -0,0 +1,105 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Threading;
+using PicView.Avalonia.ViewModels;
+using PicView.Core.MacOS.FileAssociation;
+using Avalonia;
+using PicView.Core.MacOS.AppLauncher;
+
+namespace PicView.Avalonia.MacOS.Views;
+
+public partial class OpenWithView : Window
+{
+    private bool _isLaunchingApp = false;
+
+    public OpenWithView()
+    {
+        InitializeComponent();
+        Loaded += OnLoaded;
+        
+        // Close when window loses focus (more standard behavior)
+        Deactivated += OnDeactivated;
+        
+        // Handle key presses (optional: close on Escape)
+        KeyDown += OnKeyDown;
+        
+        // Ensure the window can receive focus
+        Focusable = true;
+    }
+
+    private void OnLoaded(object? sender, RoutedEventArgs e)
+    {
+        if (DataContext is not MainViewModel vm)
+        {
+            return;
+        }
+
+        // Focus the window
+        Focus();
+        
+        CancelButton.Click += (_, _) => (VisualRoot as Window)?.Close();
+
+        Task.Run(async () =>
+        {
+            var apps = await GetAssociatedFiles.GetAssociatedFilesAsync(vm.PicViewer?.FileInfo?.FullName);
+            await Dispatcher.UIThread.InvokeAsync(() =>
+            {
+                foreach (var app in apps)
+                {
+                    var btn = new Button
+                    {
+                        Classes = { "altHover" },
+                        Width = 300,
+                        Padding = new Thickness(0, 5, 0, 5),
+                        Content =
+                            new TextBlock
+                            {
+                                Text = app.Name,
+                                VerticalAlignment = VerticalAlignment.Center,
+                                Classes = { "txt", "txtShadow" },
+                                MaxWidth = 250,
+                            }
+                    };
+
+                    // Add click handler to launch the app with the file and close window
+                    btn.Click += async (_, _) =>
+                    {
+                        _isLaunchingApp = true; // Prevent deactivated event from closing
+                        await AppLauncher.LaunchAppWithFileAsync(app.Path, vm.PicViewer?.FileInfo?.FullName);
+                        Close(); // Close the window after launching the app
+                    };
+
+                    ParentPanel.Children.Add(btn);
+                }
+            });
+        });
+    }
+
+    private void OnDeactivated(object? sender, EventArgs e)
+    {
+        // Only close if we're not in the process of launching an app
+        if (!_isLaunchingApp)
+        {
+            Close();
+        }
+    }
+
+    private void OnKeyDown(object? sender, KeyEventArgs e)
+    {
+        // Close window on Escape key
+        if (e.Key == Key.Escape)
+        {
+            Close();
+        }
+    }
+
+    // Override to ensure proper cleanup
+    protected override void OnClosed(EventArgs e)
+    {
+        Deactivated -= OnDeactivated;
+        KeyDown -= OnKeyDown;
+        base.OnClosed(e);
+    }
+}

+ 0 - 1
src/PicView.Avalonia/Views/MainView.axaml.cs

@@ -30,7 +30,6 @@ public partial class MainView : UserControl
         {
             // TODO: Add macOS support
             WallpaperMenuItem.IsVisible = false;
-            OpenWithMenuItem.IsVisible = false;
             PrintMenuItem.IsVisible = false;
             
             // Move alt hover to left side on macOS and switch button order

+ 0 - 1
src/PicView.Avalonia/Views/UC/Menus/FileMenu.axaml.cs

@@ -21,7 +21,6 @@ public partial class FileMenu : AnimatedMenu
 
             if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
             {
-                OpenWithButton.IsEnabled = false;
                 PrintButton.IsEnabled = false;
             }
         };

+ 82 - 0
src/PicView.Core.MacOS/AppLauncher/AppLauncher.cs

@@ -0,0 +1,82 @@
+using System.Diagnostics;
+
+namespace PicView.Core.MacOS.AppLauncher;
+
+public static class AppLauncher
+{
+    /// <summary>
+    /// Launches the specified application with the given file
+    /// </summary>
+    /// <param name="appPath">Full path to the application</param>
+    /// <param name="filePath">Path to the file to open</param>
+    /// <returns>True if the app was launched successfully</returns>
+    public static async Task<bool> LaunchAppWithFileAsync(string appPath, string filePath)
+    {
+        try
+        {
+            if (string.IsNullOrEmpty(appPath) || string.IsNullOrEmpty(filePath))
+                return false;
+
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    FileName = "open",
+                    Arguments = $"-a \"{appPath}\" \"{filePath}\"",
+                    UseShellExecute = false,
+                    CreateNoWindow = true,
+                    RedirectStandardError = true
+                }
+            };
+
+            process.Start();
+            await process.WaitForExitAsync();
+            
+            return process.ExitCode == 0;
+        }
+        catch (Exception ex)
+        {
+#if DEBUG
+            Console.WriteLine($"Error launching app {appPath} with file {filePath}: {ex.Message}");
+#endif
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// Launches the default application for the file type
+    /// </summary>
+    /// <param name="filePath">Path to the file to open</param>
+    /// <returns>True if the file was opened successfully</returns>
+    public static async Task<bool> OpenWithDefaultAppAsync(string filePath)
+    {
+        try
+        {
+            if (string.IsNullOrEmpty(filePath))
+                return false;
+
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    FileName = "open",
+                    Arguments = $"\"{filePath}\"",
+                    UseShellExecute = false,
+                    CreateNoWindow = true
+                }
+            };
+
+            process.Start();
+            await process.WaitForExitAsync();
+            
+            return process.ExitCode == 0;
+        }
+        catch (Exception ex)
+        {
+#if DEBUG
+            Console.WriteLine($"Error opening file {filePath}: {ex.Message}");
+#endif
+            return false;
+        }
+    }
+}

+ 102 - 0
src/PicView.Core.MacOS/FileAssociation/GetAssociatedFiles.cs

@@ -0,0 +1,102 @@
+using PicView.Core.MacOS.AppleScripts;
+
+namespace PicView.Core.MacOS.FileAssociation;
+
+public class AppInfo
+{
+    public string Name { get; set; } = string.Empty;
+    public string Path { get; set; } = string.Empty;
+    public string BundleId { get; set; } = string.Empty;
+}
+
+public static class GetAssociatedFiles
+{
+    private static readonly string[] ExcludedBundleIds = 
+    {
+        "com.ruben2776.picview",
+        "PicView" // Fallback name check
+    };
+
+    public static async Task<AppInfo[]> GetAssociatedFilesAsync(string filePath)
+    {
+        var appleScript = $@"
+use AppleScript version ""2.4""
+use scripting additions
+use framework ""AppKit""
+
+set filePath to ""{filePath}""
+set fileURL to current application's NSURL's fileURLWithPath:filePath
+
+set workspace to current application's NSWorkspace's sharedWorkspace()
+set appsArray to workspace's URLsForApplicationsToOpenURL:fileURL
+
+set resultList to {{}}
+repeat with appURL in appsArray
+    set appPath to appURL's |path|() as text
+    set appName to appURL's lastPathComponent() as text
+    
+    -- Get bundle identifier
+    set bundleId to """"
+    try
+        set appBundle to current application's NSBundle's bundleWithURL:appURL
+        if appBundle is not missing value then
+            set bundleId to appBundle's bundleIdentifier() as text
+        end if
+    end try
+    
+    set end of resultList to appName & ""|"" & appPath & ""|"" & bundleId
+end repeat
+return resultList";
+
+        var result = await AppleScriptManager.ExecuteAppleScriptWithResultAsync(appleScript);
+        if (!string.IsNullOrEmpty(result))
+        {
+            var apps = result.Split(',');
+            var appInfos = new List<AppInfo>();
+            
+            foreach (var app in apps)
+            {
+                var parts = app.Trim().Split('|');
+                if (parts.Length < 2)
+                {
+                    continue;
+                }
+
+                var appName = parts[0].Trim();
+                var appPath = parts[1].Trim();
+                var bundleId = parts.Length > 2 ? parts[2].Trim() : string.Empty;
+                    
+                // Skip if this is our own app
+                if (IsOwnApp(appName, appPath, bundleId))
+                    continue;
+                    
+                appInfos.Add(new AppInfo
+                {
+                    Name = appName,
+                    Path = appPath,
+                    BundleId = bundleId
+                });
+            }
+            return appInfos.ToArray();
+        }
+        
+#if DEBUG
+        Console.WriteLine("No applications found for this file type: " + filePath);
+#endif
+        return Array.Empty<AppInfo>();
+    }
+    
+    private static bool IsOwnApp(string appName, string appPath, string bundleId)
+    {
+        // Check by bundle identifier first (most reliable)
+        if (!string.IsNullOrEmpty(bundleId) && 
+            ExcludedBundleIds.Any(excluded => 
+                string.Equals(bundleId, excluded, StringComparison.OrdinalIgnoreCase)))
+        {
+            return true;
+        }
+
+        // Fallback: Check by app name
+        return appName.Contains("PicView", StringComparison.OrdinalIgnoreCase);
+    }
+}