feat: external capture queue and modal-first add flow (v0.1.1)
This commit is contained in:
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "/opt/homebrew/bin/node" "/Volumes/WD/Users/yommi/Work/tauri_projects/gdown/tools/native-host/host.mjs"
|
||||
24
tools/native-host/README.md
Normal file
24
tools/native-host/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# gdown Native Messaging Host (Step 1 MVP)
|
||||
|
||||
## Install (macOS + Chrome)
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash install-macos.sh <EXTENSION_ID>
|
||||
```
|
||||
|
||||
If extension ID is omitted, default ID is used.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash uninstall-macos.sh
|
||||
```
|
||||
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`)
|
||||
41
tools/native-host/install-macos.sh
Executable file
41
tools/native-host/install-macos.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXTENSION_ID="${1:-alaohbbicffclloghmknhlmfdbobcigc}"
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"
|
||||
TEMPLATE_PATH="$SCRIPT_DIR/manifest/${HOST_NAME}.json.template"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
NODE_PATH="$(command -v node || true)"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_PATH" ]]; then
|
||||
echo "template not found: $TEMPLATE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_PATH" ]]; then
|
||||
echo "node not found in current shell PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CHROME_DIR"
|
||||
mkdir -p "$SCRIPT_DIR/.runtime"
|
||||
|
||||
cat > "$RUNNER_PATH" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$NODE_PATH" "$SCRIPT_DIR/host.mjs"
|
||||
EOF
|
||||
|
||||
chmod +x "$RUNNER_PATH"
|
||||
|
||||
sed \
|
||||
-e "s|__ABSOLUTE_HOST_PATH__|$RUNNER_PATH|g" \
|
||||
-e "s|__EXTENSION_ID__|$EXTENSION_ID|g" \
|
||||
"$TEMPLATE_PATH" > "$OUT_PATH"
|
||||
|
||||
echo "installed: $OUT_PATH"
|
||||
echo "extension id: $EXTENSION_ID"
|
||||
echo "node path: $NODE_PATH"
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "org.gdown.nativehost",
|
||||
"description": "gdown Native Messaging Host",
|
||||
"path": "__ABSOLUTE_HOST_PATH__",
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
"chrome-extension://__EXTENSION_ID__/"
|
||||
]
|
||||
}
|
||||
13
tools/native-host/package.json
Normal file
13
tools/native-host/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "gdown-native-host",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./host.mjs",
|
||||
"smoke": "node ./smoke.mjs"
|
||||
}
|
||||
}
|
||||
5
tools/native-host/run-host.sh
Executable file
5
tools/native-host/run-host.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$SCRIPT_DIR/host.mjs"
|
||||
41
tools/native-host/smoke.mjs
Normal file
41
tools/native-host/smoke.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
function encodeMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
return Buffer.concat([len, body])
|
||||
}
|
||||
|
||||
function decodeMessages(buffer) {
|
||||
const messages = []
|
||||
let offset = 0
|
||||
while (offset + 4 <= buffer.length) {
|
||||
const len = buffer.readUInt32LE(offset)
|
||||
if (offset + 4 + len > buffer.length) break
|
||||
const body = buffer.subarray(offset + 4, offset + 4 + len)
|
||||
messages.push(JSON.parse(body.toString('utf8')))
|
||||
offset += 4 + len
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['host.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
child.stdout.on('data', (chunk) => chunks.push(chunk))
|
||||
|
||||
child.stdin.write(encodeMessage({ action: 'ping' }))
|
||||
setTimeout(() => {
|
||||
child.stdin.end()
|
||||
}, 120)
|
||||
|
||||
child.on('exit', () => {
|
||||
const out = Buffer.concat(chunks)
|
||||
const messages = decodeMessages(out)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(messages, null, 2))
|
||||
})
|
||||
13
tools/native-host/uninstall-macos.sh
Executable file
13
tools/native-host/uninstall-macos.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
|
||||
if [[ -f "$OUT_PATH" ]]; then
|
||||
rm -f "$OUT_PATH"
|
||||
echo "removed: $OUT_PATH"
|
||||
else
|
||||
echo "not found: $OUT_PATH"
|
||||
fi
|
||||
Reference in New Issue
Block a user