feat: external capture queue and modal-first add flow (v0.1.1)
This commit is contained in:
182
tools/native-host/host.mjs
Normal file
182
tools/native-host/host.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
let readBuffer = Buffer.alloc(0)
|
||||
let pendingRequests = 0
|
||||
let stdinEnded = false
|
||||
|
||||
function sendMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
process.stdout.write(len)
|
||||
process.stdout.write(body)
|
||||
}
|
||||
|
||||
function parseHeaders(lines = []) {
|
||||
const map = new Map()
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
const key = line.slice(0, idx).trim().toLowerCase()
|
||||
const value = line.slice(idx + 1).trim()
|
||||
if (!key || !value) continue
|
||||
map.set(key, value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function parseCookieHeader(lines = []) {
|
||||
const headers = parseHeaders(lines)
|
||||
return headers.get('cookie') || ''
|
||||
}
|
||||
|
||||
function queueFilePath() {
|
||||
return join(homedir(), '.gdown', 'external_add_queue.jsonl')
|
||||
}
|
||||
|
||||
async function enqueueExternalAdd(payload) {
|
||||
const filePath = queueFilePath()
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function focusGdownApp() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
resolve({ ok: true, note: 'focus noop on non-macos in step1 host' })
|
||||
return
|
||||
}
|
||||
|
||||
const attempts = [
|
||||
['osascript', ['-e', 'tell application id "com.tauri.dev" to activate']],
|
||||
['osascript', ['-e', 'tell application "gdown" to activate']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "gdown" to true']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "Gdown" to true']],
|
||||
['osascript', ['-e', 'tell application "System Events" to set frontmost of first process whose name is "app" to true']],
|
||||
['open', ['-b', 'com.tauri.dev']],
|
||||
['open', ['-a', 'gdown']],
|
||||
]
|
||||
|
||||
const run = (index) => {
|
||||
if (index >= attempts.length) {
|
||||
resolve({ ok: false, message: 'all focus strategies failed' })
|
||||
return
|
||||
}
|
||||
|
||||
const [bin, args] = attempts[index]
|
||||
execFile(bin, args, (error) => {
|
||||
if (!error) {
|
||||
resolve({ ok: true, strategy: `${bin} ${args.join(' ')}` })
|
||||
return
|
||||
}
|
||||
run(index + 1)
|
||||
})
|
||||
}
|
||||
|
||||
run(0)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const action = String(message?.action || '').trim()
|
||||
|
||||
if (action === 'ping') {
|
||||
return {
|
||||
ok: true,
|
||||
version: '0.1.0',
|
||||
host: 'org.gdown.nativehost',
|
||||
capabilities: ['ping', 'addUri', 'focus'],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'addUri') {
|
||||
const url = String(message?.url || '').trim()
|
||||
if (!url) return { ok: false, error: 'url is required' }
|
||||
const referer = String(message?.referer || '').trim()
|
||||
const userAgent = String(message?.userAgent || '').trim()
|
||||
const out = String(message?.out || '').trim()
|
||||
const dir = String(message?.dir || '').trim()
|
||||
const cookie = String(message?.cookie || '').trim()
|
||||
const authorization = String(message?.authorization || '').trim()
|
||||
const proxy = String(message?.proxy || '').trim()
|
||||
const split = Number(message?.split || 0)
|
||||
|
||||
const parsedCookie = parseCookieHeader(Array.isArray(message?.headers) ? message.headers : [])
|
||||
const cookieValue = cookie || parsedCookie
|
||||
|
||||
await enqueueExternalAdd({
|
||||
url,
|
||||
referer: referer || undefined,
|
||||
userAgent: userAgent || undefined,
|
||||
out: out || undefined,
|
||||
dir: dir || undefined,
|
||||
cookie: cookieValue || undefined,
|
||||
authorization: authorization || undefined,
|
||||
proxy: proxy || undefined,
|
||||
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
|
||||
})
|
||||
await focusGdownApp()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pending: true,
|
||||
mode: 'prompt',
|
||||
requestId: `pending-${Date.now()}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'focus') {
|
||||
return focusGdownApp()
|
||||
}
|
||||
|
||||
return { ok: false, error: `unknown action: ${action || '(empty)'}` }
|
||||
}
|
||||
|
||||
async function drainBuffer() {
|
||||
while (readBuffer.length >= 4) {
|
||||
const bodyLength = readBuffer.readUInt32LE(0)
|
||||
if (readBuffer.length < 4 + bodyLength) return
|
||||
const body = readBuffer.subarray(4, 4 + bodyLength)
|
||||
readBuffer = readBuffer.subarray(4 + bodyLength)
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(body.toString('utf8'))
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: `invalid JSON payload: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
pendingRequests += 1
|
||||
const result = await handleRequest(payload)
|
||||
sendMessage(result)
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: String(error) })
|
||||
} finally {
|
||||
pendingRequests -= 1
|
||||
if (stdinEnded && pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', async (chunk) => {
|
||||
readBuffer = Buffer.concat([readBuffer, chunk])
|
||||
await drainBuffer()
|
||||
})
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
stdinEnded = true
|
||||
if (pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Emit one line for manual shell smoke visibility (not used by native messaging framing).
|
||||
process.stderr.write(`[native-host] started pid=${process.pid}\n`)
|
||||
Reference in New Issue
Block a user