feat: add computer control and managed browser
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AtspiSemanticCore,
|
||||
type AtspiRawNode,
|
||||
type AtspiTransport
|
||||
} from './atspi-semantic-core'
|
||||
|
||||
const transportFor = (
|
||||
nodes: Record<string, AtspiRawNode>,
|
||||
children: Record<string, string[]>
|
||||
): AtspiTransport => ({
|
||||
readNode: vi.fn(async (reference) => {
|
||||
const node = nodes[reference]
|
||||
if (!node) {
|
||||
throw new Error('missing node')
|
||||
}
|
||||
return node
|
||||
}),
|
||||
listChildren: vi.fn(async (reference) => children[reference] ?? []),
|
||||
invoke: vi.fn(async () => true),
|
||||
setText: vi.fn(async () => true),
|
||||
select: vi.fn(async () => true),
|
||||
focus: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const node = (
|
||||
nativeReference: string,
|
||||
overrides: Partial<AtspiRawNode> = {}
|
||||
): AtspiRawNode => ({
|
||||
nativeReference,
|
||||
owner: 'private-owner',
|
||||
window: 'private-window',
|
||||
role: 'push button',
|
||||
name: 'Action',
|
||||
states: ['enabled', 'enabled'],
|
||||
actions: ['Click'],
|
||||
geometry: { x: -10, y: 20, width: 100, height: 30 },
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('AtspiSemanticCore', () => {
|
||||
it('traverses a cyclic tree safely and returns only normalized opaque data', async () => {
|
||||
const transport = transportFor(
|
||||
{
|
||||
'/raw/root': node('/raw/root', {
|
||||
role: 'Application',
|
||||
name: ' Demo\u0000 app '
|
||||
}),
|
||||
'/raw/child': node('/raw/child')
|
||||
},
|
||||
{
|
||||
'/raw/root': ['/raw/child'],
|
||||
'/raw/child': ['/raw/root']
|
||||
}
|
||||
)
|
||||
let token = 0
|
||||
const core = new AtspiSemanticCore(transport, {
|
||||
createToken: () => `opaque-${++token}`
|
||||
})
|
||||
const tree = await core.snapshot('/raw/root', new AbortController().signal)
|
||||
const serialized = JSON.stringify(tree)
|
||||
|
||||
expect(tree.truncated).toBe(true)
|
||||
expect(tree.root).toMatchObject({
|
||||
ref: 'opaque-1',
|
||||
role: 'application',
|
||||
name: 'Demo app',
|
||||
states: ['enabled'],
|
||||
children: [
|
||||
{
|
||||
ref: 'opaque-2',
|
||||
role: 'push-button',
|
||||
actions: ['click'],
|
||||
children: []
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(serialized).not.toContain('/raw/')
|
||||
expect(serialized).not.toContain('private-owner')
|
||||
expect(serialized).not.toContain('private-window')
|
||||
})
|
||||
|
||||
it('bounds node count, depth, children, text, and invalid geometry', async () => {
|
||||
const transport = transportFor(
|
||||
{
|
||||
root: node('root', {
|
||||
text: 'abcdef',
|
||||
geometry: { x: 0, y: 0, width: -1, height: 10 }
|
||||
}),
|
||||
one: node('one'),
|
||||
two: node('two')
|
||||
},
|
||||
{ root: ['one', 'two'] }
|
||||
)
|
||||
let token = 0
|
||||
const core = new AtspiSemanticCore(transport, {
|
||||
createToken: () => `ref-${++token}`,
|
||||
maximumNodes: 2,
|
||||
maximumChildrenPerNode: 1,
|
||||
maximumTextLength: 3
|
||||
})
|
||||
const tree = await core.snapshot('root', new AbortController().signal)
|
||||
|
||||
expect(tree.truncated).toBe(true)
|
||||
expect(tree.root.text).toBe('abc')
|
||||
expect(tree.root.geometry).toBeUndefined()
|
||||
expect(tree.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('redacts and refuses protected and password elements', async () => {
|
||||
const transport = transportFor(
|
||||
{
|
||||
password: node('password', {
|
||||
role: 'password text',
|
||||
name: 'bank password',
|
||||
text: 'hunter2',
|
||||
value: 123,
|
||||
password: true
|
||||
})
|
||||
},
|
||||
{}
|
||||
)
|
||||
const core = new AtspiSemanticCore(transport, {
|
||||
createToken: () => 'protected-ref'
|
||||
})
|
||||
const tree = await core.snapshot(
|
||||
'password',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(tree.root).toMatchObject({
|
||||
ref: 'protected-ref',
|
||||
name: '受保护内容',
|
||||
actions: [],
|
||||
protected: true
|
||||
})
|
||||
expect(tree.root.text).toBeUndefined()
|
||||
expect(tree.root.value).toBeUndefined()
|
||||
await expect(
|
||||
core.focus('protected-ref', new AbortController().signal)
|
||||
).rejects.toThrow('Protected')
|
||||
expect(transport.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes semantic operations and rejects stale or unsupported references', async () => {
|
||||
let now = 10
|
||||
let token = 0
|
||||
const transport = transportFor(
|
||||
{
|
||||
first: node('first', { owner: 'owner-a', window: 'window-a' }),
|
||||
second: node('second', { owner: 'owner-b', window: 'window-b' })
|
||||
},
|
||||
{}
|
||||
)
|
||||
const core = new AtspiSemanticCore(transport, {
|
||||
now: () => now,
|
||||
referenceTtlMs: 50,
|
||||
createToken: () => `ref-${++token}`
|
||||
})
|
||||
await core.snapshot('first', new AbortController().signal)
|
||||
await expect(
|
||||
core.invoke('ref-1', 'CLICK', new AbortController().signal)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
core.setText('ref-1', 'new text', new AbortController().signal)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
core.select('ref-1', new AbortController().signal)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
core.invoke('ref-1', 'delete', new AbortController().signal)
|
||||
).rejects.toThrow('unavailable')
|
||||
|
||||
core.invalidateWindow('owner-a', 'window-a')
|
||||
await expect(
|
||||
core.focus('ref-1', new AbortController().signal)
|
||||
).rejects.toThrow('stale')
|
||||
|
||||
await core.snapshot('second', new AbortController().signal)
|
||||
core.invalidateOwner('owner-b')
|
||||
await expect(
|
||||
core.focus('ref-2', new AbortController().signal)
|
||||
).rejects.toThrow('stale')
|
||||
|
||||
await core.snapshot('first', new AbortController().signal)
|
||||
now = 60
|
||||
await expect(
|
||||
core.focus('ref-3', new AbortController().signal)
|
||||
).rejects.toThrow('stale')
|
||||
|
||||
now = 10
|
||||
await core.snapshot('first', new AbortController().signal)
|
||||
core.invalidateRegistryOwner()
|
||||
await expect(
|
||||
core.focus('ref-4', new AbortController().signal)
|
||||
).rejects.toThrow('stale')
|
||||
})
|
||||
|
||||
it('invalidates every reference from snapshot N when snapshot N+1 starts', async () => {
|
||||
let token = 0
|
||||
const core = new AtspiSemanticCore(
|
||||
transportFor(
|
||||
{
|
||||
first: node('first'),
|
||||
second: node('second')
|
||||
},
|
||||
{}
|
||||
),
|
||||
{ createToken: () => `generation-ref-${++token}` }
|
||||
)
|
||||
|
||||
await core.snapshot('first', new AbortController().signal)
|
||||
await core.snapshot('second', new AbortController().signal)
|
||||
|
||||
await expect(
|
||||
core.focus('generation-ref-1', new AbortController().signal)
|
||||
).rejects.toThrow('stale')
|
||||
await expect(
|
||||
core.focus('generation-ref-2', new AbortController().signal)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the opaque reference registry hard bounded across snapshots', async () => {
|
||||
let token = 0
|
||||
const core = new AtspiSemanticCore(
|
||||
transportFor(
|
||||
{
|
||||
root: node('root'),
|
||||
child: node('child'),
|
||||
extra: node('extra')
|
||||
},
|
||||
{ root: ['child', 'extra'] }
|
||||
),
|
||||
{
|
||||
maximumReferences: 2,
|
||||
createToken: () => `bounded-ref-${++token}`
|
||||
}
|
||||
)
|
||||
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
const tree = await core.snapshot(
|
||||
'root',
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(tree.truncated).toBe(true)
|
||||
expect(
|
||||
(
|
||||
core as unknown as {
|
||||
references: Map<string, unknown>
|
||||
}
|
||||
).references.size
|
||||
).toBeLessThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not surface raw transport errors or accept duplicate opaque tokens', async () => {
|
||||
const failedTransport = transportFor({ root: node('root') }, {})
|
||||
failedTransport.readNode = vi.fn(async () => {
|
||||
throw new Error('private bus path /org/a11y/atspi/accessible/123')
|
||||
})
|
||||
const failedCore = new AtspiSemanticCore(failedTransport)
|
||||
await expect(
|
||||
failedCore.snapshot('root', new AbortController().signal)
|
||||
).rejects.toThrow('could not be read')
|
||||
await expect(
|
||||
failedCore.snapshot('root', new AbortController().signal)
|
||||
).rejects.not.toThrow('/org/a11y/')
|
||||
|
||||
const duplicateCore = new AtspiSemanticCore(
|
||||
transportFor(
|
||||
{ root: node('root'), child: node('child') },
|
||||
{ root: ['child'] }
|
||||
),
|
||||
{ createToken: () => 'duplicate-token' }
|
||||
)
|
||||
await expect(
|
||||
duplicateCore.snapshot('root', new AbortController().signal)
|
||||
).rejects.toThrow('reference creation failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,442 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export type AtspiNativeReference = string
|
||||
|
||||
export type AtspiRect = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type AtspiRawNode = {
|
||||
nativeReference: AtspiNativeReference
|
||||
owner: string
|
||||
window: string
|
||||
role?: string
|
||||
name?: string
|
||||
states?: string[]
|
||||
actions?: string[]
|
||||
text?: string
|
||||
value?: number
|
||||
geometry?: AtspiRect
|
||||
protected?: boolean
|
||||
password?: boolean
|
||||
}
|
||||
|
||||
export interface AtspiTransport {
|
||||
readNode(
|
||||
reference: AtspiNativeReference,
|
||||
signal: AbortSignal
|
||||
): Promise<AtspiRawNode>
|
||||
listChildren(
|
||||
reference: AtspiNativeReference,
|
||||
signal: AbortSignal
|
||||
): Promise<AtspiNativeReference[]>
|
||||
invoke(
|
||||
reference: AtspiNativeReference,
|
||||
action: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean>
|
||||
setText(
|
||||
reference: AtspiNativeReference,
|
||||
text: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean>
|
||||
select(
|
||||
reference: AtspiNativeReference,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean>
|
||||
focus(
|
||||
reference: AtspiNativeReference,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean>
|
||||
}
|
||||
|
||||
export type SemanticElement = {
|
||||
ref: string
|
||||
role: string
|
||||
name: string
|
||||
states: string[]
|
||||
actions: string[]
|
||||
text?: string
|
||||
value?: number
|
||||
geometry?: AtspiRect
|
||||
protected: boolean
|
||||
children: SemanticElement[]
|
||||
}
|
||||
|
||||
export type SemanticTree = {
|
||||
generation: number
|
||||
truncated: boolean
|
||||
root: SemanticElement
|
||||
}
|
||||
|
||||
export type AtspiSemanticCoreOptions = {
|
||||
now?: () => number
|
||||
createToken?: () => string
|
||||
referenceTtlMs?: number
|
||||
maximumNodes?: number
|
||||
maximumDepth?: number
|
||||
maximumChildrenPerNode?: number
|
||||
maximumTextLength?: number
|
||||
maximumReferences?: number
|
||||
}
|
||||
|
||||
type StoredReference = {
|
||||
nativeReference: AtspiNativeReference
|
||||
owner: string
|
||||
window: string
|
||||
generation: number
|
||||
expiresAt: number
|
||||
protected: boolean
|
||||
actions: Set<string>
|
||||
}
|
||||
|
||||
const cleanText = (
|
||||
value: string | undefined,
|
||||
maximumLength: number,
|
||||
fallback = ''
|
||||
): string => {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
const clean = [...value]
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code >= 32 && code !== 127
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
return clean.slice(0, maximumLength) || fallback
|
||||
}
|
||||
|
||||
const normalizeIdentifier = (
|
||||
value: string | undefined,
|
||||
fallback: string
|
||||
): string => {
|
||||
const normalized = cleanText(value, 64, fallback)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
const normalizeGeometry = (geometry: AtspiRect | undefined): AtspiRect | undefined => {
|
||||
if (
|
||||
!geometry ||
|
||||
!Number.isFinite(geometry.x) ||
|
||||
!Number.isFinite(geometry.y) ||
|
||||
!Number.isFinite(geometry.width) ||
|
||||
!Number.isFinite(geometry.height) ||
|
||||
geometry.width < 0 ||
|
||||
geometry.height < 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
width: geometry.width,
|
||||
height: geometry.height
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveRoles = new Set([
|
||||
'password-text',
|
||||
'password',
|
||||
'secret',
|
||||
'credential'
|
||||
])
|
||||
|
||||
export class AtspiSemanticCore {
|
||||
private readonly now: () => number
|
||||
private readonly createToken: () => string
|
||||
private readonly referenceTtlMs: number
|
||||
private readonly maximumNodes: number
|
||||
private readonly maximumDepth: number
|
||||
private readonly maximumChildrenPerNode: number
|
||||
private readonly maximumTextLength: number
|
||||
private readonly maximumReferences: number
|
||||
private readonly references = new Map<string, StoredReference>()
|
||||
private generation = 0
|
||||
|
||||
constructor(
|
||||
private readonly transport: AtspiTransport,
|
||||
options: AtspiSemanticCoreOptions = {}
|
||||
) {
|
||||
this.now = options.now ?? Date.now
|
||||
this.createToken =
|
||||
options.createToken ?? (() => randomBytes(24).toString('base64url'))
|
||||
this.referenceTtlMs = options.referenceTtlMs ?? 3_000
|
||||
this.maximumNodes = options.maximumNodes ?? 500
|
||||
this.maximumDepth = options.maximumDepth ?? 24
|
||||
this.maximumChildrenPerNode = options.maximumChildrenPerNode ?? 100
|
||||
this.maximumTextLength = options.maximumTextLength ?? 4_096
|
||||
this.maximumReferences =
|
||||
options.maximumReferences ?? this.maximumNodes
|
||||
if (
|
||||
!Number.isSafeInteger(this.maximumReferences) ||
|
||||
this.maximumReferences < 1 ||
|
||||
this.maximumReferences > 10_000
|
||||
) {
|
||||
throw new Error('Invalid semantic reference capacity')
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
rootReference: AtspiNativeReference,
|
||||
signal: AbortSignal
|
||||
): Promise<SemanticTree> {
|
||||
this.generation += 1
|
||||
const snapshotGeneration = this.generation
|
||||
this.pruneReferences()
|
||||
const visited = new Set<AtspiNativeReference>()
|
||||
let remaining = Math.min(
|
||||
this.maximumNodes,
|
||||
this.maximumReferences
|
||||
)
|
||||
let truncated = false
|
||||
|
||||
const visit = async (
|
||||
reference: AtspiNativeReference,
|
||||
depth: number
|
||||
): Promise<SemanticElement | undefined> => {
|
||||
if (
|
||||
signal.aborted ||
|
||||
visited.has(reference) ||
|
||||
depth > this.maximumDepth ||
|
||||
remaining <= 0
|
||||
) {
|
||||
truncated = true
|
||||
return undefined
|
||||
}
|
||||
visited.add(reference)
|
||||
remaining -= 1
|
||||
let raw: AtspiRawNode
|
||||
try {
|
||||
raw = await this.transport.readNode(reference, signal)
|
||||
} catch {
|
||||
throw new Error('Accessibility element could not be read')
|
||||
}
|
||||
if (snapshotGeneration !== this.generation) {
|
||||
throw new Error('Accessibility snapshot was superseded')
|
||||
}
|
||||
const role = normalizeIdentifier(raw.role, 'unknown')
|
||||
const isProtected =
|
||||
raw.protected === true ||
|
||||
raw.password === true ||
|
||||
sensitiveRoles.has(role)
|
||||
const actions = [
|
||||
...new Set(
|
||||
(raw.actions ?? []).map((action) =>
|
||||
normalizeIdentifier(action, 'action')
|
||||
)
|
||||
)
|
||||
].slice(0, 32)
|
||||
const states = [
|
||||
...new Set(
|
||||
(raw.states ?? []).map((state) =>
|
||||
normalizeIdentifier(state, 'state')
|
||||
)
|
||||
)
|
||||
].slice(0, 64)
|
||||
const opaqueReference = this.createToken()
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{5,256}$/.test(opaqueReference) ||
|
||||
this.references.has(opaqueReference)
|
||||
) {
|
||||
throw new Error('Opaque accessibility reference creation failed')
|
||||
}
|
||||
while (this.references.size >= this.maximumReferences) {
|
||||
const oldest = this.references.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.references.delete(oldest)
|
||||
truncated = true
|
||||
}
|
||||
this.references.set(opaqueReference, {
|
||||
nativeReference: raw.nativeReference,
|
||||
owner: raw.owner,
|
||||
window: raw.window,
|
||||
generation: snapshotGeneration,
|
||||
expiresAt: this.now() + this.referenceTtlMs,
|
||||
protected: isProtected,
|
||||
actions: new Set(actions)
|
||||
})
|
||||
const element: SemanticElement = {
|
||||
ref: opaqueReference,
|
||||
role,
|
||||
name: isProtected
|
||||
? '受保护内容'
|
||||
: cleanText(raw.name, 256, role),
|
||||
states,
|
||||
actions: isProtected ? [] : actions,
|
||||
protected: isProtected,
|
||||
children: []
|
||||
}
|
||||
if (!isProtected) {
|
||||
const text = cleanText(raw.text, this.maximumTextLength)
|
||||
if (text) {
|
||||
element.text = text
|
||||
}
|
||||
if (raw.value !== undefined && Number.isFinite(raw.value)) {
|
||||
element.value = raw.value
|
||||
}
|
||||
}
|
||||
const geometry = normalizeGeometry(raw.geometry)
|
||||
if (geometry) {
|
||||
element.geometry = geometry
|
||||
}
|
||||
|
||||
if (depth === this.maximumDepth || remaining <= 0) {
|
||||
truncated = true
|
||||
return element
|
||||
}
|
||||
let children: AtspiNativeReference[]
|
||||
try {
|
||||
children = await this.transport.listChildren(reference, signal)
|
||||
} catch {
|
||||
throw new Error('Accessibility children could not be read')
|
||||
}
|
||||
if (snapshotGeneration !== this.generation) {
|
||||
throw new Error('Accessibility snapshot was superseded')
|
||||
}
|
||||
if (children.length > this.maximumChildrenPerNode) {
|
||||
truncated = true
|
||||
}
|
||||
for (const child of children.slice(0, this.maximumChildrenPerNode)) {
|
||||
const normalized = await visit(child, depth + 1)
|
||||
if (normalized) {
|
||||
element.children.push(normalized)
|
||||
}
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
const root = await visit(rootReference, 0)
|
||||
if (snapshotGeneration !== this.generation) {
|
||||
throw new Error('Accessibility snapshot was superseded')
|
||||
}
|
||||
if (!root) {
|
||||
throw new Error('Accessibility tree root is unavailable')
|
||||
}
|
||||
return {
|
||||
generation: snapshotGeneration,
|
||||
truncated,
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
invalidateRegistryOwner(): void {
|
||||
this.generation += 1
|
||||
this.references.clear()
|
||||
}
|
||||
|
||||
invalidateOwner(owner: string): void {
|
||||
for (const [reference, stored] of this.references) {
|
||||
if (stored.owner === owner) {
|
||||
this.references.delete(reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateWindow(owner: string, window: string): void {
|
||||
for (const [reference, stored] of this.references) {
|
||||
if (stored.owner === owner && stored.window === window) {
|
||||
this.references.delete(reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async invoke(
|
||||
reference: string,
|
||||
action: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const stored = this.resolve(reference)
|
||||
const normalizedAction = normalizeIdentifier(action, 'action')
|
||||
if (!stored.actions.has(normalizedAction)) {
|
||||
throw new Error('Semantic action is unavailable')
|
||||
}
|
||||
try {
|
||||
return await this.transport.invoke(
|
||||
stored.nativeReference,
|
||||
normalizedAction,
|
||||
signal
|
||||
)
|
||||
} catch {
|
||||
throw new Error('Semantic action failed')
|
||||
}
|
||||
}
|
||||
|
||||
async setText(
|
||||
reference: string,
|
||||
text: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const stored = this.resolve(reference)
|
||||
if (text.length > this.maximumTextLength) {
|
||||
throw new Error('Text exceeds the semantic input limit')
|
||||
}
|
||||
try {
|
||||
return await this.transport.setText(stored.nativeReference, text, signal)
|
||||
} catch {
|
||||
throw new Error('Semantic text update failed')
|
||||
}
|
||||
}
|
||||
|
||||
async select(
|
||||
reference: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const stored = this.resolve(reference)
|
||||
try {
|
||||
return await this.transport.select(stored.nativeReference, signal)
|
||||
} catch {
|
||||
throw new Error('Semantic selection failed')
|
||||
}
|
||||
}
|
||||
|
||||
async focus(
|
||||
reference: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const stored = this.resolve(reference)
|
||||
try {
|
||||
return await this.transport.focus(stored.nativeReference, signal)
|
||||
} catch {
|
||||
throw new Error('Semantic focus failed')
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(reference: string): StoredReference {
|
||||
this.pruneReferences()
|
||||
const stored = this.references.get(reference)
|
||||
if (
|
||||
!stored ||
|
||||
stored.generation !== this.generation ||
|
||||
this.now() >= stored.expiresAt
|
||||
) {
|
||||
this.references.delete(reference)
|
||||
throw new Error('Semantic element is stale')
|
||||
}
|
||||
if (stored.protected) {
|
||||
throw new Error('Protected semantic element cannot be controlled')
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
private pruneReferences(): void {
|
||||
const timestamp = this.now()
|
||||
for (const [reference, stored] of this.references) {
|
||||
if (
|
||||
stored.generation !== this.generation ||
|
||||
timestamp >= stored.expiresAt
|
||||
) {
|
||||
this.references.delete(reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
probeLinuxDesktopCapabilities,
|
||||
type LinuxCapabilityProbes
|
||||
} from './capability-probe'
|
||||
|
||||
const probes = (
|
||||
overrides: Partial<LinuxCapabilityProbes> = {}
|
||||
): LinuxCapabilityProbes => ({
|
||||
sessionBus: async () => true,
|
||||
accessibilityBus: async () => ({
|
||||
busOwner: true,
|
||||
registryOwner: true
|
||||
}),
|
||||
portalVersions: async () => ({ screenCast: 4, remoteDesktop: 2 }),
|
||||
pipeWire: async () => true,
|
||||
eis: async () => true,
|
||||
x11: async () => false,
|
||||
xTest: async () => false,
|
||||
ydotool: async () => false,
|
||||
electronCapture: async () => true,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('probeLinuxDesktopCapabilities', () => {
|
||||
it('reports concrete protocol checks without using desktop labels', async () => {
|
||||
const result = await probeLinuxDesktopCapabilities(probes(), {
|
||||
XDG_CURRENT_DESKTOP: 'GNOME:Treeland'
|
||||
})
|
||||
|
||||
expect(result.overall).toBe('supported')
|
||||
expect(result.sessionLabels).toEqual(['GNOME', 'Treeland'])
|
||||
expect(result.checks).toContainEqual({
|
||||
capability: 'portal-remote-desktop',
|
||||
status: 'supported',
|
||||
diagnostic: 'RemoteDesktop portal version 2 responded'
|
||||
})
|
||||
expect(result.checks.find((check) => check.capability === 'xtest')?.status)
|
||||
.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('does not infer availability from a known desktop product name', async () => {
|
||||
const unavailable = probes({
|
||||
sessionBus: async () => false,
|
||||
accessibilityBus: async () => ({
|
||||
busOwner: false,
|
||||
registryOwner: false
|
||||
}),
|
||||
portalVersions: async () => ({}),
|
||||
pipeWire: async () => false,
|
||||
eis: async () => false,
|
||||
electronCapture: async () => false
|
||||
})
|
||||
const result = await probeLinuxDesktopCapabilities(unavailable, {
|
||||
XDG_CURRENT_DESKTOP: 'KDE'
|
||||
})
|
||||
|
||||
expect(result.overall).toBe('unavailable')
|
||||
expect(result.sessionLabels).toEqual(['KDE'])
|
||||
expect(result.checks.every((check) => check.status === 'unavailable')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('contains bounded diagnostics rather than thrown details or environment values', async () => {
|
||||
const result = await probeLinuxDesktopCapabilities(
|
||||
probes({
|
||||
sessionBus: async () => {
|
||||
throw new Error(
|
||||
'address=unix:path=/run/user/1000/bus token=very-secret'
|
||||
)
|
||||
},
|
||||
ydotool: async () => true,
|
||||
accessibilityBus: async () => ({
|
||||
busOwner: true,
|
||||
registryOwner: false
|
||||
})
|
||||
}),
|
||||
{ DBUS_SESSION_BUS_ADDRESS: 'private-address' }
|
||||
)
|
||||
const diagnostics = result.checks
|
||||
.map((check) => check.diagnostic)
|
||||
.join(' ')
|
||||
|
||||
expect(diagnostics).not.toContain('/run/')
|
||||
expect(diagnostics).not.toContain('secret')
|
||||
expect(diagnostics).not.toContain('private-address')
|
||||
expect(
|
||||
result.checks.find((check) => check.capability === 'accessibility')
|
||||
?.status
|
||||
).toBe('degraded')
|
||||
expect(
|
||||
result.checks.find((check) => check.capability === 'ydotool')?.status
|
||||
).toBe('supported')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
export type CapabilityStatus = 'supported' | 'degraded' | 'unavailable'
|
||||
|
||||
export type CapabilityCheck = {
|
||||
capability:
|
||||
| 'session-bus'
|
||||
| 'accessibility'
|
||||
| 'portal-screen-cast'
|
||||
| 'portal-remote-desktop'
|
||||
| 'pipewire'
|
||||
| 'eis'
|
||||
| 'x11'
|
||||
| 'xtest'
|
||||
| 'ydotool'
|
||||
| 'electron-capture'
|
||||
status: CapabilityStatus
|
||||
diagnostic: string
|
||||
}
|
||||
|
||||
export type LinuxDesktopCapabilities = {
|
||||
overall: CapabilityStatus
|
||||
checks: CapabilityCheck[]
|
||||
sessionLabels: string[]
|
||||
}
|
||||
|
||||
export interface LinuxCapabilityProbes {
|
||||
sessionBus(): Promise<boolean>
|
||||
accessibilityBus(): Promise<{
|
||||
busOwner: boolean
|
||||
registryOwner: boolean
|
||||
}>
|
||||
portalVersions(): Promise<{
|
||||
screenCast?: number
|
||||
remoteDesktop?: number
|
||||
}>
|
||||
pipeWire(): Promise<boolean>
|
||||
eis(): Promise<boolean>
|
||||
x11(): Promise<boolean>
|
||||
xTest(): Promise<boolean>
|
||||
ydotool(): Promise<boolean>
|
||||
electronCapture(): Promise<boolean>
|
||||
}
|
||||
|
||||
const diagnostic = (
|
||||
available: boolean,
|
||||
positive: string,
|
||||
negative: string
|
||||
): string => (available ? positive : negative)
|
||||
|
||||
const statusRank: Record<CapabilityStatus, number> = {
|
||||
unavailable: 0,
|
||||
degraded: 1,
|
||||
supported: 2
|
||||
}
|
||||
|
||||
const settle = async <T>(
|
||||
probe: () => Promise<T>,
|
||||
fallback: T
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await probe()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const sessionLabels = (source: NodeJS.ProcessEnv): string[] => {
|
||||
const text = [
|
||||
source.XDG_CURRENT_DESKTOP,
|
||||
source.XDG_SESSION_DESKTOP,
|
||||
source.DESKTOP_SESSION
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.join(':')
|
||||
.toLowerCase()
|
||||
const labels = [
|
||||
['gnome', 'GNOME'],
|
||||
['kde', 'KDE'],
|
||||
['deepin', 'DDE'],
|
||||
['dde', 'DDE'],
|
||||
['treeland', 'Treeland'],
|
||||
['ukui', 'UKUI']
|
||||
] as const
|
||||
return [
|
||||
...new Set(
|
||||
labels
|
||||
.filter(([needle]) => text.includes(needle))
|
||||
.map(([, label]) => label)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes protocols and concrete operations. Desktop names are intentionally
|
||||
* excluded from capability decisions and are returned only as diagnostics.
|
||||
*/
|
||||
export async function probeLinuxDesktopCapabilities(
|
||||
probes: LinuxCapabilityProbes,
|
||||
environment: NodeJS.ProcessEnv = process.env
|
||||
): Promise<LinuxDesktopCapabilities> {
|
||||
const [
|
||||
bus,
|
||||
accessibility,
|
||||
portals,
|
||||
pipeWire,
|
||||
eis,
|
||||
x11,
|
||||
xTest,
|
||||
ydotool,
|
||||
electronCapture
|
||||
] = await Promise.all([
|
||||
settle(() => probes.sessionBus(), false),
|
||||
settle(() => probes.accessibilityBus(), {
|
||||
busOwner: false,
|
||||
registryOwner: false
|
||||
}),
|
||||
settle(() => probes.portalVersions(), {}),
|
||||
settle(() => probes.pipeWire(), false),
|
||||
settle(() => probes.eis(), false),
|
||||
settle(() => probes.x11(), false),
|
||||
settle(() => probes.xTest(), false),
|
||||
settle(() => probes.ydotool(), false),
|
||||
settle(() => probes.electronCapture(), false)
|
||||
])
|
||||
|
||||
const screenCast = portals.screenCast ?? 0
|
||||
const remoteDesktop = portals.remoteDesktop ?? 0
|
||||
const checks: CapabilityCheck[] = [
|
||||
{
|
||||
capability: 'session-bus',
|
||||
status: bus ? 'supported' : 'unavailable',
|
||||
diagnostic: diagnostic(
|
||||
bus,
|
||||
'Session bus responded',
|
||||
'Session bus did not respond'
|
||||
)
|
||||
},
|
||||
{
|
||||
capability: 'accessibility',
|
||||
status:
|
||||
accessibility.busOwner && accessibility.registryOwner
|
||||
? 'supported'
|
||||
: accessibility.busOwner
|
||||
? 'degraded'
|
||||
: 'unavailable',
|
||||
diagnostic:
|
||||
accessibility.busOwner && accessibility.registryOwner
|
||||
? 'Accessibility bus and registry responded'
|
||||
: accessibility.busOwner
|
||||
? 'Accessibility bus responded but registry is unavailable'
|
||||
: 'Accessibility bus is unavailable'
|
||||
},
|
||||
{
|
||||
capability: 'portal-screen-cast',
|
||||
status: screenCast > 0 ? 'supported' : 'unavailable',
|
||||
diagnostic:
|
||||
screenCast > 0
|
||||
? `ScreenCast portal version ${screenCast} responded`
|
||||
: 'ScreenCast portal is unavailable'
|
||||
},
|
||||
{
|
||||
capability: 'portal-remote-desktop',
|
||||
status: remoteDesktop > 0 ? 'supported' : 'unavailable',
|
||||
diagnostic:
|
||||
remoteDesktop > 0
|
||||
? `RemoteDesktop portal version ${remoteDesktop} responded`
|
||||
: 'RemoteDesktop portal is unavailable'
|
||||
},
|
||||
{
|
||||
capability: 'pipewire',
|
||||
status: pipeWire ? 'supported' : 'unavailable',
|
||||
diagnostic: diagnostic(
|
||||
pipeWire,
|
||||
'PipeWire connection succeeded',
|
||||
'PipeWire connection failed'
|
||||
)
|
||||
},
|
||||
{
|
||||
capability: 'eis',
|
||||
status: eis ? 'supported' : 'unavailable',
|
||||
diagnostic: diagnostic(
|
||||
eis,
|
||||
'EIS helper handshake succeeded',
|
||||
'EIS helper handshake failed'
|
||||
)
|
||||
},
|
||||
{
|
||||
capability: 'x11',
|
||||
status: x11 ? 'supported' : 'unavailable',
|
||||
diagnostic: diagnostic(
|
||||
x11,
|
||||
'X11 connection succeeded',
|
||||
'X11 connection failed'
|
||||
)
|
||||
},
|
||||
{
|
||||
capability: 'xtest',
|
||||
status: x11 && xTest ? 'supported' : 'unavailable',
|
||||
diagnostic:
|
||||
x11 && xTest
|
||||
? 'XTest extension query succeeded'
|
||||
: 'XTest is unavailable on the proven display connection'
|
||||
},
|
||||
{
|
||||
capability: 'ydotool',
|
||||
status: ydotool ? 'supported' : 'unavailable',
|
||||
diagnostic: ydotool
|
||||
? 'Optional ydotool helper passed its direct probe'
|
||||
: 'Optional ydotool helper is unavailable'
|
||||
},
|
||||
{
|
||||
capability: 'electron-capture',
|
||||
status: electronCapture ? 'supported' : 'unavailable',
|
||||
diagnostic: diagnostic(
|
||||
electronCapture,
|
||||
'Electron one-shot capture succeeded',
|
||||
'Electron one-shot capture failed'
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const usable = checks.filter(
|
||||
(check) =>
|
||||
check.capability !== 'session-bus' &&
|
||||
check.capability !== 'ydotool'
|
||||
)
|
||||
const best = usable.reduce<CapabilityStatus>(
|
||||
(current, check) =>
|
||||
statusRank[check.status] > statusRank[current] ? check.status : current,
|
||||
'unavailable'
|
||||
)
|
||||
|
||||
return {
|
||||
overall: best,
|
||||
checks,
|
||||
sessionLabels: sessionLabels(environment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CoordinateMapper,
|
||||
type CapturedFrameLayout,
|
||||
type DesktopLayout
|
||||
} from './coordinate-mapper'
|
||||
|
||||
const layout: DesktopLayout = {
|
||||
revision: 7,
|
||||
capturedAt: 1_000,
|
||||
displays: [
|
||||
{
|
||||
id: 'left',
|
||||
logicalBounds: { x: -1024, y: -100, width: 1024, height: 768 },
|
||||
scale: 1.25,
|
||||
rotation: 0
|
||||
},
|
||||
{
|
||||
id: 'rotated',
|
||||
logicalBounds: { x: 0, y: 0, width: 800, height: 600 },
|
||||
scale: 2,
|
||||
rotation: 90
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const frame = (
|
||||
overrides: Partial<CapturedFrameLayout> = {}
|
||||
): CapturedFrameLayout => ({
|
||||
frameId: 'frame-1',
|
||||
displayId: 'left',
|
||||
sourceBounds: { x: -1024, y: -100, width: 1024, height: 768 },
|
||||
width: 1280,
|
||||
height: 960,
|
||||
layoutRevision: 7,
|
||||
capturedAt: 1_001,
|
||||
expiresAt: 2_000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('CoordinateMapper', () => {
|
||||
it('maps negative origins and fractional display scale', () => {
|
||||
const mapper = new CoordinateMapper(() => 1_100)
|
||||
mapper.updateLayout(layout)
|
||||
|
||||
expect(mapper.mapPoint({ x: -512, y: 284 }, frame())).toEqual({
|
||||
frameId: 'frame-1',
|
||||
x: 640,
|
||||
y: 480
|
||||
})
|
||||
})
|
||||
|
||||
it('maps rotated displays into their physical frame orientation', () => {
|
||||
const mapper = new CoordinateMapper(() => 1_100)
|
||||
mapper.updateLayout(layout)
|
||||
const rotated = frame({
|
||||
frameId: 'rotated-frame',
|
||||
displayId: 'rotated',
|
||||
sourceBounds: { x: 0, y: 0, width: 800, height: 600 },
|
||||
width: 1200,
|
||||
height: 1600
|
||||
})
|
||||
|
||||
expect(mapper.mapPoint({ x: 400, y: 300 }, rotated)).toEqual({
|
||||
frameId: 'rotated-frame',
|
||||
x: 600,
|
||||
y: 800
|
||||
})
|
||||
expect(mapper.mapPoint({ x: 0, y: 0 }, rotated)).toEqual({
|
||||
frameId: 'rotated-frame',
|
||||
x: 1199,
|
||||
y: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('maps bounded rectangles without crossing frame edges', () => {
|
||||
const mapper = new CoordinateMapper(() => 1_100)
|
||||
mapper.updateLayout(layout)
|
||||
|
||||
expect(
|
||||
mapper.mapRect(
|
||||
{ x: -1024, y: -100, width: 1024, height: 768 },
|
||||
frame()
|
||||
)
|
||||
).toEqual({ x: 0, y: 0, width: 1280, height: 960 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
frame({ layoutRevision: 6 }),
|
||||
frame({ expiresAt: 1_100 }),
|
||||
frame({ width: 1270 }),
|
||||
frame({
|
||||
sourceBounds: { x: -2000, y: -100, width: 1024, height: 768 }
|
||||
})
|
||||
])('rejects stale or inconsistent captured frame layout', (captured) => {
|
||||
const mapper = new CoordinateMapper(() => 1_100)
|
||||
mapper.updateLayout(layout)
|
||||
expect(() => mapper.mapPoint({ x: -512, y: 284 }, captured)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects stale and non-monotonic desktop layouts', () => {
|
||||
const mapper = new CoordinateMapper(() => 7_000)
|
||||
mapper.updateLayout(layout)
|
||||
expect(() => mapper.mapPoint({ x: -512, y: 284 }, frame())).toThrow(
|
||||
'stale'
|
||||
)
|
||||
expect(() => mapper.updateLayout(layout)).toThrow('revision is stale')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
export type DesktopPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type DesktopRect = DesktopPoint & {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type DisplayRotation = 0 | 90 | 180 | 270
|
||||
|
||||
export type DisplayLayout = {
|
||||
id: string
|
||||
logicalBounds: DesktopRect
|
||||
scale: number
|
||||
rotation: DisplayRotation
|
||||
}
|
||||
|
||||
export type DesktopLayout = {
|
||||
revision: number
|
||||
capturedAt: number
|
||||
displays: DisplayLayout[]
|
||||
}
|
||||
|
||||
export type CapturedFrameLayout = {
|
||||
frameId: string
|
||||
displayId: string
|
||||
sourceBounds: DesktopRect
|
||||
width: number
|
||||
height: number
|
||||
layoutRevision: number
|
||||
capturedAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type FramePoint = {
|
||||
frameId: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const finiteRect = (rect: DesktopRect): boolean =>
|
||||
Number.isFinite(rect.x) &&
|
||||
Number.isFinite(rect.y) &&
|
||||
Number.isFinite(rect.width) &&
|
||||
Number.isFinite(rect.height) &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
|
||||
const containsPoint = (rect: DesktopRect, point: DesktopPoint): boolean =>
|
||||
point.x >= rect.x &&
|
||||
point.y >= rect.y &&
|
||||
point.x < rect.x + rect.width &&
|
||||
point.y < rect.y + rect.height
|
||||
|
||||
const containsRect = (outer: DesktopRect, inner: DesktopRect): boolean =>
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
|
||||
export class CoordinateMapper {
|
||||
private layout: DesktopLayout | undefined
|
||||
|
||||
constructor(
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly maximumLayoutAgeMs = 5_000
|
||||
) {}
|
||||
|
||||
updateLayout(layout: DesktopLayout): void {
|
||||
if (
|
||||
!Number.isSafeInteger(layout.revision) ||
|
||||
layout.revision < 1 ||
|
||||
!Number.isFinite(layout.capturedAt) ||
|
||||
layout.displays.length === 0 ||
|
||||
layout.displays.some(
|
||||
(display) =>
|
||||
!display.id ||
|
||||
!finiteRect(display.logicalBounds) ||
|
||||
!Number.isFinite(display.scale) ||
|
||||
display.scale <= 0 ||
|
||||
display.scale > 8
|
||||
) ||
|
||||
new Set(layout.displays.map((display) => display.id)).size !==
|
||||
layout.displays.length
|
||||
) {
|
||||
throw new Error('Invalid desktop layout')
|
||||
}
|
||||
if (this.layout && layout.revision <= this.layout.revision) {
|
||||
throw new Error('Desktop layout revision is stale')
|
||||
}
|
||||
this.layout = structuredClone(layout)
|
||||
}
|
||||
|
||||
mapPoint(
|
||||
point: DesktopPoint,
|
||||
frame: CapturedFrameLayout
|
||||
): FramePoint {
|
||||
const { display } = this.validateFrame(frame)
|
||||
if (
|
||||
!Number.isFinite(point.x) ||
|
||||
!Number.isFinite(point.y) ||
|
||||
!containsPoint(frame.sourceBounds, point)
|
||||
) {
|
||||
throw new Error('Desktop point is outside the captured frame')
|
||||
}
|
||||
|
||||
const u = (point.x - frame.sourceBounds.x) / frame.sourceBounds.width
|
||||
const v = (point.y - frame.sourceBounds.y) / frame.sourceBounds.height
|
||||
const rotated = this.rotate(u, v, display.rotation)
|
||||
return {
|
||||
frameId: frame.frameId,
|
||||
x: Math.min(frame.width - 1, Math.max(0, Math.floor(rotated.x * frame.width))),
|
||||
y: Math.min(
|
||||
frame.height - 1,
|
||||
Math.max(0, Math.floor(rotated.y * frame.height))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
mapRect(rect: DesktopRect, frame: CapturedFrameLayout): DesktopRect {
|
||||
if (!finiteRect(rect) || !containsRect(frame.sourceBounds, rect)) {
|
||||
throw new Error('Desktop rectangle is outside the captured frame')
|
||||
}
|
||||
const right = rect.x + rect.width
|
||||
const bottom = rect.y + rect.height
|
||||
const xInset = Math.min(rect.width / 2, Math.max(1e-9, rect.width * 1e-9))
|
||||
const yInset = Math.min(
|
||||
rect.height / 2,
|
||||
Math.max(1e-9, rect.height * 1e-9)
|
||||
)
|
||||
const points = [
|
||||
this.mapPoint({ x: rect.x, y: rect.y }, frame),
|
||||
this.mapPoint({ x: right - xInset, y: rect.y }, frame),
|
||||
this.mapPoint({ x: rect.x, y: bottom - yInset }, frame),
|
||||
this.mapPoint(
|
||||
{ x: right - xInset, y: bottom - yInset },
|
||||
frame
|
||||
)
|
||||
]
|
||||
const xs = points.map((point) => point.x)
|
||||
const ys = points.map((point) => point.y)
|
||||
const x = Math.min(...xs)
|
||||
const y = Math.min(...ys)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(...xs) - x + 1,
|
||||
height: Math.max(...ys) - y + 1
|
||||
}
|
||||
}
|
||||
|
||||
private validateFrame(frame: CapturedFrameLayout): {
|
||||
display: DisplayLayout
|
||||
} {
|
||||
const layout = this.layout
|
||||
if (!layout) {
|
||||
throw new Error('Desktop layout is unavailable')
|
||||
}
|
||||
if (
|
||||
this.now() - layout.capturedAt >= this.maximumLayoutAgeMs ||
|
||||
frame.layoutRevision !== layout.revision ||
|
||||
this.now() >= frame.expiresAt ||
|
||||
frame.capturedAt < layout.capturedAt
|
||||
) {
|
||||
throw new Error('Desktop layout or captured frame is stale')
|
||||
}
|
||||
const display = layout.displays.find(
|
||||
(candidate) => candidate.id === frame.displayId
|
||||
)
|
||||
if (
|
||||
!display ||
|
||||
!frame.frameId ||
|
||||
!finiteRect(frame.sourceBounds) ||
|
||||
!containsRect(display.logicalBounds, frame.sourceBounds) ||
|
||||
!Number.isSafeInteger(frame.width) ||
|
||||
frame.width < 1 ||
|
||||
!Number.isSafeInteger(frame.height) ||
|
||||
frame.height < 1
|
||||
) {
|
||||
throw new Error('Invalid captured frame layout')
|
||||
}
|
||||
const rotated = display.rotation === 90 || display.rotation === 270
|
||||
const expectedWidth =
|
||||
(rotated ? frame.sourceBounds.height : frame.sourceBounds.width) *
|
||||
display.scale
|
||||
const expectedHeight =
|
||||
(rotated ? frame.sourceBounds.width : frame.sourceBounds.height) *
|
||||
display.scale
|
||||
if (
|
||||
Math.abs(frame.width - expectedWidth) > 1 ||
|
||||
Math.abs(frame.height - expectedHeight) > 1
|
||||
) {
|
||||
throw new Error('Captured frame scale does not match the display')
|
||||
}
|
||||
return { display }
|
||||
}
|
||||
|
||||
private rotate(u: number, v: number, rotation: DisplayRotation): DesktopPoint {
|
||||
switch (rotation) {
|
||||
case 0:
|
||||
return { x: u, y: v }
|
||||
case 90:
|
||||
return { x: 1 - v, y: u }
|
||||
case 180:
|
||||
return { x: 1 - u, y: 1 - v }
|
||||
case 270:
|
||||
return { x: v, y: 1 - u }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LinuxDesktopInputRouter,
|
||||
type InjectedInputBackend,
|
||||
type SemanticInput,
|
||||
type YdotoolInputBackend
|
||||
} from './input-router'
|
||||
|
||||
const semantic = (result: boolean): SemanticInput => ({
|
||||
perform: vi.fn(async () => result)
|
||||
})
|
||||
|
||||
const backend = (): InjectedInputBackend => ({
|
||||
inject: vi.fn(async () => undefined),
|
||||
releasePressedInput: vi.fn(async () => undefined)
|
||||
})
|
||||
|
||||
describe('LinuxDesktopInputRouter', () => {
|
||||
it('always attempts the semantic action before native injection', async () => {
|
||||
const portal = backend()
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(true),
|
||||
portal,
|
||||
portalConsentActive: () => true,
|
||||
provenX11: () => false
|
||||
})
|
||||
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'text', text: 'hello' },
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual({ route: 'semantic' })
|
||||
expect(portal.inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses portal input only while user consent is active', async () => {
|
||||
let consent = false
|
||||
const portal = backend()
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portal,
|
||||
portalConsentActive: () => consent,
|
||||
provenX11: () => false
|
||||
})
|
||||
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'pointer-move', x: 1, y: 2 },
|
||||
new AbortController().signal,
|
||||
{ backend: 'portal' }
|
||||
)
|
||||
).rejects.toThrow('active user consent')
|
||||
consent = true
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'pointer-move', x: 1, y: 2 },
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual({ route: 'portal' })
|
||||
})
|
||||
|
||||
it('uses XTest only for a proven X11 connection', async () => {
|
||||
let proven = false
|
||||
const xTest = backend()
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portalConsentActive: () => false,
|
||||
xTest,
|
||||
provenX11: () => proven
|
||||
})
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'key', key: 'Enter', pressed: true },
|
||||
new AbortController().signal,
|
||||
{ backend: 'xtest' }
|
||||
)
|
||||
).rejects.toThrow('proven X11')
|
||||
|
||||
proven = true
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'key', key: 'Enter', pressed: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual({ route: 'xtest' })
|
||||
})
|
||||
|
||||
it('never silently selects ydotool and requires its exact fixed executable', async () => {
|
||||
const ydotool: YdotoolInputBackend = {
|
||||
...backend(),
|
||||
executablePath: '/usr/bin/ydotool'
|
||||
}
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portalConsentActive: () => false,
|
||||
provenX11: () => false,
|
||||
ydotool,
|
||||
ydotoolOptIn: true,
|
||||
fixedYdotoolExecutable: '/usr/bin/ydotool'
|
||||
})
|
||||
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'scroll', deltaX: 0, deltaY: 1 },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('No consented')
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'scroll', deltaX: 0, deltaY: 1 },
|
||||
new AbortController().signal,
|
||||
{ backend: 'ydotool' }
|
||||
)
|
||||
).resolves.toEqual({ route: 'ydotool' })
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portalConsentActive: () => false,
|
||||
provenX11: () => false,
|
||||
ydotoolOptIn: true,
|
||||
fixedYdotoolExecutable: 'ydotool'
|
||||
})
|
||||
).toThrow('absolute fixed executable')
|
||||
})
|
||||
|
||||
it('releases pressed input best-effort when an operation is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const portal = backend()
|
||||
portal.inject = vi.fn(
|
||||
async (_action, signal) =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
portal.releasePressedInput = vi.fn(async () => {
|
||||
throw new Error('release failed')
|
||||
})
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portal,
|
||||
portalConsentActive: () => true,
|
||||
provenX11: () => false
|
||||
})
|
||||
const routed = router.route(
|
||||
{ type: 'pointer-button', button: 1, pressed: true },
|
||||
controller.signal
|
||||
)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
await expect(routed).rejects.toThrow()
|
||||
expect(portal.releasePressedInput).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases partially pressed input after a non-abort injection failure', async () => {
|
||||
const portal = backend()
|
||||
portal.inject = vi.fn(async () => {
|
||||
throw new Error('partial native injection')
|
||||
})
|
||||
portal.releasePressedInput = vi.fn(async () => {
|
||||
throw new Error('best-effort release failed')
|
||||
})
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semantic(false),
|
||||
portal,
|
||||
portalConsentActive: () => true,
|
||||
provenX11: () => false
|
||||
})
|
||||
|
||||
await expect(
|
||||
router.route(
|
||||
{ type: 'key', key: 'Control_L', pressed: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('partial native injection')
|
||||
expect(portal.releasePressedInput).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ type: 'pointer-move', x: Number.NaN, y: 0 } as const,
|
||||
{ type: 'pointer-button', button: 0, pressed: true } as const,
|
||||
{ type: 'scroll', deltaX: 0, deltaY: Number.POSITIVE_INFINITY } as const,
|
||||
{ type: 'key', key: '\n', pressed: true } as const,
|
||||
{ type: 'text', text: '\u0000secret' } as const
|
||||
])('rejects malformed actions before any backend sees them', async (action) => {
|
||||
const semanticInput = semantic(false)
|
||||
const portal = backend()
|
||||
const router = new LinuxDesktopInputRouter({
|
||||
semantic: semanticInput,
|
||||
portal,
|
||||
portalConsentActive: () => true,
|
||||
provenX11: () => false
|
||||
})
|
||||
|
||||
await expect(
|
||||
router.route(action, new AbortController().signal)
|
||||
).rejects.toThrow('Invalid')
|
||||
expect(semanticInput.perform).not.toHaveBeenCalled()
|
||||
expect(portal.inject).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { isAbsolute } from 'node:path'
|
||||
|
||||
export type DesktopInputAction =
|
||||
| { type: 'pointer-move'; x: number; y: number }
|
||||
| { type: 'pointer-button'; button: number; pressed: boolean }
|
||||
| { type: 'scroll'; deltaX: number; deltaY: number }
|
||||
| { type: 'key'; key: string; pressed: boolean }
|
||||
| { type: 'text'; text: string }
|
||||
|
||||
export type InputBackendName = 'portal' | 'xtest' | 'ydotool'
|
||||
|
||||
export interface SemanticInput {
|
||||
perform(action: DesktopInputAction, signal: AbortSignal): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface InjectedInputBackend {
|
||||
inject(action: DesktopInputAction, signal: AbortSignal): Promise<void>
|
||||
releasePressedInput(): Promise<void>
|
||||
}
|
||||
|
||||
export interface YdotoolInputBackend extends InjectedInputBackend {
|
||||
readonly executablePath: string
|
||||
}
|
||||
|
||||
export type InputRouterOptions = {
|
||||
semantic: SemanticInput
|
||||
portal?: InjectedInputBackend
|
||||
portalConsentActive: () => boolean
|
||||
xTest?: InjectedInputBackend
|
||||
provenX11: () => boolean
|
||||
ydotool?: YdotoolInputBackend
|
||||
ydotoolOptIn?: boolean
|
||||
fixedYdotoolExecutable?: string
|
||||
}
|
||||
|
||||
export type RouteInputOptions = {
|
||||
backend?: 'auto' | InputBackendName
|
||||
}
|
||||
|
||||
export type RoutedInputResult = {
|
||||
route: 'semantic' | InputBackendName
|
||||
}
|
||||
|
||||
const hasControlCharacters = (value: string): boolean =>
|
||||
[...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code < 32 || code === 127
|
||||
})
|
||||
|
||||
const validateAction = (action: DesktopInputAction): void => {
|
||||
switch (action.type) {
|
||||
case 'pointer-move':
|
||||
if (!Number.isFinite(action.x) || !Number.isFinite(action.y)) {
|
||||
throw new Error('Invalid pointer coordinates')
|
||||
}
|
||||
return
|
||||
case 'pointer-button':
|
||||
if (
|
||||
!Number.isSafeInteger(action.button) ||
|
||||
action.button < 1 ||
|
||||
action.button > 32
|
||||
) {
|
||||
throw new Error('Invalid pointer button')
|
||||
}
|
||||
return
|
||||
case 'scroll':
|
||||
if (
|
||||
!Number.isFinite(action.deltaX) ||
|
||||
!Number.isFinite(action.deltaY) ||
|
||||
Math.abs(action.deltaX) > 100_000 ||
|
||||
Math.abs(action.deltaY) > 100_000
|
||||
) {
|
||||
throw new Error('Invalid scroll delta')
|
||||
}
|
||||
return
|
||||
case 'key':
|
||||
if (
|
||||
action.key.length < 1 ||
|
||||
action.key.length > 64 ||
|
||||
hasControlCharacters(action.key)
|
||||
) {
|
||||
throw new Error('Invalid key identifier')
|
||||
}
|
||||
return
|
||||
case 'text':
|
||||
if (
|
||||
action.text.length > 4_096 ||
|
||||
action.text.includes('\u0000')
|
||||
) {
|
||||
throw new Error('Invalid text input')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LinuxDesktopInputRouter {
|
||||
private readonly usedBackends = new Set<InjectedInputBackend>()
|
||||
|
||||
constructor(private readonly options: InputRouterOptions) {
|
||||
if (
|
||||
options.ydotoolOptIn &&
|
||||
(!options.fixedYdotoolExecutable ||
|
||||
!isAbsolute(options.fixedYdotoolExecutable))
|
||||
) {
|
||||
throw new Error('ydotool requires an absolute fixed executable path')
|
||||
}
|
||||
}
|
||||
|
||||
async route(
|
||||
action: DesktopInputAction,
|
||||
signal: AbortSignal,
|
||||
routeOptions: RouteInputOptions = {}
|
||||
): Promise<RoutedInputResult> {
|
||||
validateAction(action)
|
||||
if (signal.aborted) {
|
||||
throw signal.reason
|
||||
}
|
||||
if (await this.options.semantic.perform(action, signal)) {
|
||||
return { route: 'semantic' }
|
||||
}
|
||||
|
||||
const selected = this.selectBackend(routeOptions.backend ?? 'auto')
|
||||
this.usedBackends.add(selected.backend)
|
||||
let released = false
|
||||
let releasePromise: Promise<void> | undefined
|
||||
const releaseOnAbort = (): void => {
|
||||
if (!released) {
|
||||
released = true
|
||||
releasePromise = this.releasePressedInput()
|
||||
}
|
||||
}
|
||||
signal.addEventListener('abort', releaseOnAbort, { once: true })
|
||||
try {
|
||||
await selected.backend.inject(action, signal)
|
||||
if (signal.aborted) {
|
||||
releaseOnAbort()
|
||||
throw signal.reason
|
||||
}
|
||||
return { route: selected.name }
|
||||
} catch (error) {
|
||||
await (releasePromise ?? this.releasePressedInput())
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', releaseOnAbort)
|
||||
}
|
||||
}
|
||||
|
||||
async releasePressedInput(): Promise<void> {
|
||||
const backends = [...this.usedBackends]
|
||||
this.usedBackends.clear()
|
||||
await Promise.allSettled(
|
||||
backends.map((backend) => backend.releasePressedInput())
|
||||
)
|
||||
}
|
||||
|
||||
private selectBackend(preference: 'auto' | InputBackendName): {
|
||||
name: InputBackendName
|
||||
backend: InjectedInputBackend
|
||||
} {
|
||||
if (preference === 'portal') {
|
||||
if (!this.options.portal || !this.options.portalConsentActive()) {
|
||||
throw new Error('Portal input requires active user consent')
|
||||
}
|
||||
return { name: 'portal', backend: this.options.portal }
|
||||
}
|
||||
if (preference === 'xtest') {
|
||||
if (!this.options.xTest || !this.options.provenX11()) {
|
||||
throw new Error('XTest requires a proven X11 connection')
|
||||
}
|
||||
return { name: 'xtest', backend: this.options.xTest }
|
||||
}
|
||||
if (preference === 'ydotool') {
|
||||
const fixed = this.options.fixedYdotoolExecutable
|
||||
if (
|
||||
!this.options.ydotoolOptIn ||
|
||||
!fixed ||
|
||||
!this.options.ydotool ||
|
||||
this.options.ydotool.executablePath !== fixed
|
||||
) {
|
||||
throw new Error(
|
||||
'ydotool requires explicit opt-in and the fixed executable'
|
||||
)
|
||||
}
|
||||
return { name: 'ydotool', backend: this.options.ydotool }
|
||||
}
|
||||
|
||||
if (this.options.portal && this.options.portalConsentActive()) {
|
||||
return { name: 'portal', backend: this.options.portal }
|
||||
}
|
||||
if (this.options.xTest && this.options.provenX11()) {
|
||||
return { name: 'xtest', backend: this.options.xTest }
|
||||
}
|
||||
throw new Error('No consented or proven input backend is available')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock
|
||||
} from 'vitest'
|
||||
import {
|
||||
PortalDesktopSession,
|
||||
PortalSessionError,
|
||||
validatePipeWireFrameMetadata,
|
||||
type ClosableResource,
|
||||
type PortalResponse,
|
||||
type PortalTransport
|
||||
} from './portal-session'
|
||||
|
||||
type MockPortal = {
|
||||
transport: PortalTransport
|
||||
pipeWireClose: Mock<() => void>
|
||||
eisClose: Mock<() => void>
|
||||
}
|
||||
|
||||
const mockPortal = (
|
||||
responseOverrides: Record<string, Partial<PortalResponse>> = {}
|
||||
): MockPortal => {
|
||||
const pipeWireClose = vi.fn<() => void>()
|
||||
const eisClose = vi.fn<() => void>()
|
||||
const resource = (close: Mock<() => void>): ClosableResource => ({
|
||||
close: () => close()
|
||||
})
|
||||
const transport: PortalTransport = {
|
||||
createSession: vi.fn(async () => ({ requestHandle: 'create' })),
|
||||
selectDevices: vi.fn(async () => ({ requestHandle: 'devices' })),
|
||||
selectSources: vi.fn(async () => ({ requestHandle: 'sources' })),
|
||||
start: vi.fn(async () => ({ requestHandle: 'start' })),
|
||||
waitForResponse: vi.fn(async (requestHandle) => ({
|
||||
requestHandle,
|
||||
response: 0,
|
||||
results:
|
||||
requestHandle === 'create'
|
||||
? { session_handle: 'private-session' }
|
||||
: {},
|
||||
...responseOverrides[requestHandle]
|
||||
})),
|
||||
openPipeWireRemote: vi.fn(async () => resource(pipeWireClose)),
|
||||
connectEis: vi.fn(async () => resource(eisClose)),
|
||||
closeRequest: vi.fn(async () => undefined),
|
||||
closeSession: vi.fn(async () => undefined)
|
||||
}
|
||||
return { transport, pipeWireClose, eisClose }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('PortalDesktopSession', () => {
|
||||
it('performs the portal protocol in order and cleans every resource on stop', async () => {
|
||||
const mock = mockPortal()
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
|
||||
await session.open(
|
||||
{ devices: true, sources: true, parentWindow: 'window-token' },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(session.state).toBe('active')
|
||||
expect(session.hasActiveConsent).toBe(true)
|
||||
expect(mock.transport.selectDevices).toHaveBeenCalledWith(
|
||||
'private-session',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mock.transport.selectSources).toHaveBeenCalled()
|
||||
expect(mock.transport.start).toHaveBeenCalledWith(
|
||||
'private-session',
|
||||
'window-token',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
|
||||
await session.stop()
|
||||
|
||||
expect(session.state).toBe('stopped')
|
||||
expect(session.hasActiveConsent).toBe(false)
|
||||
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
|
||||
expect(mock.eisClose).toHaveBeenCalledOnce()
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledTimes(4)
|
||||
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 'cancelled'],
|
||||
[2, 'denied']
|
||||
] as const)('handles portal consent response %s as %s', async (code, reason) => {
|
||||
const mock = mockPortal({
|
||||
sources: { response: code }
|
||||
})
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
|
||||
await expect(
|
||||
session.open(
|
||||
{ devices: false, sources: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ reason })
|
||||
expect(session.state).toBe('failed')
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledWith('sources')
|
||||
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
|
||||
expect(mock.transport.openPipeWireRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects mismatched response handles and closes the request', async () => {
|
||||
const mock = mockPortal({
|
||||
create: { requestHandle: 'unrelated-response' }
|
||||
})
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
|
||||
await expect(
|
||||
session.open(
|
||||
{ devices: true, sources: false },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ reason: 'protocol' })
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
|
||||
})
|
||||
|
||||
it('aborts a pending request on timeout and performs cleanup', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mock = mockPortal()
|
||||
mock.transport.waitForResponse = vi.fn(
|
||||
async (_handle, signal) =>
|
||||
new Promise<PortalResponse>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
const opening = session.open(
|
||||
{ devices: true, sources: false, timeoutMs: 10 },
|
||||
new AbortController().signal
|
||||
)
|
||||
const rejection = expect(opening).rejects.toMatchObject({
|
||||
reason: 'timeout'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
await rejection
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
|
||||
})
|
||||
|
||||
it('cleans active descriptors and revokes consent on portal owner loss', async () => {
|
||||
const mock = mockPortal()
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
await session.open(
|
||||
{ devices: true, sources: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await session.portalOwnerLost()
|
||||
|
||||
expect(session.state).toBe('failed')
|
||||
expect(session.hasActiveConsent).toBe(false)
|
||||
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
|
||||
expect(mock.eisClose).toHaveBeenCalledOnce()
|
||||
expect(mock.transport.closeSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops an opening session without leaving requests or consent active', async () => {
|
||||
const mock = mockPortal()
|
||||
mock.transport.waitForResponse = vi.fn(
|
||||
async (_handle, signal) =>
|
||||
new Promise<PortalResponse>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
const opening = session.open(
|
||||
{ devices: true, sources: false },
|
||||
new AbortController().signal
|
||||
)
|
||||
const rejection = expect(opening).rejects.toMatchObject({
|
||||
reason: 'aborted'
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
await session.stop()
|
||||
await rejection
|
||||
|
||||
expect(session.state).toBe('stopped')
|
||||
expect(session.hasActiveConsent).toBe(false)
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
|
||||
})
|
||||
|
||||
it('prevents a late stopped open from mutating a reopened session', async () => {
|
||||
const mock = mockPortal()
|
||||
let resolveFirst:
|
||||
| ((request: { requestHandle: string }) => void)
|
||||
| undefined
|
||||
const firstCreate = new Promise<{ requestHandle: string }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
let createCount = 0
|
||||
mock.transport.createSession = vi.fn(async () => {
|
||||
createCount += 1
|
||||
if (createCount === 1) {
|
||||
return firstCreate
|
||||
}
|
||||
return { requestHandle: 'create-second' }
|
||||
})
|
||||
mock.transport.waitForResponse = vi.fn(async (requestHandle) => ({
|
||||
requestHandle,
|
||||
response: 0,
|
||||
results:
|
||||
requestHandle === 'create-second'
|
||||
? { session_handle: 'session-second' }
|
||||
: {}
|
||||
}))
|
||||
const session = new PortalDesktopSession(mock.transport)
|
||||
const firstOpening = session.open(
|
||||
{ devices: true, sources: false },
|
||||
new AbortController().signal
|
||||
)
|
||||
const firstRejection = expect(firstOpening).rejects.toMatchObject({
|
||||
reason: 'aborted'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(mock.transport.createSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
await session.stop()
|
||||
await firstRejection
|
||||
await session.open(
|
||||
{ devices: true, sources: false },
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(session.state).toBe('active')
|
||||
|
||||
resolveFirst?.({ requestHandle: 'late-first-create' })
|
||||
await vi.waitFor(() => {
|
||||
expect(mock.transport.closeRequest).toHaveBeenCalledWith(
|
||||
'late-first-create'
|
||||
)
|
||||
})
|
||||
expect(session.state).toBe('active')
|
||||
expect(session.hasActiveConsent).toBe(true)
|
||||
expect(mock.transport.closeSession).not.toHaveBeenCalledWith(
|
||||
'session-second'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['stop', 'owner-loss'] as const)(
|
||||
'bounds hung cleanup during %s',
|
||||
async (lifecycle) => {
|
||||
const mock = mockPortal()
|
||||
const never = () => new Promise<void>(() => undefined)
|
||||
mock.transport.closeRequest = vi.fn(never)
|
||||
mock.transport.closeSession = vi.fn(never)
|
||||
mock.transport.openPipeWireRemote = vi.fn(async () => ({
|
||||
close: never
|
||||
}))
|
||||
const session = new PortalDesktopSession(mock.transport, {
|
||||
cleanupTimeoutMs: 10
|
||||
})
|
||||
await session.open(
|
||||
{ devices: false, sources: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
vi.useFakeTimers()
|
||||
|
||||
const cleanup =
|
||||
lifecycle === 'stop'
|
||||
? session.stop()
|
||||
: session.portalOwnerLost()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await expect(cleanup).resolves.toBeUndefined()
|
||||
expect(session.state).toBe(
|
||||
lifecycle === 'stop' ? 'stopped' : 'failed'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('bounds hung cleanup after a failed open', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mock = mockPortal({
|
||||
sources: { response: 2 }
|
||||
})
|
||||
const never = () => new Promise<void>(() => undefined)
|
||||
mock.transport.closeRequest = vi.fn(never)
|
||||
mock.transport.closeSession = vi.fn(never)
|
||||
const session = new PortalDesktopSession(mock.transport, {
|
||||
cleanupTimeoutMs: 10
|
||||
})
|
||||
const opening = session.open(
|
||||
{ devices: false, sources: true },
|
||||
new AbortController().signal
|
||||
)
|
||||
const rejection = expect(opening).rejects.toMatchObject({
|
||||
reason: 'denied'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mock.transport.closeSession).toHaveBeenCalled()
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await rejection
|
||||
expect(session.state).toBe('failed')
|
||||
})
|
||||
|
||||
it('requires at least one consented portal capability', async () => {
|
||||
const session = new PortalDesktopSession(mockPortal().transport)
|
||||
await expect(
|
||||
session.open(
|
||||
{ devices: false, sources: false },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toBeInstanceOf(PortalSessionError)
|
||||
await expect(
|
||||
session.open(
|
||||
{ devices: true, sources: false, timeoutMs: 0 },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ reason: 'protocol' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePipeWireFrameMetadata', () => {
|
||||
const valid = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
stride: 7680,
|
||||
planes: [{ offset: 0, stride: 7680, bytes: 8_294_400 }],
|
||||
byteLength: 8_294_400,
|
||||
fpsNumerator: 60,
|
||||
fpsDenominator: 1
|
||||
}
|
||||
|
||||
it('accepts bounded, internally consistent frame metadata', () => {
|
||||
expect(validatePipeWireFrameMetadata(valid)).toEqual(valid)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...valid, width: 0 },
|
||||
{ ...valid, height: 20_000 },
|
||||
{ ...valid, stride: 100 },
|
||||
{ ...valid, fpsDenominator: 0 },
|
||||
{ ...valid, fpsNumerator: 241 },
|
||||
{ ...valid, byteLength: 10 },
|
||||
{
|
||||
...valid,
|
||||
planes: [{ offset: 1, stride: 7680, bytes: 8_294_399 }]
|
||||
},
|
||||
{
|
||||
...valid,
|
||||
planes: [
|
||||
{ offset: 0, stride: 7680, bytes: 100 },
|
||||
{ offset: 50, stride: 960, bytes: 8_294_350 }
|
||||
]
|
||||
}
|
||||
])('rejects unsafe or inconsistent metadata', (metadata) => {
|
||||
expect(() => validatePipeWireFrameMetadata(metadata)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,564 @@
|
||||
export type PortalSessionState =
|
||||
| 'idle'
|
||||
| 'creating'
|
||||
| 'selecting'
|
||||
| 'starting'
|
||||
| 'active'
|
||||
| 'stopping'
|
||||
| 'stopped'
|
||||
| 'failed'
|
||||
|
||||
export type PortalRequest = {
|
||||
requestHandle: string
|
||||
}
|
||||
|
||||
export type PortalResponse = {
|
||||
requestHandle: string
|
||||
response: number
|
||||
results: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface ClosableResource {
|
||||
close(): void | Promise<void>
|
||||
}
|
||||
|
||||
export interface PortalTransport {
|
||||
createSession(signal: AbortSignal): Promise<PortalRequest>
|
||||
selectDevices(
|
||||
sessionHandle: string,
|
||||
signal: AbortSignal
|
||||
): Promise<PortalRequest>
|
||||
selectSources(
|
||||
sessionHandle: string,
|
||||
signal: AbortSignal
|
||||
): Promise<PortalRequest>
|
||||
start(
|
||||
sessionHandle: string,
|
||||
parentWindow: string,
|
||||
signal: AbortSignal
|
||||
): Promise<PortalRequest>
|
||||
waitForResponse(
|
||||
requestHandle: string,
|
||||
signal: AbortSignal
|
||||
): Promise<PortalResponse>
|
||||
openPipeWireRemote(
|
||||
sessionHandle: string,
|
||||
signal: AbortSignal
|
||||
): Promise<ClosableResource>
|
||||
connectEis?(
|
||||
sessionHandle: string,
|
||||
signal: AbortSignal
|
||||
): Promise<ClosableResource>
|
||||
closeRequest(requestHandle: string): Promise<void>
|
||||
closeSession(sessionHandle: string): Promise<void>
|
||||
}
|
||||
|
||||
export type PortalSessionOptions = {
|
||||
devices: boolean
|
||||
sources: boolean
|
||||
parentWindow?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type PortalDesktopSessionOptions = {
|
||||
cleanupTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class PortalSessionError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'cancelled'
|
||||
| 'denied'
|
||||
| 'protocol'
|
||||
| 'timeout'
|
||||
| 'aborted'
|
||||
| 'owner-lost',
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PortalSessionError'
|
||||
}
|
||||
}
|
||||
|
||||
const getSessionHandle = (response: PortalResponse): string => {
|
||||
const handle = response.results.session_handle
|
||||
if (typeof handle !== 'string' || handle.length === 0) {
|
||||
throw new PortalSessionError(
|
||||
'protocol',
|
||||
'Portal did not return a session handle'
|
||||
)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
const throwForResponse = (response: PortalResponse): void => {
|
||||
if (response.response === 0) {
|
||||
return
|
||||
}
|
||||
if (response.response === 1) {
|
||||
throw new PortalSessionError('cancelled', 'Portal consent was cancelled')
|
||||
}
|
||||
if (response.response === 2) {
|
||||
throw new PortalSessionError('denied', 'Portal consent was denied')
|
||||
}
|
||||
throw new PortalSessionError('protocol', 'Portal returned an unknown response')
|
||||
}
|
||||
|
||||
export class PortalDesktopSession {
|
||||
private stateValue: PortalSessionState = 'idle'
|
||||
private generation = 0
|
||||
private operation: PortalOperation | undefined
|
||||
private openingPromise: Promise<void> | undefined
|
||||
private readonly cleanupTimeoutMs: number
|
||||
|
||||
constructor(
|
||||
private readonly transport: PortalTransport,
|
||||
options: PortalDesktopSessionOptions = {}
|
||||
) {
|
||||
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? 1_000
|
||||
if (
|
||||
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
|
||||
this.cleanupTimeoutMs < 1 ||
|
||||
this.cleanupTimeoutMs > 30_000
|
||||
) {
|
||||
throw new Error('Invalid portal cleanup timeout')
|
||||
}
|
||||
}
|
||||
|
||||
get state(): PortalSessionState {
|
||||
return this.stateValue
|
||||
}
|
||||
|
||||
get hasActiveConsent(): boolean {
|
||||
return (
|
||||
this.stateValue === 'active' &&
|
||||
this.operation !== undefined &&
|
||||
!this.operation.ownerLost
|
||||
)
|
||||
}
|
||||
|
||||
async open(
|
||||
options: PortalSessionOptions,
|
||||
outerSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (this.stateValue !== 'idle' && this.stateValue !== 'stopped') {
|
||||
throw new PortalSessionError('protocol', 'Portal session is already open')
|
||||
}
|
||||
if (!options.devices && !options.sources) {
|
||||
throw new PortalSessionError(
|
||||
'protocol',
|
||||
'Portal session must request a concrete capability'
|
||||
)
|
||||
}
|
||||
const timeoutMs = options.timeoutMs ?? 30_000
|
||||
if (
|
||||
!Number.isSafeInteger(timeoutMs) ||
|
||||
timeoutMs < 1 ||
|
||||
timeoutMs > 120_000
|
||||
) {
|
||||
throw new PortalSessionError('protocol', 'Invalid portal timeout')
|
||||
}
|
||||
|
||||
this.stateValue = 'creating'
|
||||
const operation: PortalOperation = {
|
||||
generation: ++this.generation,
|
||||
controller: new AbortController(),
|
||||
requestHandles: new Set(),
|
||||
resources: new Set(),
|
||||
ownerLost: false,
|
||||
stopRequested: false
|
||||
}
|
||||
this.operation = operation
|
||||
const opening = this.openOperation(
|
||||
operation,
|
||||
options,
|
||||
outerSignal,
|
||||
timeoutMs
|
||||
)
|
||||
this.openingPromise = opening
|
||||
opening
|
||||
.finally(() => {
|
||||
if (this.openingPromise === opening) {
|
||||
this.openingPromise = undefined
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The caller receives the original open rejection.
|
||||
})
|
||||
return opening
|
||||
}
|
||||
|
||||
private async openOperation(
|
||||
operation: PortalOperation,
|
||||
options: PortalSessionOptions,
|
||||
outerSignal: AbortSignal,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const controller = operation.controller
|
||||
const abort = (): void => controller.abort(outerSignal.reason)
|
||||
outerSignal.addEventListener('abort', abort, { once: true })
|
||||
if (outerSignal.aborted) {
|
||||
abort()
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new PortalSessionError('timeout', 'Portal timed out')),
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
try {
|
||||
const created = await this.request(
|
||||
operation,
|
||||
() => this.transport.createSession(controller.signal),
|
||||
controller.signal
|
||||
)
|
||||
operation.sessionHandle = getSessionHandle(created)
|
||||
|
||||
this.setOwnedState(operation, 'selecting')
|
||||
if (options.devices) {
|
||||
await this.request(
|
||||
operation,
|
||||
() =>
|
||||
this.transport.selectDevices(
|
||||
this.requireSessionHandle(operation),
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
}
|
||||
if (options.sources) {
|
||||
await this.request(
|
||||
operation,
|
||||
() =>
|
||||
this.transport.selectSources(
|
||||
this.requireSessionHandle(operation),
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
}
|
||||
|
||||
this.setOwnedState(operation, 'starting')
|
||||
await this.request(
|
||||
operation,
|
||||
() =>
|
||||
this.transport.start(
|
||||
this.requireSessionHandle(operation),
|
||||
options.parentWindow ?? '',
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
|
||||
if (options.sources) {
|
||||
operation.resources.add(
|
||||
await this.acquireResource(
|
||||
this.transport.openPipeWireRemote(
|
||||
this.requireSessionHandle(operation),
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
)
|
||||
}
|
||||
if (options.devices && this.transport.connectEis) {
|
||||
operation.resources.add(
|
||||
await this.acquireResource(
|
||||
this.transport.connectEis(
|
||||
this.requireSessionHandle(operation),
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
)
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
throw controller.signal.reason
|
||||
}
|
||||
this.setOwnedState(operation, 'active')
|
||||
} catch (error) {
|
||||
await this.cleanup(operation)
|
||||
this.setOwnedState(
|
||||
operation,
|
||||
operation.stopRequested ? 'stopped' : 'failed'
|
||||
)
|
||||
if (operation.ownerLost) {
|
||||
throw new PortalSessionError('owner-lost', 'Portal owner disappeared')
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
const reason = controller.signal.reason
|
||||
if (reason instanceof PortalSessionError) {
|
||||
throw reason
|
||||
}
|
||||
throw new PortalSessionError('aborted', 'Portal session was aborted')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
outerSignal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.stateValue === 'stopped' || this.stateValue === 'idle') {
|
||||
this.stateValue = 'stopped'
|
||||
return
|
||||
}
|
||||
this.stateValue = 'stopping'
|
||||
const operation = this.operation
|
||||
if (!operation) {
|
||||
this.stateValue = 'stopped'
|
||||
return
|
||||
}
|
||||
operation.stopRequested = true
|
||||
operation.controller.abort(
|
||||
new PortalSessionError('aborted', 'Portal session stopped')
|
||||
)
|
||||
await this.cleanup(operation)
|
||||
await this.awaitOpening(operation)
|
||||
if (this.operation === operation) {
|
||||
this.operation = undefined
|
||||
this.stateValue = 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
async portalOwnerLost(): Promise<void> {
|
||||
const operation = this.operation
|
||||
if (!operation) {
|
||||
this.stateValue = 'failed'
|
||||
return
|
||||
}
|
||||
operation.ownerLost = true
|
||||
operation.controller.abort(
|
||||
new PortalSessionError('owner-lost', 'Portal owner disappeared')
|
||||
)
|
||||
await this.cleanup(operation)
|
||||
await this.awaitOpening(operation)
|
||||
if (this.operation === operation) {
|
||||
this.operation = undefined
|
||||
this.stateValue = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
operation: PortalOperation,
|
||||
initiate: () => Promise<PortalRequest>,
|
||||
signal: AbortSignal
|
||||
): Promise<PortalResponse> {
|
||||
const initiating = Promise.resolve().then(initiate)
|
||||
let request: PortalRequest
|
||||
try {
|
||||
request = await this.abortable(initiating, signal)
|
||||
} catch (error) {
|
||||
void initiating
|
||||
.then((lateRequest) =>
|
||||
this.runCleanup([
|
||||
() => this.transport.closeRequest(lateRequest.requestHandle)
|
||||
])
|
||||
)
|
||||
.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
operation.requestHandles.add(request.requestHandle)
|
||||
const response = await this.abortable(
|
||||
this.transport.waitForResponse(request.requestHandle, signal),
|
||||
signal
|
||||
)
|
||||
if (response.requestHandle !== request.requestHandle) {
|
||||
throw new PortalSessionError(
|
||||
'protocol',
|
||||
'Portal response handle did not match its request'
|
||||
)
|
||||
}
|
||||
throwForResponse(response)
|
||||
return response
|
||||
}
|
||||
|
||||
private requireSessionHandle(operation: PortalOperation): string {
|
||||
if (!operation.sessionHandle) {
|
||||
throw new PortalSessionError(
|
||||
'protocol',
|
||||
'Portal session handle is unavailable'
|
||||
)
|
||||
}
|
||||
return operation.sessionHandle
|
||||
}
|
||||
|
||||
private async cleanup(operation: PortalOperation): Promise<void> {
|
||||
if (operation.cleanupPromise) {
|
||||
return operation.cleanupPromise
|
||||
}
|
||||
const resources = [...operation.resources]
|
||||
const requests = [...operation.requestHandles]
|
||||
const sessionHandle = operation.sessionHandle
|
||||
operation.resources.clear()
|
||||
operation.requestHandles.clear()
|
||||
operation.sessionHandle = undefined
|
||||
operation.cleanupPromise = this.runCleanup([
|
||||
...resources.map((resource) => () => resource.close()),
|
||||
...requests.map(
|
||||
(handle) => () => this.transport.closeRequest(handle)
|
||||
),
|
||||
...(sessionHandle
|
||||
? [() => this.transport.closeSession(sessionHandle)]
|
||||
: [])
|
||||
])
|
||||
return operation.cleanupPromise
|
||||
}
|
||||
|
||||
private async acquireResource(
|
||||
resourcePromise: Promise<ClosableResource>,
|
||||
signal: AbortSignal
|
||||
): Promise<ClosableResource> {
|
||||
try {
|
||||
return await this.abortable(resourcePromise, signal)
|
||||
} catch (error) {
|
||||
void resourcePromise
|
||||
.then((resource) =>
|
||||
this.runCleanup([() => resource.close()])
|
||||
)
|
||||
.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private abortable<T>(
|
||||
promise: Promise<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
promise.catch(() => undefined)
|
||||
return Promise.reject(signal.reason)
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
promise.catch(() => undefined)
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async runCleanup(
|
||||
operations: Array<() => void | Promise<void>>
|
||||
): Promise<void> {
|
||||
const cleanup = Promise.allSettled(
|
||||
operations.map((operation) =>
|
||||
Promise.resolve().then(operation)
|
||||
)
|
||||
).then(() => undefined)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, this.cleanupTimeoutMs)
|
||||
})
|
||||
await Promise.race([cleanup, timeout])
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitOpening(operation: PortalOperation): Promise<void> {
|
||||
const opening = this.openingPromise
|
||||
if (!opening || this.operation !== operation) {
|
||||
return
|
||||
}
|
||||
await opening.catch(() => undefined)
|
||||
}
|
||||
|
||||
private setOwnedState(
|
||||
operation: PortalOperation,
|
||||
state: PortalSessionState
|
||||
): void {
|
||||
if (
|
||||
this.operation === operation &&
|
||||
this.operation.generation === operation.generation
|
||||
) {
|
||||
this.stateValue = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PortalOperation = {
|
||||
generation: number
|
||||
controller: AbortController
|
||||
sessionHandle?: string
|
||||
requestHandles: Set<string>
|
||||
resources: Set<ClosableResource>
|
||||
ownerLost: boolean
|
||||
stopRequested: boolean
|
||||
cleanupPromise?: Promise<void>
|
||||
}
|
||||
|
||||
export type PipeWirePlaneMetadata = {
|
||||
offset: number
|
||||
stride: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export type PipeWireFrameMetadata = {
|
||||
width: number
|
||||
height: number
|
||||
stride: number
|
||||
planes: PipeWirePlaneMetadata[]
|
||||
byteLength: number
|
||||
fpsNumerator: number
|
||||
fpsDenominator: number
|
||||
}
|
||||
|
||||
const isPositiveInteger = (value: number): boolean =>
|
||||
Number.isSafeInteger(value) && value > 0
|
||||
|
||||
export function validatePipeWireFrameMetadata(
|
||||
metadata: PipeWireFrameMetadata
|
||||
): PipeWireFrameMetadata {
|
||||
if (
|
||||
!isPositiveInteger(metadata.width) ||
|
||||
!isPositiveInteger(metadata.height) ||
|
||||
metadata.width > 16_384 ||
|
||||
metadata.height > 16_384 ||
|
||||
!isPositiveInteger(metadata.stride) ||
|
||||
metadata.stride < metadata.width ||
|
||||
metadata.stride > 1_048_576 ||
|
||||
!isPositiveInteger(metadata.byteLength) ||
|
||||
metadata.byteLength > 256 * 1024 * 1024 ||
|
||||
!isPositiveInteger(metadata.fpsNumerator) ||
|
||||
!isPositiveInteger(metadata.fpsDenominator) ||
|
||||
metadata.fpsNumerator / metadata.fpsDenominator > 240 ||
|
||||
metadata.planes.length < 1 ||
|
||||
metadata.planes.length > 4
|
||||
) {
|
||||
throw new Error('Invalid PipeWire frame metadata')
|
||||
}
|
||||
let previousEnd = 0
|
||||
for (const [index, plane] of metadata.planes.entries()) {
|
||||
if (
|
||||
!Number.isSafeInteger(plane.offset) ||
|
||||
plane.offset < 0 ||
|
||||
!isPositiveInteger(plane.stride) ||
|
||||
plane.stride > 1_048_576 ||
|
||||
!isPositiveInteger(plane.bytes) ||
|
||||
(index === 0 && plane.offset !== 0) ||
|
||||
plane.offset < previousEnd ||
|
||||
plane.offset + plane.bytes > metadata.byteLength ||
|
||||
(index === 0 &&
|
||||
(plane.stride !== metadata.stride ||
|
||||
plane.bytes < metadata.stride * metadata.height))
|
||||
) {
|
||||
throw new Error('Invalid PipeWire plane metadata')
|
||||
}
|
||||
previousEnd = plane.offset + plane.bytes
|
||||
}
|
||||
if (previousEnd !== metadata.byteLength) {
|
||||
throw new Error('PipeWire plane metadata does not cover the frame buffer')
|
||||
}
|
||||
return structuredClone(metadata)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDesktopHelperEnvironment,
|
||||
type SecurePathMetadata,
|
||||
type SessionEnvironmentFileSystem
|
||||
} from './session-environment'
|
||||
|
||||
const safeMetadata = (
|
||||
canonicalPath: string,
|
||||
kind: 'file' | 'directory'
|
||||
): SecurePathMetadata => ({
|
||||
canonicalPath,
|
||||
uid: 1000,
|
||||
mode: kind === 'file' ? 0o600 : 0o700,
|
||||
isDirectory: kind === 'directory',
|
||||
isFile: kind === 'file',
|
||||
isSymbolicLink: false
|
||||
})
|
||||
|
||||
const fileSystem = (
|
||||
overrides: Partial<SecurePathMetadata> = {}
|
||||
): SessionEnvironmentFileSystem => ({
|
||||
inspect: async (path) => ({
|
||||
...safeMetadata(
|
||||
path,
|
||||
path.endsWith('.Xauthority') ? 'file' : 'directory'
|
||||
),
|
||||
...overrides
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDesktopHelperEnvironment', () => {
|
||||
it('constructs a minimal fresh helper environment', async () => {
|
||||
const environment = await buildDesktopHelperEnvironment({
|
||||
uid: 1000,
|
||||
fileSystem: fileSystem(),
|
||||
source: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
DISPLAY: ':0.0',
|
||||
WAYLAND_DISPLAY: 'wayland-1',
|
||||
XDG_RUNTIME_DIR: '/run/user/1000',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
XAUTHORITY: '/home/user/.Xauthority',
|
||||
NO_AT_BRIDGE: '1',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
NODE_OPTIONS: '--require /tmp/inject.js',
|
||||
GTK_MODULES: 'inject',
|
||||
QT_PLUGIN_PATH: '/tmp/plugins',
|
||||
HTTPS_PROXY: 'http://proxy.invalid',
|
||||
API_KEY: 'secret'
|
||||
}
|
||||
})
|
||||
|
||||
expect(environment).toEqual({
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
DISPLAY: ':0.0',
|
||||
WAYLAND_DISPLAY: 'wayland-1',
|
||||
XDG_RUNTIME_DIR: '/run/user/1000',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
XAUTHORITY: '/home/user/.Xauthority',
|
||||
NO_AT_BRIDGE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['symbolic link', { isSymbolicLink: true }],
|
||||
['world writable', { mode: 0o707 }],
|
||||
['group writable', { mode: 0o720 }],
|
||||
['wrong owner', { uid: 1001 }],
|
||||
['non-canonical path', { canonicalPath: '/different' }]
|
||||
])('rejects an unsafe %s runtime path', async (_name, override) => {
|
||||
await expect(
|
||||
buildDesktopHelperEnvironment({
|
||||
uid: 1000,
|
||||
fileSystem: fileSystem(override),
|
||||
source: { XDG_RUNTIME_DIR: '/run/user/1000' }
|
||||
})
|
||||
).rejects.toThrow('Unsafe directory path')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ DISPLAY: 'host:abc' },
|
||||
{ WAYLAND_DISPLAY: '../wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' },
|
||||
{ WAYLAND_DISPLAY: 'wayland-0' },
|
||||
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus,bad' },
|
||||
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus;tcp:' },
|
||||
{ LANG: 'en_US.UTF-8\nINJECTED=1' },
|
||||
{ PATH: '/usr/bin:relative/bin' },
|
||||
{ PATH: '/usr/bin::/bin' }
|
||||
])('rejects malformed desktop environment input', async (source) => {
|
||||
await expect(
|
||||
buildDesktopHelperEnvironment({
|
||||
uid: 1000,
|
||||
fileSystem: fileSystem(),
|
||||
source
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('does not forward values that merely resemble safe opt-ins', async () => {
|
||||
await expect(
|
||||
buildDesktopHelperEnvironment({
|
||||
uid: 1000,
|
||||
fileSystem: fileSystem(),
|
||||
source: { NO_AT_BRIDGE: '0', LD_LIBRARY_PATH: '/tmp' }
|
||||
})
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { isAbsolute, basename, posix } from 'node:path'
|
||||
|
||||
export type SecurePathMetadata = {
|
||||
canonicalPath: string
|
||||
uid: number
|
||||
mode: number
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
isSymbolicLink: boolean
|
||||
}
|
||||
|
||||
export interface SessionEnvironmentFileSystem {
|
||||
inspect(path: string): Promise<SecurePathMetadata>
|
||||
}
|
||||
|
||||
export type SessionEnvironmentOptions = {
|
||||
source?: NodeJS.ProcessEnv
|
||||
uid: number
|
||||
fileSystem: SessionEnvironmentFileSystem
|
||||
}
|
||||
|
||||
const DISPLAY_PATTERN = /^(?:(?:[A-Za-z0-9._-]+)?):\d+(?:\.\d+)?$/
|
||||
const WAYLAND_BASENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
||||
const LOCALE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/
|
||||
const DBUS_VALUE_PATTERN = /^(?:[A-Za-z0-9._~:/@+-]|%[0-9A-Fa-f]{2})+$/
|
||||
|
||||
const hasControlCharacters = (value: string): boolean =>
|
||||
[...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code < 32 || code === 127
|
||||
})
|
||||
|
||||
const isSafePathText = (value: string): boolean =>
|
||||
value.length >= 1 &&
|
||||
value.length <= 8192 &&
|
||||
!hasControlCharacters(value)
|
||||
|
||||
const assertSafePath = async (
|
||||
path: string,
|
||||
expectedKind: 'directory' | 'file',
|
||||
options: SessionEnvironmentOptions
|
||||
): Promise<string> => {
|
||||
if (!isAbsolute(path) || !isSafePathText(path)) {
|
||||
throw new Error(`Invalid ${expectedKind} path`)
|
||||
}
|
||||
const metadata = await options.fileSystem.inspect(path)
|
||||
if (
|
||||
metadata.isSymbolicLink ||
|
||||
metadata.canonicalPath !== path ||
|
||||
metadata.uid !== options.uid ||
|
||||
(metadata.mode & 0o022) !== 0 ||
|
||||
(expectedKind === 'directory'
|
||||
? !metadata.isDirectory
|
||||
: !metadata.isFile)
|
||||
) {
|
||||
throw new Error(`Unsafe ${expectedKind} path`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const validateBusAddress = (address: string): string => {
|
||||
if (
|
||||
address.length === 0 ||
|
||||
address.length > 4096 ||
|
||||
hasControlCharacters(address)
|
||||
) {
|
||||
throw new Error('Invalid session bus address')
|
||||
}
|
||||
const alternatives = address.split(';')
|
||||
if (alternatives.some((entry) => entry.length === 0)) {
|
||||
throw new Error('Invalid session bus address')
|
||||
}
|
||||
for (const entry of alternatives) {
|
||||
const separator = entry.indexOf(':')
|
||||
if (separator <= 0 || !/^[a-z][a-z0-9_-]*$/.test(entry.slice(0, separator))) {
|
||||
throw new Error('Invalid session bus address')
|
||||
}
|
||||
const properties = entry.slice(separator + 1).split(',')
|
||||
if (properties.some((property) => property.length === 0)) {
|
||||
throw new Error('Invalid session bus address')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const property of properties) {
|
||||
const equals = property.indexOf('=')
|
||||
const key = property.slice(0, equals)
|
||||
const value = property.slice(equals + 1)
|
||||
if (
|
||||
equals <= 0 ||
|
||||
!/^[a-z][a-z0-9_-]*$/.test(key) ||
|
||||
!DBUS_VALUE_PATTERN.test(value) ||
|
||||
seen.has(key)
|
||||
) {
|
||||
throw new Error('Invalid session bus address')
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
const copySafeValue = (
|
||||
target: NodeJS.ProcessEnv,
|
||||
source: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
pattern: RegExp
|
||||
): void => {
|
||||
const value = source[name]
|
||||
if (value !== undefined) {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
target[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const copySafePath = (
|
||||
target: NodeJS.ProcessEnv,
|
||||
source: NodeJS.ProcessEnv
|
||||
): void => {
|
||||
const value = source.PATH
|
||||
if (value === undefined) {
|
||||
return
|
||||
}
|
||||
const entries = value.split(':')
|
||||
if (
|
||||
!isSafePathText(value) ||
|
||||
entries.length === 0 ||
|
||||
entries.some((entry) => !posix.isAbsolute(entry))
|
||||
) {
|
||||
throw new Error('Invalid PATH')
|
||||
}
|
||||
target.PATH = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new environment rather than filtering in place. Native desktop
|
||||
* helpers must never inherit loader hooks, provider credentials, or proxies.
|
||||
*/
|
||||
export async function buildDesktopHelperEnvironment(
|
||||
options: SessionEnvironmentOptions
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
const source = options.source ?? process.env
|
||||
const environment: NodeJS.ProcessEnv = {}
|
||||
|
||||
copySafePath(environment, source)
|
||||
copySafeValue(environment, source, 'LANG', LOCALE_PATTERN)
|
||||
copySafeValue(environment, source, 'LC_ALL', LOCALE_PATTERN)
|
||||
copySafeValue(environment, source, 'LC_CTYPE', LOCALE_PATTERN)
|
||||
|
||||
const display = source.DISPLAY
|
||||
if (display !== undefined) {
|
||||
if (!DISPLAY_PATTERN.test(display)) {
|
||||
throw new Error('Invalid DISPLAY')
|
||||
}
|
||||
environment.DISPLAY = display
|
||||
}
|
||||
|
||||
const runtimeDirectory = source.XDG_RUNTIME_DIR
|
||||
if (runtimeDirectory !== undefined) {
|
||||
environment.XDG_RUNTIME_DIR = await assertSafePath(
|
||||
runtimeDirectory,
|
||||
'directory',
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
const waylandDisplay = source.WAYLAND_DISPLAY
|
||||
if (waylandDisplay !== undefined) {
|
||||
if (
|
||||
basename(waylandDisplay) !== waylandDisplay ||
|
||||
!WAYLAND_BASENAME_PATTERN.test(waylandDisplay) ||
|
||||
runtimeDirectory === undefined
|
||||
) {
|
||||
throw new Error('Invalid WAYLAND_DISPLAY')
|
||||
}
|
||||
environment.WAYLAND_DISPLAY = waylandDisplay
|
||||
}
|
||||
|
||||
const busAddress = source.DBUS_SESSION_BUS_ADDRESS
|
||||
if (busAddress !== undefined) {
|
||||
environment.DBUS_SESSION_BUS_ADDRESS = validateBusAddress(busAddress)
|
||||
}
|
||||
|
||||
const authority = source.XAUTHORITY
|
||||
if (authority !== undefined) {
|
||||
environment.XAUTHORITY = await assertSafePath(
|
||||
authority,
|
||||
'file',
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
if (source.NO_AT_BRIDGE === '1') {
|
||||
environment.NO_AT_BRIDGE = '1'
|
||||
}
|
||||
|
||||
return environment
|
||||
}
|
||||
Reference in New Issue
Block a user