feat: external capture queue and modal-first add flow (v0.1.1)

This commit is contained in:
2026-02-25 01:40:52 +09:00
parent 552f27c002
commit e9f332171e
24 changed files with 2772 additions and 110 deletions

View 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"

View 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
View 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`)

View 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"

View File

@@ -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__/"
]
}

View 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
View 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"

View 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))
})

View 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