@pointylab/dsh 0.0.0-stage → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ASSETS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +33 -0
- package/package.json +38 -4
- package/pet/app/card/card.css +365 -0
- package/pet/app/card/card.html +68 -0
- package/pet/app/card/card.js +896 -0
- package/pet/app/card/markdown.js +64 -0
- package/pet/app/card/mascot/blink.png +0 -0
- package/pet/app/card/mascot/done.png +0 -0
- package/pet/app/card/mascot/error.png +0 -0
- package/pet/app/card/mascot/idle.png +0 -0
- package/pet/app/card/mascot/peek.png +0 -0
- package/pet/app/card/mascot/think.png +0 -0
- package/pet/app/card/preload.js +21 -0
- package/pet/app/fullscreen.js +131 -0
- package/pet/app/main.js +1284 -0
- package/pet/app/native/uia-selection.ps1 +158 -0
- package/pet/app/notice/notice.html +37 -0
- package/pet/app/notice/notice.js +31 -0
- package/pet/app/notice/preload.js +7 -0
- package/pet/app/overlay/overlay.html +30 -0
- package/pet/app/overlay/overlay.js +42 -0
- package/pet/app/overlay/preload.js +7 -0
- package/pet/app/pill/pill.css +73 -0
- package/pet/app/pill/pill.html +11 -0
- package/pet/app/pill/pill.js +35 -0
- package/pet/app/pill/preload.js +8 -0
- package/pet/app/preload.js +16 -0
- package/pet/app/renderer/index.html +24 -0
- package/pet/app/renderer/lines.js +25 -0
- package/pet/app/renderer/pet.js +292 -0
- package/pet/app/renderer/renderer2d.js +71 -0
- package/pet/app/renderer/style.css +157 -0
- package/pet/app/selection.js +246 -0
- package/pet/app/shake.js +76 -0
- package/pet/app/uipick.js +314 -0
- package/pet/assets/cursor/cursor-32.cur +0 -0
- package/pet/assets/cursor/cursor-32.png +0 -0
- package/pet/assets/cursor/cursor-48.cur +0 -0
- package/pet/assets/cursor/cursor-48.png +0 -0
- package/pet/assets/cursor/cursor-64.cur +0 -0
- package/pet/assets/cursor/cursor-64.png +0 -0
- package/pet/assets/cursor/cursor-clean.png +0 -0
- package/pet/assets/cursor/make_cursor.py +85 -0
- package/pet/assets/cursor/source.png +0 -0
- package/pet/assets/pointy.ico +0 -0
- package/pet/assets/tray.png +0 -0
- package/pet/assets/tray@2x.png +0 -0
- package/pet/boot.js +6 -0
- package/pet/extension/background.js +252 -0
- package/pet/extension/cursor-32.png +0 -0
- package/pet/extension/cursor-48.png +0 -0
- package/pet/extension/cursor-64.png +0 -0
- package/pet/extension/icon.png +0 -0
- package/pet/extension/manifest.json +12 -0
- package/pet/extension/pick.js +391 -0
- package/pet/extension/popup.html +29 -0
- package/pet/extension/popup.js +30 -0
- package/pet/package.json +11 -0
- package/pet/vendor/koffi/LICENSE.txt +22 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
- package/pet/vendor/koffi/index.js +634 -0
- package/pet/vendor/koffi/indirect.js +533 -0
- package/pet/vendor/koffi/package.json +35 -0
- package/pet/vendor/ws/LICENSE +20 -0
- package/pet/vendor/ws/index.js +22 -0
- package/pet/vendor/ws/lib/buffer-util.js +142 -0
- package/pet/vendor/ws/lib/constants.js +19 -0
- package/pet/vendor/ws/lib/event-target.js +292 -0
- package/pet/vendor/ws/lib/extension.js +203 -0
- package/pet/vendor/ws/lib/limiter.js +55 -0
- package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
- package/pet/vendor/ws/lib/receiver.js +743 -0
- package/pet/vendor/ws/lib/sender.js +610 -0
- package/pet/vendor/ws/lib/stream.js +161 -0
- package/pet/vendor/ws/lib/subprotocol.js +62 -0
- package/pet/vendor/ws/lib/validation.js +152 -0
- package/pet/vendor/ws/lib/websocket-server.js +562 -0
- package/pet/vendor/ws/lib/websocket.js +1426 -0
- package/pet/vendor/ws/package.json +74 -0
- package/pet/vendor/ws/wrapper.mjs +21 -0
- package/src/node.js +732 -0
- package/src/runtime.js +178 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""把 source.png(画进图里的灰白格子"假透明")抠成真透明,裁到内容,
|
|
2
|
+
再缩成光标用的 PNG 和 .cur(热点在箭头尖)。用法:python make_cursor.py"""
|
|
3
|
+
import io
|
|
4
|
+
import struct
|
|
5
|
+
from collections import deque
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
HERE = Path(__file__).parent
|
|
12
|
+
SIZES = (32, 48, 64)
|
|
13
|
+
|
|
14
|
+
img = np.array(Image.open(HERE / 'source.png').convert('RGB')).astype(int)
|
|
15
|
+
h, w, _ = img.shape
|
|
16
|
+
r, g, b = img[..., 0], img[..., 1], img[..., 2]
|
|
17
|
+
# 格子是浅灰和白,几乎没有颜色;人物和箭头都有深蓝描边把里面围起来
|
|
18
|
+
neutral = (np.abs(r - g) < 18) & (np.abs(g - b) < 18) & (np.abs(r - b) < 18) & (r > 110)
|
|
19
|
+
|
|
20
|
+
# 从四条边往里漫水,只吃连通的"无色浅色",描边挡住,箭头里面的白不会被吃掉
|
|
21
|
+
bg = np.zeros((h, w), bool)
|
|
22
|
+
q = deque((y, x) for y in range(h) for x in (0, w - 1)) + deque((y, x) for x in range(w) for y in (0, h - 1))
|
|
23
|
+
while q:
|
|
24
|
+
y, x = q.popleft()
|
|
25
|
+
if bg[y, x] or not neutral[y, x]:
|
|
26
|
+
continue
|
|
27
|
+
bg[y, x] = True
|
|
28
|
+
if y > 0: q.append((y - 1, x))
|
|
29
|
+
if y < h - 1: q.append((y + 1, x))
|
|
30
|
+
if x > 0: q.append((y, x - 1))
|
|
31
|
+
if x < w - 1: q.append((y, x + 1))
|
|
32
|
+
|
|
33
|
+
# 被描边围住的小洞(比如呆毛圈里)漫水进不去;格子是深浅两种灰交替,按"一块无色区域里明暗差得多"认出来
|
|
34
|
+
seen = bg.copy()
|
|
35
|
+
for sy, sx in zip(*np.where(neutral & ~bg)):
|
|
36
|
+
if seen[sy, sx]:
|
|
37
|
+
continue
|
|
38
|
+
comp, q = [], deque([(sy, sx)])
|
|
39
|
+
seen[sy, sx] = True
|
|
40
|
+
while q:
|
|
41
|
+
y, x = q.popleft()
|
|
42
|
+
comp.append((y, x))
|
|
43
|
+
for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
|
|
44
|
+
if 0 <= ny < h and 0 <= nx < w and not seen[ny, nx] and neutral[ny, nx]:
|
|
45
|
+
seen[ny, nx] = True
|
|
46
|
+
q.append((ny, nx))
|
|
47
|
+
vals = np.array([r[p] for p in comp])
|
|
48
|
+
if len(comp) > 200 and vals.std() > 15:
|
|
49
|
+
for p in comp:
|
|
50
|
+
bg[p] = True
|
|
51
|
+
|
|
52
|
+
rgba = np.dstack([img, np.where(bg, 0, 255)]).astype(np.uint8)
|
|
53
|
+
out = Image.fromarray(rgba, 'RGBA')
|
|
54
|
+
ys, xs = np.where(~bg)
|
|
55
|
+
box = (xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)
|
|
56
|
+
out = out.crop(box)
|
|
57
|
+
|
|
58
|
+
# 箭头尖:最左边那一列里最靠上的不透明像素(箭头是整张图最左的东西)
|
|
59
|
+
a = np.array(out)[..., 3]
|
|
60
|
+
col = np.where(a.any(axis=0))[0][0]
|
|
61
|
+
tip = (int(col), int(np.where(a[:, col])[0][0]))
|
|
62
|
+
side = max(out.size)
|
|
63
|
+
square = Image.new('RGBA', (side, side), (0, 0, 0, 0))
|
|
64
|
+
square.paste(out, (0, 0)) # 左上对齐,箭头尖靠近左上角
|
|
65
|
+
square.save(HERE / 'cursor-clean.png')
|
|
66
|
+
print('cropped', out.size, 'tip in cropped', tip)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cur_file(img, size, hx, hy):
|
|
70
|
+
# 先存成 BMP 格式的 .ico,再把类型改成光标(2)、把热点写进 planes / bitcount 的位置。
|
|
71
|
+
# PNG 压缩的 .cur LoadImage 读不了,必须用 BMP
|
|
72
|
+
buf = io.BytesIO()
|
|
73
|
+
img.save(buf, 'ICO', sizes=[(size, size)], bitmap_format='bmp')
|
|
74
|
+
b = bytearray(buf.getvalue())
|
|
75
|
+
struct.pack_into('<H', b, 2, 2)
|
|
76
|
+
struct.pack_into('<HH', b, 6 + 4, hx, hy)
|
|
77
|
+
return bytes(b)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
for s in SIZES:
|
|
81
|
+
small = square.resize((s, s), Image.LANCZOS)
|
|
82
|
+
small.save(HERE / f'cursor-{s}.png')
|
|
83
|
+
hx, hy = round(tip[0] * s / side), round(tip[1] * s / side)
|
|
84
|
+
(HERE / f'cursor-{s}.cur').write_bytes(cur_file(small, s, hx, hy))
|
|
85
|
+
print(f'{s}px hotspot', hx, hy)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/pet/boot.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// 生成的文件(tools/pack-plugin.mjs):让 require('koffi') / require('ws') 找到 vendor 里的副本
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const Module = require('node:module')
|
|
4
|
+
process.env.NODE_PATH = [path.join(__dirname, 'vendor'), process.env.NODE_PATH].filter(Boolean).join(path.delimiter)
|
|
5
|
+
Module._initPaths()
|
|
6
|
+
require('./app/main.js')
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// 和本机 pointy 保持一条 WebSocket:Pointy按快捷键时来要选区,右键菜单时主动发过去。
|
|
2
|
+
// 只在这两种时候读页面,平时不碰任何标签页。
|
|
3
|
+
// Pointy默认端口;storage 里存了 port 就用那个(Pointy改过 POINTY_PORT 时)
|
|
4
|
+
const DEFAULT_PORT = 47321
|
|
5
|
+
const petUrl = async () => `ws://127.0.0.1:${(await chrome.storage.local.get('port')).port ?? DEFAULT_PORT}`
|
|
6
|
+
const BROWSER = navigator.userAgent.includes('Edg/') ? 'edge' : 'chrome'
|
|
7
|
+
const KEEPALIVE_MS = 20_000
|
|
8
|
+
|
|
9
|
+
let ws
|
|
10
|
+
let authed = false
|
|
11
|
+
let keepalive
|
|
12
|
+
|
|
13
|
+
const enc = new TextEncoder()
|
|
14
|
+
const hex = (buf) => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
15
|
+
|
|
16
|
+
async function hmac(token, data) {
|
|
17
|
+
const key = await crypto.subtle.importKey('raw', enc.encode(token), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
18
|
+
return hex(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const randomHex = () => hex(crypto.getRandomValues(new Uint8Array(16)))
|
|
22
|
+
|
|
23
|
+
async function connect() {
|
|
24
|
+
if (ws && ws.readyState <= WebSocket.OPEN) return
|
|
25
|
+
const { id, token } = await chrome.storage.local.get(['id', 'token'])
|
|
26
|
+
// 还没配对:自己去问 Pointy,Pointy 弹窗让用户点"允许"
|
|
27
|
+
if (!id || !token) { if (Date.now() >= pairQuietUntil) await pair(false); return }
|
|
28
|
+
const nonce = randomHex()
|
|
29
|
+
const sock = new WebSocket(await petUrl())
|
|
30
|
+
ws = sock
|
|
31
|
+
authed = false
|
|
32
|
+
sock.onmessage = async (ev) => {
|
|
33
|
+
const msg = JSON.parse(ev.data)
|
|
34
|
+
if (msg.type === 'challenge') {
|
|
35
|
+
sock.send(JSON.stringify({ type: 'hello', role: 'extension', id, browser: BROWSER, version: chrome.runtime.getManifest().version, nonce, proof: await hmac(token, `ext|${msg.nonce}`) }))
|
|
36
|
+
} else if (msg.type === 'hello-ok') {
|
|
37
|
+
if (msg.proof !== await hmac(token, `pet|${nonce}`)) return sock.close()
|
|
38
|
+
authed = true
|
|
39
|
+
clearInterval(keepalive)
|
|
40
|
+
// 有流量的 WebSocket 会让 MV3 service worker 一直活着
|
|
41
|
+
keepalive = setInterval(() => sock.readyState === WebSocket.OPEN && sock.send('{"type":"ping"}'), KEEPALIVE_MS)
|
|
42
|
+
} else if (authed && msg.type === 'grab') {
|
|
43
|
+
const result = await grabActiveTab()
|
|
44
|
+
sock.send(JSON.stringify({ type: 'grab-result', reqId: msg.reqId, ...result }))
|
|
45
|
+
} else if (authed && msg.type === 'pick-abort') {
|
|
46
|
+
if (pickTab) chrome.tabs.sendMessage(pickTab.id, { type: 'pick-abort' }).catch(() => {})
|
|
47
|
+
pickReq = undefined
|
|
48
|
+
} else if (authed && msg.type === 'focus-tab') {
|
|
49
|
+
focusTab(msg.origin)
|
|
50
|
+
} else if (authed && msg.type === 'reload') {
|
|
51
|
+
// 磁盘上的扩展比正在跑的新(Pointy 看 manifest 版本号判断),自己重载一次,不用去 chrome://extensions 点刷新
|
|
52
|
+
chrome.runtime.reload()
|
|
53
|
+
} else if (authed && msg.type === 'pick-release') {
|
|
54
|
+
const tabId = heldTabs.get(msg.reqId)
|
|
55
|
+
heldTabs.delete(msg.reqId)
|
|
56
|
+
if (tabId !== undefined) chrome.tabs.sendMessage(tabId, { type: 'pick-release', reqId: msg.reqId }).catch(() => {})
|
|
57
|
+
} else if (authed && msg.type === 'pick-start') {
|
|
58
|
+
const ok = await startPick(msg.cursor, msg.urlHint)
|
|
59
|
+
if (!ok) sock.send(JSON.stringify({ type: 'pick-unavailable', reqId: msg.reqId }))
|
|
60
|
+
else {
|
|
61
|
+
pickReq = msg.reqId
|
|
62
|
+
// 回报注入到了哪个标签页,Pointy记日志,方便查"高亮框跑哪去了"
|
|
63
|
+
sock.send(JSON.stringify({ type: 'pick-started', reqId: msg.reqId, tabTitle: pickTab?.title, tabUrl: pickTab?.url }))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
sock.onclose = () => {
|
|
68
|
+
if (ws === sock) { ws = undefined; authed = false }
|
|
69
|
+
clearInterval(keepalive)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 在页面里执行:取选区和它前后的文字。密码框直接拒绝。 */
|
|
74
|
+
function extractSelection() {
|
|
75
|
+
const active = document.activeElement
|
|
76
|
+
if (active && active.tagName === 'INPUT' && active.type === 'password') return { blocked: '焦点在密码框里' }
|
|
77
|
+
const editable = active && (active.tagName === 'TEXTAREA' || (active.tagName === 'INPUT' && /^(text|search|url|email|)$/.test(active.type)))
|
|
78
|
+
if (editable && active.selectionStart !== active.selectionEnd) {
|
|
79
|
+
const { value, selectionStart: s, selectionEnd: e } = active
|
|
80
|
+
return { text: value.slice(s, e).slice(0, 20000), before: value.slice(Math.max(0, s - 400), s).trim(), after: value.slice(e, e + 400).trim() }
|
|
81
|
+
}
|
|
82
|
+
const sel = window.getSelection()
|
|
83
|
+
const text = sel ? sel.toString() : ''
|
|
84
|
+
let before = ''
|
|
85
|
+
let after = ''
|
|
86
|
+
if (text && sel.rangeCount) {
|
|
87
|
+
let block = sel.getRangeAt(0).commonAncestorContainer
|
|
88
|
+
if (block.nodeType !== 1) block = block.parentElement
|
|
89
|
+
block = block.closest('p,li,td,blockquote,pre,article,section,div') || block
|
|
90
|
+
const full = block.innerText || ''
|
|
91
|
+
const needle = text.trim().slice(0, 60)
|
|
92
|
+
const at = needle ? full.indexOf(needle) : -1
|
|
93
|
+
if (at >= 0) {
|
|
94
|
+
before = full.slice(Math.max(0, at - 400), at).trim()
|
|
95
|
+
after = full.slice(at + text.trim().length, at + text.trim().length + 400).trim()
|
|
96
|
+
}
|
|
97
|
+
if (!before) before = (block.previousElementSibling?.innerText || '').slice(-400).trim()
|
|
98
|
+
if (!after) after = (block.nextElementSibling?.innerText || '').slice(0, 400).trim()
|
|
99
|
+
}
|
|
100
|
+
return { text: text.slice(0, 20000), before, after }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function grabActiveTab(tab) {
|
|
104
|
+
tab ??= (await chrome.tabs.query({ active: true, lastFocusedWindow: true }))[0]
|
|
105
|
+
if (!tab) return {}
|
|
106
|
+
const base = { url: tab.url, pageTitle: tab.title, browser: BROWSER }
|
|
107
|
+
// chrome:// 之类的页面不让注入脚本,只带网址和标题
|
|
108
|
+
if (!/^(https?|file):/.test(tab.url ?? '')) return base
|
|
109
|
+
try {
|
|
110
|
+
const frames = await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, func: extractSelection })
|
|
111
|
+
const blocked = frames.find(f => f.result?.blocked)
|
|
112
|
+
if (blocked) return { ...base, blocked: blocked.result.blocked }
|
|
113
|
+
const hit = frames.find(f => f.result?.text) ?? frames.find(f => f.frameId === 0)
|
|
114
|
+
return { ...base, ...(hit?.result ?? {}) }
|
|
115
|
+
} catch {
|
|
116
|
+
return base
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let pickReq
|
|
121
|
+
let pickTab
|
|
122
|
+
const heldTabs = new Map() // reqId -> 还留着高亮框的标签页 id,卡片关掉时Pointy发 pick-release 过来
|
|
123
|
+
|
|
124
|
+
/** 往当前标签页注入指认脚本;chrome:// 之类的页面注入不了就返回 false,Pointy会改用系统层面的指认 */
|
|
125
|
+
async function startPick(cursor, urlHint) {
|
|
126
|
+
// 正常是晃鼠标时眼前的那个标签页;Pointy也可以指定网址(测试时用,不必把窗口切到前台)
|
|
127
|
+
const [tab] = urlHint
|
|
128
|
+
? (await chrome.tabs.query({})).filter(t => t.url === urlHint || t.url?.startsWith(urlHint))
|
|
129
|
+
: await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
|
130
|
+
if (!tab || !/^(https?|file):/.test(tab.url ?? '')) return false
|
|
131
|
+
try {
|
|
132
|
+
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (c) => { window.__pointyStart = c }, args: [cursor ?? null] })
|
|
133
|
+
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['pick.js'] })
|
|
134
|
+
pickTab = tab
|
|
135
|
+
return true
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 截当前可见区域,按元素位置裁出来;返回 data URL */
|
|
142
|
+
async function cropElement(tab, rect, viewport) {
|
|
143
|
+
const shot = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' })
|
|
144
|
+
const bitmap = await createImageBitmap(await (await fetch(shot)).blob())
|
|
145
|
+
const scale = bitmap.width / viewport.width
|
|
146
|
+
const pad = 8
|
|
147
|
+
const sx = Math.max(0, Math.floor((rect.x - pad) * scale))
|
|
148
|
+
const sy = Math.max(0, Math.floor((rect.y - pad) * scale))
|
|
149
|
+
const sw = Math.min(bitmap.width - sx, Math.ceil((rect.width + pad * 2) * scale))
|
|
150
|
+
const sh = Math.min(bitmap.height - sy, Math.ceil((rect.height + pad * 2) * scale))
|
|
151
|
+
if (sw < 4 || sh < 4) return undefined
|
|
152
|
+
// 太大的缩到 1600 宽以内,够模型看清
|
|
153
|
+
const k = Math.min(1, 1600 / sw)
|
|
154
|
+
const canvas = new OffscreenCanvas(Math.round(sw * k), Math.round(sh * k))
|
|
155
|
+
canvas.getContext('2d').drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)
|
|
156
|
+
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
|
|
157
|
+
const bytes = new Uint8Array(await blob.arrayBuffer())
|
|
158
|
+
let bin = ''
|
|
159
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
160
|
+
return `data:image/jpeg;base64,${btoa(bin)}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function onPickResult(msg, sender) {
|
|
164
|
+
const reqId = pickReq
|
|
165
|
+
pickReq = undefined
|
|
166
|
+
if (!authed || reqId === undefined) return
|
|
167
|
+
if (msg.cancelled || !msg.result) return ws.send(JSON.stringify({ type: 'pick-cancel', reqId }))
|
|
168
|
+
const tab = sender.tab ?? pickTab
|
|
169
|
+
// 图片、视频、画布这类光看文字不知道是什么的,截那一块一起发(只在内存里传,不存盘)
|
|
170
|
+
const r = msg.result
|
|
171
|
+
const visual = r.kind === 'image' || r.kind === 'video' || r.tag === 'canvas' || ((r.text ?? '').trim().length < 30 && r.images?.length > 0)
|
|
172
|
+
const image = visual ? await cropElement(tab, r.rect, r.viewport).catch(() => undefined) : undefined
|
|
173
|
+
// 截完了,让页面把高亮框重新显示出来,卡片开着的时候一直留着
|
|
174
|
+
heldTabs.set(reqId, tab.id)
|
|
175
|
+
chrome.tabs.sendMessage(tab.id, { type: 'pick-hold', reqId }).catch(() => {})
|
|
176
|
+
ws.send(JSON.stringify({ type: 'picked', reqId, url: tab.url, pageTitle: tab.title, browser: BROWSER, element: r, image }))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// web 版 DSH:Pointy 卡片上点"在 DSH 中打开"时,把开着它的标签页切到前面
|
|
180
|
+
async function focusTab(origin) {
|
|
181
|
+
if (typeof origin !== 'string' || !/^https?:\/\/[^/]+$/.test(origin)) return
|
|
182
|
+
const tabs = await chrome.tabs.query({})
|
|
183
|
+
const tab = tabs.find((t) => t.url?.startsWith(origin + '/'))
|
|
184
|
+
// 没开着就不新开:web 版首次打开要带 token 的地址,扩展拿不到
|
|
185
|
+
if (!tab) return
|
|
186
|
+
await chrome.tabs.update(tab.id, { active: true })
|
|
187
|
+
await chrome.windows.update(tab.windowId, { focused: true })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 用户点了"不允许"或 Pointy 正忙着问别的:过一阵再自动问;弹窗里手动点"连接"不受这个限制
|
|
191
|
+
const PAIR_QUIET_MS = 10 * 60_000
|
|
192
|
+
let pairQuietUntil = 0
|
|
193
|
+
let pairing
|
|
194
|
+
|
|
195
|
+
function pair(manual) {
|
|
196
|
+
if (pairing) return pairing
|
|
197
|
+
pairing = (async () => {
|
|
198
|
+
const url = await petUrl()
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const sock = new WebSocket(url)
|
|
201
|
+
let timer
|
|
202
|
+
let settled = false
|
|
203
|
+
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); pairing = undefined; resolve(result); try { sock.close() } catch {} }
|
|
204
|
+
// Pointy 在等用户点的时候每 10 秒发一条 pair-wait;超过这个没动静就当它不在了
|
|
205
|
+
const arm = () => { clearTimeout(timer); timer = setTimeout(() => finish({ ok: false, error: 'Pointy 没响应' }), 20_000) }
|
|
206
|
+
arm()
|
|
207
|
+
sock.onerror = () => finish({ ok: false, error: '连不上 Pointy:确认 DSH 开着、Pointy 插件已启用' })
|
|
208
|
+
sock.onclose = () => finish({ ok: false, error: '连接断了' })
|
|
209
|
+
sock.onmessage = async (ev) => {
|
|
210
|
+
const msg = JSON.parse(ev.data)
|
|
211
|
+
arm()
|
|
212
|
+
if (msg.type === 'challenge') sock.send(JSON.stringify({ type: 'pair-request', browser: BROWSER, manual }))
|
|
213
|
+
else if (msg.type === 'paired') {
|
|
214
|
+
await chrome.storage.local.set({ id: msg.id, token: msg.token })
|
|
215
|
+
finish({ ok: true })
|
|
216
|
+
connect()
|
|
217
|
+
} else if (msg.type === 'pair-denied' || msg.type === 'pair-busy') {
|
|
218
|
+
pairQuietUntil = Date.now() + PAIR_QUIET_MS
|
|
219
|
+
finish({ ok: false, error: msg.type === 'pair-busy' ? 'Pointy 正在问别的,稍后再试' : '在 Pointy 那边没有允许' })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
})()
|
|
224
|
+
return pairing
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
228
|
+
chrome.contextMenus.create({ id: 'ask-pet', title: '问 Pointy:“%s”', contexts: ['selection'] })
|
|
229
|
+
chrome.contextMenus.create({ id: 'ask-pet-page', title: '问 Pointy 这个页面', contexts: ['page'] })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
233
|
+
await connect()
|
|
234
|
+
const result = await grabActiveTab(tab)
|
|
235
|
+
if (!result.text && info.selectionText) result.text = info.selectionText
|
|
236
|
+
if (authed) ws.send(JSON.stringify({ type: 'selection', ...result }))
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
|
|
240
|
+
if (msg.type === 'pick-result') { onPickResult(msg, sender); return }
|
|
241
|
+
if (msg.type === 'pair') { pair(true).then(reply); return true }
|
|
242
|
+
if (msg.type === 'status') {
|
|
243
|
+
chrome.storage.local.get(['id']).then(({ id }) => reply({ paired: !!id, connected: authed }))
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
if (msg.type === 'unpair') { chrome.storage.local.remove(['id', 'token']).then(() => { ws?.close(); reply({ ok: true }) }); return true }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
chrome.alarms.create('reconnect', { periodInMinutes: 1 })
|
|
250
|
+
chrome.alarms.onAlarm.addListener(() => connect())
|
|
251
|
+
chrome.runtime.onStartup.addListener(() => connect())
|
|
252
|
+
connect()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Pointy 指哪问哪",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"description": "晃一下鼠标指认网页上的内容,或右键「问 Pointy」,把指到的内容、网址和标题交给本机的 Pointy。只在被叫到时读页面。",
|
|
6
|
+
"permissions": ["storage", "scripting", "contextMenus", "alarms"],
|
|
7
|
+
"host_permissions": ["<all_urls>"],
|
|
8
|
+
"background": { "service_worker": "background.js" },
|
|
9
|
+
"action": { "default_title": "Pointy", "default_popup": "popup.html" },
|
|
10
|
+
"icons": { "128": "icon.png" },
|
|
11
|
+
"web_accessible_resources": [{ "resources": ["cursor-32.png", "cursor-48.png", "cursor-64.png"], "matches": ["<all_urls>"] }]
|
|
12
|
+
}
|