fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout
- LND wallet: request correct address type so receive-address generation no longer 400s - AIUI/app session: on-screen pointer can click + type into app content (incl. app store search); "open in new tab" opens the phone browser; mobile credential modal centered instead of full-height (remote-relay.ts, AppSession.vue, AppSessionFrame.vue, AppIconGrid.vue, openExternal.ts, WebViewScreen.kt) + remote-relay tests - health_monitor: electrs auto-recovers from a corrupt index and shows a percent/block-height progress screen while reindexing (useElectrsSync.ts) - update.rs: drop retired tx1138 secondary mirror (one-time migration); longer download timeout for slow connections - CHANGELOG: v1.7.90-alpha notes - tests/release/run.sh: harness tweaks Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
340b981b79
commit
c800293f1f
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { isTextField, typeKeyIntoField } from '../remote-relay'
|
||||
|
||||
/**
|
||||
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
|
||||
* values in the browser, so the relay edits `.value` at the caret directly and
|
||||
* fires an `input` event. These tests lock in that behaviour so a regression
|
||||
* (like the old "type goes to document, nothing happens" bug) is caught before
|
||||
* release rather than by a user with a companion controller.
|
||||
*/
|
||||
describe('isTextField', () => {
|
||||
it('accepts text-like inputs and textareas', () => {
|
||||
const text = document.createElement('input')
|
||||
text.type = 'text'
|
||||
const search = document.createElement('input')
|
||||
search.type = 'search'
|
||||
const area = document.createElement('textarea')
|
||||
expect(isTextField(text)).toBe(true)
|
||||
expect(isTextField(search)).toBe(true)
|
||||
expect(isTextField(area)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-text controls and null', () => {
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
expect(isTextField(checkbox)).toBe(false)
|
||||
expect(isTextField(document.createElement('button'))).toBe(false)
|
||||
expect(isTextField(document.createElement('div'))).toBe(false)
|
||||
expect(isTextField(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeKeyIntoField', () => {
|
||||
let input: HTMLInputElement
|
||||
let inputEvents: number
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.createElement('input')
|
||||
input.type = 'search'
|
||||
document.body.appendChild(input)
|
||||
inputEvents = 0
|
||||
input.addEventListener('input', () => { inputEvents++ })
|
||||
})
|
||||
|
||||
it('inserts printable characters at the caret and fires input', () => {
|
||||
typeKeyIntoField(input, 'b')
|
||||
typeKeyIntoField(input, 't')
|
||||
typeKeyIntoField(input, 'c')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(3)
|
||||
expect(inputEvents).toBe(3)
|
||||
})
|
||||
|
||||
it('inserts a character in the middle of existing text', () => {
|
||||
input.value = 'bc'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
typeKeyIntoField(input, 't')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace deletes the char before the caret', () => {
|
||||
input.value = 'btc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('bt')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace removes the active selection', () => {
|
||||
input.value = 'bitcoin'
|
||||
input.selectionStart = 0
|
||||
input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('coin')
|
||||
expect(input.selectionStart).toBe(0)
|
||||
})
|
||||
|
||||
it('arrow keys move the caret without changing the value', () => {
|
||||
input.value = 'abc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'ArrowLeft')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
expect(input.value).toBe('abc')
|
||||
})
|
||||
|
||||
it('Enter on a single-line input is left for the app to handle', () => {
|
||||
input.value = 'query'
|
||||
input.selectionStart = input.selectionEnd = 5
|
||||
const consumed = typeKeyIntoField(input, 'Enter')
|
||||
expect(consumed).toBe(false)
|
||||
expect(input.value).toBe('query')
|
||||
})
|
||||
|
||||
it('Enter inserts a newline in a textarea', () => {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = 'a'
|
||||
area.selectionStart = area.selectionEnd = 1
|
||||
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
|
||||
expect(area.value).toBe('a\n')
|
||||
})
|
||||
|
||||
it('non-text keys are not consumed as editing', () => {
|
||||
input.value = 'x'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
|
||||
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
|
||||
expect(input.value).toBe('x')
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
|
||||
return KEY_MAP[xdotoolKey] ?? xdotoolKey
|
||||
}
|
||||
|
||||
/** <input> types that accept free-text entry (so we should type into them). */
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
|
||||
])
|
||||
|
||||
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
|
||||
if (!el) return false
|
||||
if (el.tagName === 'TEXTAREA') return true
|
||||
if (el.tagName === 'INPUT') {
|
||||
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
|
||||
return TEXT_INPUT_TYPES.has(type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
|
||||
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
|
||||
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
|
||||
* direct ports) are opaque to the parent by browser security policy, so the
|
||||
* deepest reachable element there is the <iframe> itself.
|
||||
*/
|
||||
function deepElementFromPoint(x: number, y: number): Element | null {
|
||||
let cx = x
|
||||
let cy = y
|
||||
let el = document.elementFromPoint(cx, cy)
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc) break
|
||||
const rect = el.getBoundingClientRect()
|
||||
cx -= rect.left
|
||||
cy -= rect.top
|
||||
const inner = doc.elementFromPoint(cx, cy)
|
||||
if (!inner || inner === el) break
|
||||
el = inner
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/** The actually-focused element, descending through same-origin iframes. */
|
||||
function deepActiveElement(): Element | null {
|
||||
let el: Element | null = document.activeElement
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
|
||||
el = doc.activeElement
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
|
||||
* input values (browser security), so we edit `.value` at the caret directly
|
||||
* and fire an `input` event so Vue v-model / reactive search pick it up.
|
||||
* Returns true if the key was consumed as text editing.
|
||||
*/
|
||||
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
|
||||
const value = el.value
|
||||
const start = el.selectionStart ?? value.length
|
||||
const end = el.selectionEnd ?? value.length
|
||||
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
|
||||
const replaceSelection = (text: string) => {
|
||||
el.value = value.slice(0, start) + text + value.slice(end)
|
||||
setCaret(start + text.length)
|
||||
}
|
||||
|
||||
if (key === 'Backspace') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
|
||||
else return true
|
||||
} else if (key === 'Delete') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
|
||||
} else if (key === 'ArrowLeft') {
|
||||
setCaret(Math.max(0, start - 1))
|
||||
} else if (key === 'ArrowRight') {
|
||||
setCaret(Math.min(value.length, end + 1))
|
||||
} else if (key === 'Home') {
|
||||
setCaret(0)
|
||||
} else if (key === 'End') {
|
||||
setCaret(value.length)
|
||||
} else if (key === 'Enter') {
|
||||
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
|
||||
else return false // let the app's keydown handler act (e.g. search submit)
|
||||
} else if (key.length === 1) {
|
||||
replaceSelection(key) // printable character
|
||||
} else {
|
||||
return false // Tab / Escape / F-keys / etc. — not text editing
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
function handleMessage(data: string) {
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||
try {
|
||||
@@ -125,9 +222,17 @@ function handleMessage(data: string) {
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||
}
|
||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
// Deliver the key to the actually-focused element (descending into
|
||||
// same-origin iframes) so it reaches embedded-app inputs and search
|
||||
// boxes, not just the top-level document.
|
||||
const focused = deepActiveElement()
|
||||
const keyTarget: EventTarget = focused ?? document
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
// Synthetic key events never insert text, so edit the field directly.
|
||||
if (isTextField(focused)) {
|
||||
typeKeyIntoField(focused, key)
|
||||
}
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
break
|
||||
}
|
||||
case 'm': {
|
||||
@@ -135,16 +240,29 @@ function handleMessage(data: string) {
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const target = document.elementFromPoint(cursorX, cursorY)
|
||||
const target = deepElementFromPoint(cursorX, cursorY)
|
||||
if (target) {
|
||||
if (cursorEl) {
|
||||
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
||||
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, cancelable: true,
|
||||
const eventInit: MouseEventInit = {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
clientX: cursorX, clientY: cursorY,
|
||||
}))
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('click', eventInit))
|
||||
// A synthetic click does NOT move keyboard focus the way a real click
|
||||
// does, so the app-store search box (and any input) would stay
|
||||
// unfocused and untypable. Explicitly focus the nearest focusable
|
||||
// element — for same-origin iframe targets this focuses inside the app.
|
||||
const focusable = (target.closest?.(
|
||||
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
|
||||
) ?? target) as HTMLElement
|
||||
if (typeof focusable.focus === 'function') {
|
||||
focusable.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user