diff --git a/src/Avalonia.FreeDesktop/Atspi.cs b/src/Avalonia.FreeDesktop/Atspi.cs index 1877b6c542..ac072717f4 100644 --- a/src/Avalonia.FreeDesktop/Atspi.cs +++ b/src/Avalonia.FreeDesktop/Atspi.cs @@ -148,6 +148,41 @@ namespace Avalonia.FreeDesktop.Atspi public ObjectPath Path { get; } } + internal readonly struct CacheItem + { + public CacheItem( + ObjectReference path, + ObjectReference application, + ObjectReference parent, + ObjectReference[] children, + string[] supportedInterfaces, + string name, + uint role, + string description, + int[] state) + { + Path = path; + Application = application; + Parent = parent; + Children = children; + SupportedInterfaces = supportedInterfaces; + Name = name; + Role = role; + Description = description; + State = state; + } + + public ObjectReference Path { get; } + public ObjectReference Application { get; } + public ObjectReference Parent { get; } + public ObjectReference[] Children { get; } + public string[] SupportedInterfaces { get; } + public string Name { get; } + public uint Role { get; } + public string Description { get; } + public int[] State { get; } + }; + [DBusInterface("org.a11y.Bus")] internal interface IBus : IDBusObject { @@ -176,6 +211,7 @@ namespace Avalonia.FreeDesktop.Atspi [DBusInterface("org.a11y.atspi.Application")] internal interface IApplication : IDBusObject { + Task GetApplicationBusAddressAsync(); Task GetLocaleAsync(uint lcType); Task RegisterEventListenerAsync(string Event); Task DeregisterEventListenerAsync(string Event); @@ -185,6 +221,14 @@ namespace Avalonia.FreeDesktop.Atspi Task WatchPropertiesAsync(Action handler); } + [DBusInterface("org.a11y.atspi.Cache")] + internal interface ICache : IDBusObject + { + Task GetItemsAsync(); + Task WatchAddAccessibleAsync(Action handler, Action? onError = null); + Task WatchRemoveAccessibleAsync(Action handler, Action? onError = null); + } + [DBusInterface("org.a11y.atspi.Socket")] internal interface ISocket : IDBusObject { diff --git a/src/Avalonia.FreeDesktop/AtspiCache.cs b/src/Avalonia.FreeDesktop/AtspiCache.cs new file mode 100644 index 0000000000..474f932f87 --- /dev/null +++ b/src/Avalonia.FreeDesktop/AtspiCache.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Avalonia.FreeDesktop.Atspi; +using Tmds.DBus; + +#nullable enable + +namespace Avalonia.FreeDesktop +{ + internal class AtspiCache : ICache + { + private readonly AtspiRoot _root; + private readonly ObservableCollection _items = new ObservableCollection(); + + public AtspiCache(AtspiRoot root) + { + _root = root; + } + + public ObjectPath ObjectPath => "/org/a11y/atspi/cache"; + + public void Add(AtspiContext item) => _items.Add(item.ToCacheItem()); + public Task GetItemsAsync() => Task.FromResult(_items.ToArray()); + public Task WatchAddAccessibleAsync(Action handler, Action? onError = null) + { + void Listener(object s, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + foreach (CacheItem i in e.NewItems) handler(i); + } + + _items.CollectionChanged += Listener; + return Task.FromResult(Disposable.Create(() => _items.CollectionChanged -= Listener)); + } + + public Task WatchRemoveAccessibleAsync(Action handler, Action? onError = null) + { + void Listener(object s, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Remove) + foreach (CacheItem i in e.NewItems) handler(i); + } + + _items.CollectionChanged += Listener; + return Task.FromResult(Disposable.Create(() => _items.CollectionChanged -= Listener)); + } + } +} diff --git a/src/Avalonia.FreeDesktop/AtspiContext.cs b/src/Avalonia.FreeDesktop/AtspiContext.cs index 6587cb1448..8f7eeecbfd 100644 --- a/src/Avalonia.FreeDesktop/AtspiContext.cs +++ b/src/Avalonia.FreeDesktop/AtspiContext.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reactive.Disposables; using System.Threading.Tasks; using Avalonia.Controls.Automation.Peers; using Avalonia.FreeDesktop.Atspi; @@ -10,8 +11,15 @@ using Tmds.DBus; namespace Avalonia.FreeDesktop { + /// + /// A node in the AT-SPI UI automation tree. + /// + /// + /// This class is the platform implementation for an when using AT-SPI. + /// internal class AtspiContext : IAccessible, IAutomationPeerImpl { + private static uint _id; private readonly AtspiRoot _root; private readonly AutomationPeer _peer; private readonly AtspiRole _role; @@ -21,11 +29,25 @@ namespace Avalonia.FreeDesktop _root = root; _peer = peer; _role = role; - ObjectPath = new ObjectPath("/net/avaloniaui/a11y/" + Guid.NewGuid().ToString().Replace("-", "")); + ObjectPath = new ObjectPath("/org/a11y/atspi/accessible/" + ++_id); } public ObjectPath ObjectPath { get; } + public CacheItem ToCacheItem() + { + return new CacheItem( + new ObjectReference(_root.LocalName, ObjectPath), + _root.ApplicationPath, + _root.ApplicationPath, + new ObjectReference[0], + new[] { "org.a11y.atspi.Accessible" }, + string.Empty, + (uint)_role, + string.Empty, + new[] { 0, 0 }); + } + Task IAccessible.GetChildAtIndexAsync(int Index) { throw new NotImplementedException(); @@ -65,12 +87,12 @@ namespace Avalonia.FreeDesktop Task IAccessible.SetAsync(string prop, object val) { - throw new NotImplementedException(); + return Task.FromResult(Disposable.Empty); } Task IAccessible.WatchPropertiesAsync(Action handler) { - throw new NotImplementedException(); + return Task.FromResult(Disposable.Empty); } } } diff --git a/src/Avalonia.FreeDesktop/AtspiRoot.cs b/src/Avalonia.FreeDesktop/AtspiRoot.cs index 9a2cf1718b..e6eedd1853 100644 --- a/src/Avalonia.FreeDesktop/AtspiRoot.cs +++ b/src/Avalonia.FreeDesktop/AtspiRoot.cs @@ -11,13 +11,21 @@ using Avalonia.FreeDesktop.Atspi; using Avalonia.Logging; using Avalonia.Platform; using Avalonia.Threading; +using JetBrains.Annotations; using Tmds.DBus; #nullable enable namespace Avalonia.FreeDesktop { - public class AtspiRoot : IAccessible, IApplication, IPlatformAutomationPeerFactory + /// + /// The root Application in the AT-SPI automation tree. + /// + /// + /// When using AT-SPI there is a single AT-SPI root object for the application. Its children are the application's + /// open windows. + /// + public class AtspiRoot : IAccessible, IApplication { private const string RootPath = "/org/a11y/atspi/accessible/root"; private const string AtspiVersion = "2.1"; @@ -27,8 +35,10 @@ namespace Avalonia.FreeDesktop private static bool _instanceInitialized; private readonly List _children = new List(); + private string? _applicationBusAddress; private Connection? _connection; private string? _localName; + private AtspiCache? _cache; private AccessibleProperties? _accessibleProperties; private ApplicationProperties? _applicationProperties; @@ -39,9 +49,10 @@ namespace Avalonia.FreeDesktop } public ObjectPath ObjectPath => RootPath; - public ObjectReference ApplicationPath => _accessibleProperties!.Parent; + public ObjectReference ApplicationPath => new ObjectReference(LocalName, ObjectPath); public IDictionary Attributes { get; } - + public string LocalName => _localName ?? throw new AvaloniaInternalException("AT-SPI not initialized."); + public static AtspiRoot? RegisterRoot(Func peerGetter) { if (!_instanceInitialized) @@ -56,7 +67,14 @@ namespace Avalonia.FreeDesktop public IAutomationPeerImpl CreateAutomationPeerImpl(AutomationPeer peer) { - return AtspiContextFactory.Create(this, peer); + if (_connection is null) + throw new AvaloniaInternalException("AT-SPI not initialized."); + + var result = AtspiContextFactory.Create(this, peer); + var _ = _connection.RegisterObjectAsync(result); + _cache!.Add(result); + System.Diagnostics.Debug.WriteLine($"Created {result.ObjectPath} for {peer}"); + return result; } private async void Register(Connection sessionConnection) @@ -65,21 +83,23 @@ namespace Avalonia.FreeDesktop { // Get the address of the a11y bus and open a connection to it. var bus = sessionConnection.CreateProxy("org.a11y.Bus", "/org/a11y/bus"); - var address = await bus.GetAddressAsync(); - var connection = new Connection(address); + _applicationBusAddress = await bus.GetAddressAsync(); + + var connection = new Connection(_applicationBusAddress); var connectionInfo = await connection.ConnectAsync(); // Register the org.a11y.atspi.Application and org.a11y.atspi.Accessible interfaces at the well-known - // object path + // object path. await connection.RegisterObjectAsync(this); - // Register ourselves on the a11y bus. + // Get the a11y Register object's Socket interface. var socket = connection.CreateProxy("org.a11y.atspi.Registry", RootPath); - var plug = new ObjectReference(connectionInfo.LocalName, RootPath); - + + // Set up our properties now as they can be read when we call Socket.Embed. _accessibleProperties = new AccessibleProperties { Name = Application.Current.Name ?? "Unnamed", + Description = string.Empty, Locale = CultureInfo.CurrentCulture.Name, ChildCount = _children.Count, AccessibleId = string.Empty, @@ -93,9 +113,22 @@ namespace Avalonia.FreeDesktop ToolkitName = "Avalonia", }; - _accessibleProperties.Parent = await socket.EmbedAsync(plug); - _localName = connectionInfo.LocalName; + // Store the connection object and local name as the call to Socket.Embed will result in a call + // to GetChildAtIndexAsync. _connection = connection; + _localName = connectionInfo.LocalName; + + // Embed ourselves using the Socket.Embed method. We pass the local name (aka unique name) for the + // connection along with the root a11y path and get back an object reference to the desktop object + // (I think?). + var plug = new ObjectReference(connectionInfo.LocalName, RootPath); + _accessibleProperties.Parent = await socket.EmbedAsync(plug); + + // Create and register the cache. + _cache = new AtspiCache(this); + await connection.RegisterObjectAsync(_cache); + + System.Diagnostics.Debug.WriteLine($"Set up AtspiRoot on {LocalName}"); } catch (Exception e) { @@ -119,20 +152,19 @@ namespace Avalonia.FreeDesktop if (context is null) throw new AvaloniaInternalException("AutomationPeer has no platform implementation."); - return new ObjectReference(_localName!, context.ObjectPath); + return new ObjectReference(LocalName, context.ObjectPath); } - Task IAccessible.GetChildrenAsync() + async Task IAccessible.GetChildrenAsync() { - var result = new List(); + var result = new ObjectReference[_children.Count]; - // foreach (var p in _automationPeers) - // { - // var peer = p(); - // result.Add((_localName, p.GetHashCode().ToString())); - // } + for (var i = 0; i < _children.Count; ++i) + { + result[i] = await ((IAccessible)this).GetChildAtIndexAsync(i); + } - return Task.FromResult(result.ToArray()); + return result; } Task IAccessible.GetIndexInParentAsync() => Task.FromResult(-1); @@ -149,6 +181,7 @@ namespace Avalonia.FreeDesktop Task IAccessible.GetApplicationAsync() => Task.FromResult(ApplicationPath); Task> IAccessible.GetAttributesAsync() => Task.FromResult(Attributes); + Task IApplication.GetApplicationBusAddressAsync() => Task.FromResult(_applicationBusAddress!); Task IApplication.GetLocaleAsync(uint lcType) => Task.FromResult(CultureInfo.CurrentCulture.Name); Task IApplication.RegisterEventListenerAsync(string Event)