645 lines
22 KiB
Go
645 lines
22 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
package atspi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"log/slog"
|
|
"runtime/cgo"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
/*
|
|
#cgo pkg-config: atspi-2 gobject-2.0
|
|
#include "adapter_native.h"
|
|
*/
|
|
import "C"
|
|
|
|
const eventQueueCapacity = 64
|
|
|
|
const (
|
|
FocusEvent = "focus:"
|
|
FocusedStateEvent = "object:state-changed:focused"
|
|
WindowActivatedEvent = "window:activate"
|
|
WindowDeactivatedEvent = "window:deactivate"
|
|
WindowDestroyedEvent = "window:destroy"
|
|
WindowCreateEvent = "window:create"
|
|
CaretEvent = "object:text-caret-moved"
|
|
MouseButtonEvent = "mouse:button"
|
|
TextSelectionChangedEvent = "object:text-selection-changed"
|
|
TextInsertedEvent = "object:text-changed:insert"
|
|
TextDeletedEvent = "object:text-changed:delete"
|
|
ValueChangedEvent = "object:value-changed"
|
|
AccessibleValueEvent = "object:property-change:accessible-value"
|
|
AccessibleNameEvent = "object:property-change:accessible-name"
|
|
ActiveDescendantEvent = "object:active-descendant-changed"
|
|
SelectionChangedEvent = "object:selection-changed"
|
|
SelectedStateEvent = "object:state-changed:selected"
|
|
ChildrenChangedEvent = "object:children-changed"
|
|
ChildrenAddedEvent = "object:children-changed:add"
|
|
ChildrenRemovedEvent = "object:children-changed:remove"
|
|
DefunctStateEvent = "object:state-changed:defunct"
|
|
BoundsChangedEvent = "object:bounds-changed"
|
|
VisibleStateEvent = "object:state-changed:visible"
|
|
ShowingStateEvent = "object:state-changed:showing"
|
|
TextBoundsChangedEvent = "object:text-bounds-changed"
|
|
TextAttributesChangedEvent = "object:text-attributes-changed"
|
|
DocumentReloadEvent = "document:reload"
|
|
DocumentLoadCompleteEvent = "document:load-complete"
|
|
DocumentLoadStoppedEvent = "document:load-stopped"
|
|
BusyStateEvent = "object:state-changed:busy"
|
|
AnnouncementEvent = "object:announcement"
|
|
)
|
|
|
|
var eventTypes = []string{
|
|
FocusEvent,
|
|
FocusedStateEvent,
|
|
WindowActivatedEvent,
|
|
WindowDeactivatedEvent,
|
|
WindowDestroyedEvent,
|
|
WindowCreateEvent,
|
|
CaretEvent,
|
|
TextSelectionChangedEvent,
|
|
TextInsertedEvent,
|
|
TextDeletedEvent,
|
|
ValueChangedEvent,
|
|
AccessibleValueEvent,
|
|
AccessibleNameEvent,
|
|
ActiveDescendantEvent,
|
|
SelectionChangedEvent,
|
|
ChildrenChangedEvent,
|
|
DefunctStateEvent,
|
|
BoundsChangedEvent,
|
|
VisibleStateEvent,
|
|
ShowingStateEvent,
|
|
TextBoundsChangedEvent,
|
|
TextAttributesChangedEvent,
|
|
DocumentReloadEvent,
|
|
DocumentLoadCompleteEvent,
|
|
DocumentLoadStoppedEvent,
|
|
BusyStateEvent,
|
|
AnnouncementEvent,
|
|
"object:state-changed:checked",
|
|
"object:state-changed:expanded",
|
|
"object:state-changed:indeterminate",
|
|
"object:state-changed:invalid-entry",
|
|
"object:state-changed:pressed",
|
|
"object:state-changed:read-only",
|
|
"object:state-changed:editable",
|
|
"object:state-changed:required",
|
|
SelectedStateEvent,
|
|
"object:state-changed:sensitive",
|
|
"object:state-changed:visited",
|
|
MouseButtonEvent,
|
|
}
|
|
|
|
type Adapter struct {
|
|
captureLogger atomic.Pointer[slog.Logger]
|
|
libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop
|
|
applicationProcesses map[string]string // GLib owner; bounded provider identity cache
|
|
webNotificationRoots map[ObjectID]bool // copied identities only; no native references
|
|
deferredLiveRegions deferredLiveRegionStore // GLib owner; copied identities only
|
|
foregroundReader *xorg.ForegroundReader // GLib thread only
|
|
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client
|
|
foreground atomic.Pointer[ForegroundState]
|
|
preferencesPID atomic.Int64 // Owned preferences helper; zero when closed.
|
|
echoFocus atomic.Pointer[echoFocus]
|
|
eventListener *C.AtspiEventListener // GLib owner; released with subscriptions.
|
|
foregroundChanges chan struct{}
|
|
foregroundTitle atomic.Pointer[ForegroundTitle]
|
|
titleChanges chan struct{}
|
|
foregroundWindow ObjectID // GLib thread: correlated accessible for current X client
|
|
promptRefreshWindow ObjectID // GLib thread: frame activated before its prompt label appeared
|
|
promptScannedWindow ObjectID // GLib thread: avoid rescanning repeated activations
|
|
promptScannedEpoch uint64
|
|
foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI
|
|
recoveringForeground bool
|
|
|
|
events chan Event
|
|
treeRequests chan treeCaptureRequest
|
|
overflow atomic.Uint64
|
|
treeInvalidations atomic.Uint64
|
|
backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries
|
|
secureMutex sync.Mutex
|
|
secureContext context.Context
|
|
secureCancel context.CancelFunc
|
|
secureEpoch atomic.Uint64
|
|
secure atomic.Bool
|
|
secureReset atomic.Bool
|
|
secureResync atomic.Bool
|
|
captureSound atomic.Bool
|
|
captureTextAttributes atomic.Bool
|
|
running atomic.Bool
|
|
ready atomic.Bool
|
|
loopDone chan struct{}
|
|
loopError error // published by closing loopDone
|
|
stopRequested bool // stopMutex; keep Stop retries from queuing stale quit sources
|
|
stopMutex sync.Mutex
|
|
hungMutex sync.Mutex
|
|
hungBuses map[string]time.Time
|
|
treeMutex sync.Mutex
|
|
tree *AccessibleTree
|
|
documentTree *AccessibleTree
|
|
documentTextTree *AccessibleTree
|
|
// The active document slots above serve one document root at a time.
|
|
// Recently captured documents are stashed by root (bounded, LRU) so
|
|
// returning to a tab reuses its tree and retained native sources
|
|
// instead of snapshotting the whole document cold. Guarded by
|
|
// treeMutex; stashed sources are released only on the GLib thread.
|
|
documentSlotRoot ObjectID
|
|
stashedDocuments map[ObjectID]stashedDocument
|
|
stashedDocumentIDs []ObjectID
|
|
nativeDocument map[ObjectID]*C.AtspiAccessible
|
|
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
|
|
nativeReview map[ObjectID]*C.AtspiAccessible
|
|
nativeReviewBus string
|
|
navigationEcho map[ObjectID]uint8
|
|
navigationCaretOffset int
|
|
navigationSelectionOffsets map[int]bool
|
|
nativeFocus []nativeFocusReference // owned only by the GLib thread
|
|
mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown
|
|
mouseApplicationPID int
|
|
documentWindows map[ObjectID]nativeDocumentWindow
|
|
documentWindowIDs []ObjectID
|
|
selectedTabWindow ObjectID
|
|
activeDocument ObjectID
|
|
callbackHandle cgo.Handle
|
|
callbackData *C.cthulhu_callback_data
|
|
}
|
|
|
|
type nativeFocusReference struct {
|
|
id ObjectID
|
|
source *C.AtspiAccessible
|
|
// Only programmatic browse positions carry this proof. A subsequent native
|
|
// focus event replaces the entry and clears it, even for the same object.
|
|
navigationDocument ObjectReference
|
|
}
|
|
|
|
type nativeDocumentWindow struct {
|
|
id ObjectID
|
|
source *C.AtspiAccessible
|
|
}
|
|
|
|
type treeCaptureResult struct {
|
|
soundGeometry [2]SoundGeometry
|
|
item TransferItem
|
|
actions ActionSet
|
|
point PointReview
|
|
ocr OCRGeometry
|
|
attributes CharacterAttributes
|
|
content DocumentContent
|
|
tree *AccessibleTree
|
|
owner DocumentContext
|
|
caret DocumentCaretText
|
|
err error
|
|
}
|
|
|
|
type treeCaptureRequest struct {
|
|
readFocusedSnapshot bool
|
|
|
|
soundObjects [2]ObjectReference
|
|
transferItem bool
|
|
typeText bool
|
|
insertionText string
|
|
listActions bool
|
|
actionTarget *ActionTarget
|
|
actionName string
|
|
pointReview bool
|
|
pointX, pointY int
|
|
pointPID int
|
|
ocrGeometry bool
|
|
ocrZone *Rectangle
|
|
readFocusedText bool
|
|
readFocusedLine bool
|
|
readAttributes bool
|
|
setFocusedCaret bool
|
|
brailleRoute *BrailleRoute
|
|
ctx context.Context
|
|
reviewFocus ObjectID
|
|
document bool
|
|
documentText bool
|
|
contextOnly bool
|
|
position ObjectReference
|
|
caretObject ObjectReference
|
|
caretOffset int
|
|
caretUnit DocumentCaretUnit
|
|
positionOffset int
|
|
usePositionOffset bool
|
|
activate bool
|
|
result chan treeCaptureResult
|
|
}
|
|
|
|
// PointReview is copied metadata for the accessible at a screen point.
|
|
// Text ranges are optional and never include password contents.
|
|
type PointReview struct {
|
|
Snapshot Snapshot
|
|
Window ObjectID
|
|
Word, Line string
|
|
LineStart int
|
|
Offset int // native text offset at the pointer, or -1
|
|
}
|
|
|
|
func (adapter *Adapter) ReviewAtPoint(ctx context.Context, x, y, pid int) (PointReview, error) {
|
|
if ctx == nil {
|
|
return PointReview{}, errors.New("review pointer: nil context")
|
|
}
|
|
ctx, cancel := adapter.secureRequestContext(ctx)
|
|
defer cancel()
|
|
adapter.stopMutex.Lock()
|
|
defer adapter.stopMutex.Unlock()
|
|
if !adapter.ready.Load() || adapter.callbackData == nil {
|
|
return PointReview{}, errors.New("review pointer: adapter is not running")
|
|
}
|
|
request := treeCaptureRequest{ctx: ctx, pointReview: true, pointX: x, pointY: y, pointPID: pid, result: make(chan treeCaptureResult, 1)}
|
|
select {
|
|
case adapter.treeRequests <- request:
|
|
case <-ctx.Done():
|
|
return PointReview{}, ctx.Err()
|
|
}
|
|
if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 {
|
|
<-adapter.treeRequests
|
|
return PointReview{}, errors.New("schedule pointer review")
|
|
}
|
|
select {
|
|
case result := <-request.result:
|
|
return result.point, result.err
|
|
case <-ctx.Done():
|
|
return PointReview{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func New() *Adapter {
|
|
adapter := &Adapter{
|
|
events: make(chan Event, eventQueueCapacity),
|
|
foregroundChanges: make(chan struct{}, 1),
|
|
titleChanges: make(chan struct{}, 1),
|
|
treeRequests: make(chan treeCaptureRequest, 1),
|
|
}
|
|
// Direct adapter users historically receive geometry. The application
|
|
// narrows this before Start when object-sound spatialization is disabled.
|
|
adapter.captureSound.Store(true)
|
|
return adapter
|
|
}
|
|
|
|
// SetCaptureSoundGeometry controls synchronous component and ancestor queries
|
|
// performed while copying event snapshots. It is safe to change while the
|
|
// adapter is running; the next callback observes the new setting.
|
|
func (adapter *Adapter) SetCaptureSoundGeometry(enabled bool) {
|
|
adapter.captureSound.Store(enabled)
|
|
}
|
|
|
|
// SetCaptureTextAttributes enables bounded native run capture for braille.
|
|
// Disabled by default to avoid extra provider queries on ordinary focus events.
|
|
func (adapter *Adapter) SetCaptureTextAttributes(enabled bool) {
|
|
adapter.captureTextAttributes.Store(enabled)
|
|
}
|
|
|
|
func (adapter *Adapter) Events() <-chan Event {
|
|
return adapter.events
|
|
}
|
|
|
|
func (adapter *Adapter) Overflow() uint64 {
|
|
return adapter.overflow.Load()
|
|
}
|
|
|
|
func (adapter *Adapter) TreeInvalidations() uint64 {
|
|
return adapter.treeInvalidations.Load()
|
|
}
|
|
|
|
// CaptureDesktop copies the current AT-SPI desktop into the adapter's owned
|
|
// tree on the native event-loop thread. Calls are serialized with shutdown so
|
|
// callback data cannot be released while a capture is pending.
|
|
func (adapter *Adapter) CaptureDesktop(ctx context.Context) (*AccessibleTree, error) {
|
|
return adapter.capture(ctx, ObjectID{}, "desktop", false, false)
|
|
}
|
|
|
|
// CaptureReviewWindow copies the focused application's tree into the owned
|
|
// model. Flat review needs only the focused frame or dialog, so excluding
|
|
// unrelated applications keeps capture bounded on busy desktops.
|
|
func (adapter *Adapter) CaptureReviewWindow(ctx context.Context, focus ObjectID) (*AccessibleTree, error) {
|
|
if !focus.Valid() {
|
|
return nil, errors.New("capture AT-SPI review window: invalid focus")
|
|
}
|
|
return adapter.capture(ctx, focus, "review window", false, false)
|
|
}
|
|
|
|
// CaptureDocument captures semantics without flat review's viewport text work.
|
|
// Its independent tree cannot replace text required by an active flat cursor.
|
|
func (adapter *Adapter) CaptureDocument(ctx context.Context, focus ObjectID) (*AccessibleTree, error) {
|
|
if !focus.Valid() {
|
|
return nil, errors.New("capture AT-SPI document: invalid focus")
|
|
}
|
|
return adapter.capture(ctx, focus, "document", true, false)
|
|
}
|
|
|
|
// CaptureDocumentText also captures visible text and range geometry. Line
|
|
// assembly needs those native details; structural navigation does not.
|
|
func (adapter *Adapter) CaptureDocumentText(ctx context.Context, focus ObjectID) (*AccessibleTree, error) {
|
|
if !focus.Valid() {
|
|
return nil, errors.New("capture AT-SPI document text: invalid focus")
|
|
}
|
|
return adapter.capture(ctx, focus, "document text", true, true)
|
|
}
|
|
|
|
// SetCaptureLogger enables capture diagnostics when the logger accepts debug
|
|
// records. It can be updated without touching the GLib thread or native state.
|
|
func (adapter *Adapter) SetCaptureLogger(logger *slog.Logger) {
|
|
adapter.captureLogger.Store(logger)
|
|
}
|
|
|
|
const providerHungCooldown = 2 * time.Second
|
|
|
|
// markProviderHung records a capture deadline for one application bus.
|
|
// Orca similarly remembers hung objects briefly so later commands skip
|
|
// straight past a provider that just stalled.
|
|
func (adapter *Adapter) markProviderHung(busName string, at time.Time) {
|
|
if busName == "" {
|
|
return
|
|
}
|
|
adapter.hungMutex.Lock()
|
|
defer adapter.hungMutex.Unlock()
|
|
if adapter.hungBuses == nil {
|
|
adapter.hungBuses = make(map[string]time.Time)
|
|
}
|
|
adapter.hungBuses[busName] = at
|
|
}
|
|
|
|
func (adapter *Adapter) providerHung(busName string, at time.Time) bool {
|
|
adapter.hungMutex.Lock()
|
|
defer adapter.hungMutex.Unlock()
|
|
since, ok := adapter.hungBuses[busName]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if at.Sub(since) > providerHungCooldown {
|
|
delete(adapter.hungBuses, busName)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captureName string, document, documentText bool) (tree *AccessibleTree, err error) {
|
|
if ctx == nil {
|
|
return nil, fmt.Errorf("capture AT-SPI %s: nil context", captureName)
|
|
}
|
|
ctx, cancel := adapter.secureRequestContext(ctx)
|
|
defer cancel()
|
|
if document && adapter.providerHung(reviewFocus.BusName, time.Now()) {
|
|
return nil, fmt.Errorf("%w: bus %q", ErrProviderHung, reviewFocus.BusName)
|
|
}
|
|
var trace *captureDiagnostics
|
|
if reviewFocus.Valid() {
|
|
ctx, trace = new_capture_diagnostics(ctx, adapter.captureLogger.Load(), reviewFocus)
|
|
if trace != nil && document {
|
|
trace.kind = "document"
|
|
}
|
|
defer func() { trace.finish(err) }()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
adapter.stopMutex.Lock()
|
|
defer adapter.stopMutex.Unlock()
|
|
if !adapter.ready.Load() || adapter.callbackData == nil {
|
|
return nil, fmt.Errorf("capture AT-SPI %s: adapter is not running", captureName)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request := treeCaptureRequest{
|
|
ctx: ctx,
|
|
reviewFocus: reviewFocus,
|
|
document: document,
|
|
documentText: documentText,
|
|
result: make(chan treeCaptureResult, 1),
|
|
}
|
|
trace.begin_stage("queue-wait", reviewFocus, "")
|
|
select {
|
|
case adapter.treeRequests <- request:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 {
|
|
<-adapter.treeRequests
|
|
return nil, fmt.Errorf("schedule AT-SPI %s capture", captureName)
|
|
}
|
|
select {
|
|
case result := <-request.result:
|
|
if document && errors.Is(result.err, context.DeadlineExceeded) {
|
|
adapter.markProviderHung(reviewFocus.BusName, time.Now())
|
|
}
|
|
return result.tree, result.err
|
|
case <-ctx.Done():
|
|
if document && errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
adapter.markProviderHung(reviewFocus.BusName, time.Now())
|
|
}
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) installTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) {
|
|
return adapter.installCapturedTree(root, nodes, false, false)
|
|
}
|
|
|
|
const maxStashedDocuments = 3
|
|
|
|
type stashedDocument struct {
|
|
tree, textTree *AccessibleTree
|
|
sources map[ObjectID]*C.AtspiAccessible
|
|
}
|
|
|
|
// Source membership survives tree invalidation until the next capture, so it
|
|
// can identify the owner of a removed focus without guessing from its bus.
|
|
func (adapter *Adapter) retainedDocumentRoot(id ObjectID) ObjectID {
|
|
adapter.treeMutex.Lock()
|
|
defer adapter.treeMutex.Unlock()
|
|
if _, retained := adapter.nativeDocument[id]; retained {
|
|
return adapter.documentSlotRoot
|
|
}
|
|
for _, root := range adapter.stashedDocumentIDs {
|
|
if _, retained := adapter.stashedDocuments[root].sources[id]; retained {
|
|
return root
|
|
}
|
|
}
|
|
return ObjectID{}
|
|
}
|
|
|
|
// rotateDocumentSlots makes root's retained trees and native sources the
|
|
// active document slots, stashing the previously active document. Capture
|
|
// calls this on the GLib thread once the capture root is known, before
|
|
// preparing the incremental-refresh cache.
|
|
func (adapter *Adapter) rotateDocumentSlots(root ObjectID) {
|
|
adapter.rotateDocumentSlotsAndRelease(root, releaseDocumentSources)
|
|
}
|
|
|
|
func releaseDocumentSources(document stashedDocument) {
|
|
for _, source := range document.sources {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
}
|
|
|
|
// libatspi disposal synchronously emits defunct events. Publish the new slots
|
|
// and unlock before releasing references, so reentrant invalidation sees a
|
|
// consistent cache and can acquire treeMutex.
|
|
func (adapter *Adapter) rotateDocumentSlotsAndRelease(root ObjectID, release func(stashedDocument)) {
|
|
if !root.Valid() {
|
|
return
|
|
}
|
|
var retired []stashedDocument
|
|
adapter.treeMutex.Lock()
|
|
defer func() {
|
|
adapter.treeMutex.Unlock()
|
|
for _, document := range retired {
|
|
release(document)
|
|
}
|
|
}()
|
|
if root == adapter.documentSlotRoot {
|
|
return
|
|
}
|
|
// Remove the requested slot before stashing the active one: at capacity,
|
|
// stashing first could evict the very document we are returning to.
|
|
stashed := adapter.stashedDocuments[root]
|
|
delete(adapter.stashedDocuments, root)
|
|
for index, id := range adapter.stashedDocumentIDs {
|
|
if id == root {
|
|
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if adapter.documentSlotRoot.Valid() &&
|
|
(adapter.documentTree != nil || adapter.documentTextTree != nil) {
|
|
retired = adapter.stashDocumentLocked(adapter.documentSlotRoot, stashedDocument{
|
|
tree: adapter.documentTree, textTree: adapter.documentTextTree,
|
|
sources: adapter.nativeDocument,
|
|
})
|
|
} else if len(adapter.nativeDocument) != 0 {
|
|
retired = append(retired, stashedDocument{sources: adapter.nativeDocument})
|
|
}
|
|
adapter.documentTree = stashed.tree
|
|
adapter.documentTextTree = stashed.textTree
|
|
adapter.nativeDocument = stashed.sources
|
|
adapter.documentSlotRoot = root
|
|
}
|
|
|
|
func (adapter *Adapter) stashDocumentLocked(root ObjectID, document stashedDocument) []stashedDocument {
|
|
var retired []stashedDocument
|
|
if adapter.stashedDocuments == nil {
|
|
adapter.stashedDocuments = make(map[ObjectID]stashedDocument)
|
|
}
|
|
if old, ok := adapter.stashedDocuments[root]; ok {
|
|
retired = append(retired, old)
|
|
for index, id := range adapter.stashedDocumentIDs {
|
|
if id == root {
|
|
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
adapter.stashedDocuments[root] = document
|
|
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs, root)
|
|
for len(adapter.stashedDocumentIDs) > maxStashedDocuments {
|
|
oldest := adapter.stashedDocumentIDs[0]
|
|
retired = append(retired, adapter.stashedDocuments[oldest])
|
|
delete(adapter.stashedDocuments, oldest)
|
|
adapter.stashedDocumentIDs = adapter.stashedDocumentIDs[1:]
|
|
}
|
|
return retired
|
|
}
|
|
|
|
// clearStashedDocuments releases every stashed document's native sources.
|
|
// Callers run on the GLib thread (secure reset, shutdown).
|
|
func (adapter *Adapter) clearStashedDocuments() {
|
|
adapter.treeMutex.Lock()
|
|
stashed := adapter.stashedDocuments
|
|
adapter.stashedDocuments = nil
|
|
adapter.stashedDocumentIDs = nil
|
|
adapter.documentSlotRoot = ObjectID{}
|
|
adapter.treeMutex.Unlock()
|
|
for _, document := range stashed {
|
|
for _, source := range document.sources {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) ownedTrees() []*AccessibleTree {
|
|
adapter.treeMutex.Lock()
|
|
defer adapter.treeMutex.Unlock()
|
|
return adapter.ownedTreesLocked()
|
|
}
|
|
|
|
func (adapter *Adapter) ownedTreesLocked() []*AccessibleTree {
|
|
trees := make([]*AccessibleTree, 0, 3+2*len(adapter.stashedDocuments))
|
|
for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
|
|
if tree != nil {
|
|
trees = append(trees, tree)
|
|
}
|
|
}
|
|
for _, id := range adapter.stashedDocumentIDs {
|
|
stashed := adapter.stashedDocuments[id]
|
|
if stashed.tree != nil {
|
|
trees = append(trees, stashed.tree)
|
|
}
|
|
if stashed.textTree != nil {
|
|
trees = append(trees, stashed.textTree)
|
|
}
|
|
}
|
|
return trees
|
|
}
|
|
|
|
func (adapter *Adapter) installCapturedTree(root ObjectID, nodes []TreeNode, document, documentText bool) (*AccessibleTree, error) {
|
|
adapter.treeMutex.Lock()
|
|
defer adapter.treeMutex.Unlock()
|
|
owned := &adapter.tree
|
|
if documentText {
|
|
owned = &adapter.documentTextTree
|
|
} else if document {
|
|
owned = &adapter.documentTree
|
|
}
|
|
if *owned == nil {
|
|
tree, err := NewAccessibleTree(root, nodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
*owned = tree
|
|
return tree, nil
|
|
}
|
|
if err := (*owned).Replace(root, nodes); err != nil {
|
|
return nil, err
|
|
}
|
|
return *owned, nil
|
|
}
|
|
|
|
func (adapter *Adapter) invalidateTree() {
|
|
adapter.treeMutex.Lock()
|
|
defer adapter.treeMutex.Unlock()
|
|
for _, tree := range adapter.ownedTreesLocked() {
|
|
root, err := tree.Root()
|
|
if err == nil {
|
|
adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID())))
|
|
}
|
|
}
|
|
}
|