phone-lens 0.3.5 → 0.3.6

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/lib/client.js CHANGED
@@ -120,15 +120,20 @@ window.__ModuleLoader__.load({
120
120
 
121
121
  // ── composable pre-send (dsh composer draft) ───────────────────────────
122
122
  // The phone photo is staged as a composer draft attachment, NOT injected
123
- // into the model: the user types text next and hits send. Uses the dsh
124
- // channel confirmed from source: sessions.scope(sessionId) → conversation
125
- // service → createDraftImages(files) → input.for(actx).addImages(ids).
123
+ // into the model: the user types text next and hits send. On dsh 0.1.7+
124
+ // the composer is a Lexical editor whose intake is package-internal, so
125
+ // we dispatch a synthetic paste event and the editor's own paste handler
126
+ // admits the file (intakeFiles → createDrafts) — the only stable seam.
126
127
  let hostCtx = null; // set in apply() so the component can reach the runtime
127
128
 
128
129
  function currentSessionId(ctx) {
129
130
  try {
130
- // cordis: accessing a service as a ctx property requires it in
131
- // `inject`; use ctx.get() instead (never throws for absent).
131
+ // rc.2: the current session lives on the uiSession binding source,
132
+ // not on sessions.list.getSnapshot().current (removed in 0.1.7).
133
+ const ui = ctx.get("uiSession");
134
+ const key = ui?.current?.value?.key;
135
+ if (key) return key;
136
+ // pre-0.1.7 fallback.
132
137
  const sessions = ctx.get("sessions");
133
138
  if (!sessions) throw new Error("no sessions service");
134
139
  const list = sessions.list;
@@ -141,25 +146,34 @@ window.__ModuleLoader__.load({
141
146
  }
142
147
  }
143
148
 
149
+ // rc.2 composer is a Lexical editor; its root element carries the
150
+ // data-lexical-editor marker. Fall back to any visible contenteditable.
151
+ function findComposerEditor() {
152
+ const lexical = document.querySelector('div[data-lexical-editor="true"][contenteditable="true"]');
153
+ if (lexical) return lexical;
154
+ const candidates = document.querySelectorAll('[contenteditable="true"]');
155
+ for (const el of candidates) {
156
+ if (el.getClientRects().length > 0) return el;
157
+ }
158
+ return null;
159
+ }
160
+
144
161
  async function stageIntoComposer(ctx, port, attachmentId) {
145
- if (!ctx) throw new Error("no runtime ctx (phone-lens host not wired)");
146
162
  const resp = await fetch(`http://127.0.0.1:${port}/pending/${attachmentId}`).catch((e) => { throw new Error("fetch pending: " + String(e)); });
147
163
  if (!resp.ok) throw new Error("pending fetch HTTP " + resp.status);
148
164
  const buf = await resp.arrayBuffer();
149
165
  const file = new File([buf], `phone-${attachmentId.slice(0, 8)}.jpg`, { type: "image/jpeg" });
150
- const sid = currentSessionId(ctx);
151
- if (!sid) throw new Error("no current session id");
152
- // scope-addressed: ctx.get('sessions').scope(sid) → scoped actx
153
- const sessions = ctx.get("sessions");
154
- const actx = sessions.scope(sid);
155
- if (!actx) throw new Error("no session scope for " + sid);
156
- const conv = actx.get("conversation");
157
- if (!conv) throw new Error("no conversation service in scope");
158
- const atts = conv.createDraftImages([file]);
159
- if (!atts || !atts.length) throw new Error("createDraftImages empty");
160
- const input = conv.input.for(actx);
161
- const ok = input.addImages(atts.map((a) => a.id));
162
- if (ok !== true) throw new Error("input.addImages rejected (busy admission?)");
166
+ // rc.2 path: the composer's own paste handler admits image files
167
+ // through the official intake (intakeFiles → createDrafts), so we
168
+ // dispatch a synthetic paste instead of touching package-internal
169
+ // draft APIs. The composer is bound to the current session already.
170
+ const editor = findComposerEditor();
171
+ if (!editor) throw new Error("no composer input (open a session first)");
172
+ editor.focus({ preventScroll: true });
173
+ const dt = new DataTransfer();
174
+ dt.items.add(file);
175
+ const ev = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true });
176
+ editor.dispatchEvent(ev);
163
177
  return true;
164
178
  }
165
179
 
package/lib/index.js CHANGED
@@ -1456,7 +1456,7 @@ var PhoneLens = class extends Service {
1456
1456
  const attachments = () => ctx.get?.("attachments");
1457
1457
  const onAgent = (agent) => sink.track(agent);
1458
1458
  const offAgent = (agent) => sink.untrack(agent);
1459
- ctx.on?.("agent/session-start", (payload) => onAgent(payload.agent));
1459
+ ctx.on?.("agent/created", (payload) => onAgent(payload.agent));
1460
1460
  ctx.on?.("agent/inbox/inserted", (payload) => onAgent(payload.agent));
1461
1461
  ctx.on?.("agent/status", (payload) => {
1462
1462
  if (payload.status === "running") onAgent(payload.agent);
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["raw: unknown","t: unknown","value: unknown","fallback: number","min: number","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","agent: EventedAgent","admitted: AdmittedImage","note: string | undefined","mode: \"followup\" | \"steer\"","content: unknown[]","config: LensConfig","ttlMs: number","submitted: string","now: number","token: string","recordHash: string","presentedToken: string","dataDir: string","record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">","full: DeviceRecord","deviceId: string","name: string","token: string","mediaType: string","head: Buffer","input: { data: Uint8Array; mediaType: string; name: string }","attachments: AttachmentStoreLike | undefined","fallbackDir: string","out: LanAddress[]","ip: string","code: string","expiresAt: number","config: LensConfig","originOverride?: string","windowMs: number","max: number","key: string","req: IncomingMessage","deps: ServerDeps","currentCameraDeviceId: string | null","injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null }","server: HttpServer","req: IncomingMessage","socket: Duplex","head: Buffer","status: number","message: string","res: ServerResponse","ctx: RouteCtx","body: unknown","extraHeaders?: Record<string, string>","code: string","v: unknown","limit: number","maxBytes: number","chunks: Buffer[]","chunk: Buffer","dir: string","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","deviceId: string","ws: WebSocket","name: string","cam: CamState","msg: CameraControl | null","frame: Buffer","hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void }","msg: CameraControl","view: WebSocket","captureId: string","note?: string","msg: ViewServerMessage","text: string","ctx: any","rawConfig: unknown","level: LogLevel","msg: string","agent: EventedAgent","payload: { agent: EventedAgent }","payload: { agent: EventedAgent; status: string }","handle: LensServerHandle | null","error: unknown"],"sources":["../src/config.ts","../src/inject/host-sink.ts","../src/inject/target.ts","../src/store/pairing.ts","../src/store/devices.ts","../src/types.ts","../src/inject/admit.ts","../src/server/qr.ts","../src/server/static-view.ts","../src/server/auth.ts","../src/server/http.ts","../src/server/hub.ts","../src/paths.ts","../src/index.ts"],"sourcesContent":["import type { LensConfig } from \"./types.js\";\r\n\r\n/** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */\r\nexport function normalizeConfig(raw: unknown): LensConfig {\r\n const r = (raw ?? {}) as Record<string, any>;\r\n const server = (r.server ?? {}) as Record<string, any>;\r\n const limits = (r.limits ?? {}) as Record<string, any>;\r\n const pairing = (r.pairing ?? {}) as Record<string, any>;\r\n const preview = (r.preview ?? {}) as Record<string, any>;\r\n const inject = (r.inject ?? {}) as Record<string, any>;\r\n const target = (r.target ?? {}) as Record<string, any>;\r\n const app = (r.app ?? {}) as Record<string, any>;\r\n // App download sources: Gitee first (CN-friendly), GitHub as the fallback link.\r\n // Points at the Gitee release carrying the newest APK — the App itself has\r\n // not changed since v0.2.0; bump this only when a new APK ships.\r\n const GITEE_APK = \"https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk\";\r\n const GITHUB_APK = \"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\";\r\n const giteeUrl = typeof app.giteeUrl === \"string\" && app.giteeUrl ? app.giteeUrl : GITEE_APK;\r\n const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t: unknown): t is string => typeof t === \"string\") : undefined;\r\n const mode = inject.mode === \"steer\" ? \"steer\" : \"followup\";\r\n return {\r\n server: {\r\n host: typeof server.host === \"string\" && server.host ? server.host : \"0.0.0.0\",\r\n port: Number.isInteger(server.port) && server.port! > 0 && server.port! < 65536 ? server.port! : 8791,\r\n },\r\n limits: {\r\n maxUploadBytes: positiveInt(limits.maxUploadBytes, 10 * 1024 * 1024),\r\n allowedTypes: allowed && allowed.length > 0 ? allowed : [\"image/jpeg\", \"image/png\"],\r\n uploadsPerMinute: positiveInt(limits.uploadsPerMinute, 10),\r\n previewFrameMaxBytes: positiveInt(limits.previewFrameMaxBytes, 512 * 1024),\r\n // keep the local upload archive bounded: oldest files are pruned past this\r\n maxStoredUploads: positiveInt(limits.maxStoredUploads, 200),\r\n },\r\n pairing: { codeTtlMs: positiveInt(pairing.codeTtlMs, 15 * 60 * 1000) },\r\n preview: {\r\n maxWidth: positiveInt(preview.maxWidth, 854),\r\n maxHeight: positiveInt(preview.maxHeight, 480),\r\n fps: clampInt(preview.fps, 1, 30, 10),\r\n jpegQuality: clampInt(preview.jpegQuality, 20, 95, 70),\r\n },\r\n inject: {\r\n mode,\r\n notePrefix: typeof inject.notePrefix === \"string\" ? inject.notePrefix : \"[手机拍照]\",\r\n },\r\n target: {\r\n mode: target.mode === \"pinned\" ? \"pinned\" : \"latest\",\r\n pinnedSessionId: typeof target.pinnedSessionId === \"string\" && target.pinnedSessionId ? target.pinnedSessionId : null,\r\n },\r\n app: {\r\n giteeUrl,\r\n githubUrl: typeof app.githubUrl === \"string\" && app.githubUrl ? app.githubUrl : GITHUB_APK,\r\n // The QR encodes this URL; defaults to the Gitee release asset.\r\n downloadUrl: typeof app.downloadUrl === \"string\" && app.downloadUrl ? app.downloadUrl : giteeUrl,\r\n },\r\n };\r\n}\r\n\r\nfunction positiveInt(value: unknown, fallback: number): number {\r\n return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback;\r\n}\r\n\r\nfunction clampInt(value: unknown, min: number, max: number, fallback: number): number {\r\n if (!Number.isInteger(value)) return fallback;\r\n const n = value as number;\r\n return Math.min(max, Math.max(min, n));\r\n}\r\n","import { createUserMessage } from \"@deepseek-ai/dsh-llm\";\r\nimport type { AdmittedImage, DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport type { DeliverySink } from \"./deliver.js\";\r\n\r\n/** Minimal structural view of a live dsh agent (docs/dsh-caps.md Q1/Q3). */\r\ninterface LiveAgent {\r\n id: string;\r\n session: { id: string };\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n ctx?: { sessions?: unknown };\r\n}\r\n\r\nexport interface EventedAgent {\r\n send(message: unknown, target: \"next-turn\" | \"next-step\", wakeup: boolean): void;\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n session: { id: string };\r\n id?: string;\r\n}\r\n\r\n/**\r\n * Phase 2 delivery seam: turns a stored image into a durable dsh user message\r\n * attached to the current/last-active session, so the model sees it on the\r\n * next turn.\r\n *\r\n * The active session is tracked from live agent events (inbox inserts and\r\n * `running` status flips). With no live session the image stays stored but\r\n * no delivery occurs — a real state, not an error.\r\n */\r\nexport class HostDeliverySink implements DeliverySink {\r\n private lastActiveId: string | null = null;\r\n private agents = new Map<string, EventedAgent>();\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n // subscribe via injected listeners; the caller wires these to the context\r\n // because the sink must stay cordis-agnostic for standalone tests.\r\n }\r\n\r\n /** Track an agent that appeared / got input. Call from ctx event handlers. */\r\n track(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.set(id, agent);\r\n this.lastActiveId = id;\r\n this.log(\"info\", `tracking agent ${id} (active target)`);\r\n }\r\n\r\n /** Forget a disposed agent. */\r\n untrack(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.delete(id);\r\n if (this.lastActiveId === id) this.lastActiveId = this.agents.keys().next().value ?? null;\r\n }\r\n\r\n /** The session an upload targets right now, if any. */\r\n private resolve(): EventedAgent | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n const pinned = [...this.agents.values()].find((a) => String(a.session.id) === this.config.target.pinnedSessionId);\r\n if (pinned) return pinned;\r\n this.log(\"warn\", `pinned session ${this.config.target.pinnedSessionId} has no live agent`);\r\n }\r\n return (this.lastActiveId ? this.agents.get(this.lastActiveId) : null) ?? null;\r\n }\r\n\r\n async deliver(admitted: AdmittedImage, note: string | undefined, mode: \"followup\" | \"steer\"): Promise<DeliveryReceipt> {\r\n const agent = this.resolve();\r\n if (!agent) {\r\n return { ok: false, sessionId: null, mode: \"none\", reason: \"no active session\" };\r\n }\r\n try {\r\n const content: unknown[] = [\r\n { type: \"text\", text: note ?? this.config.inject.notePrefix },\r\n { type: \"image\", attachment: admitted.ref },\r\n ];\r\n const message = createUserMessage({\r\n content,\r\n source: { kind: \"plugin\", plugin: \"phone-lens\" },\r\n });\r\n this.log(\"info\", `delivering image (${admitted.ref.attachmentId}) to session ${agent.session.id} via ${mode}`);\r\n if (mode === \"steer\") agent.steer(message);\r\n else agent.followup(message);\r\n return { ok: true, sessionId: String(agent.session.id), mode };\r\n } catch (e) {\r\n this.log(\"warn\", `delivery failed: ${String(e)}`);\r\n return { ok: false, sessionId: null, mode: \"none\", reason: String(e) };\r\n }\r\n }\r\n}\r\n","import type { LensConfig } from \"../types.js\";\r\n\r\nexport interface SessionTarget {\r\n sessionId: string;\r\n title: string;\r\n active: boolean;\r\n running: boolean;\r\n}\r\n\r\n/**\r\n * Resolves the session an upload should be injected into.\r\n *\r\n * Phase 1: pure config view, no live agents (the agent-event wiring that\r\n * feeds `latest` lands in Phase 2 — see architecture.md §2 D4).\r\n */\r\nexport class TargetTracker {\r\n constructor(private readonly config: LensConfig) {}\r\n\r\n /** The session an incoming image would target right now, if any. */\r\n resolve(): SessionTarget | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n return { sessionId: this.config.target.pinnedSessionId, title: \"(pinned)\", active: true, running: false };\r\n }\r\n return null;\r\n }\r\n\r\n /** Choosable targets for GET /targets. */\r\n list(): SessionTarget[] {\r\n const pinned = this.resolve();\r\n return pinned ? [pinned] : [];\r\n }\r\n\r\n mode(): string {\r\n return this.config.target.mode;\r\n }\r\n}\r\n","import { createHash, randomBytes, randomInt, timingSafeEqual } from \"node:crypto\";\r\n\r\n/** One-shot pairing-code lifecycle: create / verify / burn, with TTL. */\r\nexport class PairingStore {\r\n private code: string | null = null;\r\n private createdAt = 0;\r\n private expiresAt = 0;\r\n private used = false;\r\n\r\n constructor(private readonly ttlMs: number) {}\r\n\r\n /** Current live code, or a freshly minted one. */\r\n current(): { code: string; expiresAt: number } {\r\n const now = Date.now();\r\n if (this.code === null || this.used || now >= this.expiresAt) this.mint(now);\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /** Force a fresh code (manual refresh). Invalidates the previous one. */\r\n refresh(): { code: string; expiresAt: number } {\r\n this.mint(Date.now());\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /**\r\n * Verify a submitted code and burn it on success.\r\n * Constant-time compare; expired codes read as invalid.\r\n */\r\n verify(submitted: string): { ok: true } | { ok: false; reason: \"invalid\" | \"expired\" } {\r\n if (this.code === null || this.used) return { ok: false, reason: \"invalid\" };\r\n const now = Date.now();\r\n if (now >= this.expiresAt) return { ok: false, reason: \"expired\" };\r\n const a = Buffer.from(submitted);\r\n const b = Buffer.from(this.code);\r\n const same = a.length === b.length && timingSafeEqual(a, b);\r\n if (!same) return { ok: false, reason: \"invalid\" };\r\n this.used = true;\r\n return { ok: true };\r\n }\r\n\r\n private mint(now: number) {\r\n // 8 decimal digits, zero-padded (human-typable fallback).\r\n this.code = String(randomInt(0, 100_000_000)).padStart(8, \"0\");\r\n this.createdAt = now;\r\n this.expiresAt = now + this.ttlMs;\r\n this.used = false;\r\n }\r\n}\r\n\r\n/** Mint a per-device token. Only its SHA-256 may be persisted. */\r\nexport function mintDeviceToken(): string {\r\n return randomBytes(32).toString(\"hex\");\r\n}\r\n\r\nexport function hashToken(token: string): string {\r\n return createHash(\"sha256\").update(token).digest(\"hex\");\r\n}\r\n\r\n/** Constant-time token-hash comparison. */\r\nexport function tokenHashMatches(recordHash: string, presentedToken: string): boolean {\r\n const a = Buffer.from(recordHash, \"hex\");\r\n const b = Buffer.from(hashToken(presentedToken), \"hex\");\r\n return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { DeviceRecord } from \"../types.js\";\r\nimport { tokenHashMatches } from \"./pairing.js\";\r\n\r\n/**\r\n * Paired-device registry, persisted as JSON under the plugin data dir.\r\n * Records carry token HASHES only; the raw token exists solely in the\r\n * pairing response and the phone's secure storage.\r\n */\r\nexport class DeviceStore {\r\n private devices = new Map<string, DeviceRecord>();\r\n private readonly file: string;\r\n private dirty = false;\r\n\r\n constructor(dataDir: string) {\r\n mkdirSync(dataDir, { recursive: true });\r\n this.file = join(dataDir, \"devices.json\");\r\n this.load();\r\n }\r\n\r\n private load() {\r\n if (!existsSync(this.file)) return;\r\n try {\r\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as { devices?: DeviceRecord[] };\r\n for (const d of raw.devices ?? []) {\r\n if (typeof d?.deviceId === \"string\" && typeof d?.tokenHash === \"string\") {\r\n this.devices.set(d.deviceId, { ...d, name: d.name ?? \"unknown\", model: d.model ?? \"\", firstPairedAt: d.firstPairedAt ?? Date.now(), lastSeenAt: d.lastSeenAt ?? 0 });\r\n }\r\n }\r\n } catch {\r\n // Corrupt registry: start empty rather than refuse to boot.\r\n }\r\n }\r\n\r\n private persist() {\r\n const tmp = `${this.file}.tmp`;\r\n writeFileSync(tmp, JSON.stringify({ version: 1, devices: [...this.devices.values()] }, null, 2), \"utf8\");\r\n renameSync(tmp, this.file);\r\n }\r\n\r\n /** Register/re-register a device, uniquifying the name against others. */\r\n upsert(record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">): DeviceRecord {\r\n const prior = this.devices.get(record.deviceId);\r\n const full: DeviceRecord = {\r\n ...record,\r\n name: this.uniquifyName(record.deviceId, record.name),\r\n firstPairedAt: prior?.firstPairedAt ?? Date.now(),\r\n lastSeenAt: Date.now(),\r\n };\r\n this.devices.set(record.deviceId, full);\r\n this.dirty = true;\r\n this.persist();\r\n return full;\r\n }\r\n\r\n /** Make a display name unique among other devices (x, x (2), x (3), …). */\r\n private uniquifyName(deviceId: string, name: string): string {\r\n const others = [...this.devices.values()].map((d) => d.name).filter((n, i, arr) => arr.indexOf(n) === i);\r\n const base = name.trim() || \"Android 设备\";\r\n if (!others.includes(base)) return base;\r\n for (let n = 2; ; n++) {\r\n const candidate = `${base} (${n})`;\r\n if (!others.includes(candidate)) return candidate;\r\n }\r\n }\r\n\r\n /** Rename a device (user-editable in the web UI); returns the updated record. */\r\n rename(deviceId: string, name: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n record.name = name.trim() || record.name;\r\n this.dirty = true;\r\n this.persist();\r\n return record;\r\n }\r\n\r\n /** Authenticate deviceId + token; refreshes lastSeenAt on success. */\r\n authenticate(deviceId: string, token: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n if (!tokenHashMatches(record.tokenHash, token)) return null;\r\n record.lastSeenAt = Date.now();\r\n this.dirty = true;\r\n return record;\r\n }\r\n\r\n remove(deviceId: string): boolean {\r\n const had = this.devices.delete(deviceId);\r\n if (had) this.persist();\r\n return had;\r\n }\r\n\r\n list(): DeviceRecord[] {\r\n return [...this.devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);\r\n }\r\n\r\n count(): number {\r\n return this.devices.size;\r\n }\r\n}\r\n","/** Shared vocabulary for the phone-lens host half. */\r\n\r\n/** Normalized plugin config (see cordis.patch.yml / config.ts defaults). */\r\nexport interface LensConfig {\r\n server: { host: string; port: number };\r\n limits: {\r\n maxUploadBytes: number;\r\n allowedTypes: string[];\r\n uploadsPerMinute: number;\r\n previewFrameMaxBytes: number;\r\n maxStoredUploads: number;\r\n };\r\n pairing: { codeTtlMs: number };\r\n preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number };\r\n inject: { mode: \"followup\" | \"steer\"; notePrefix: string };\r\n target: { mode: \"latest\" | \"pinned\"; pinnedSessionId: string | null };\r\n /** Where the phone App APK can be fetched from (QR + fallback links). */\r\n app: { downloadUrl: string; giteeUrl: string; githubUrl: string };\r\n}\r\n\r\n/** Minimal structural view of a dsh ImageAttachmentRef (docs/dsh-caps.md Q1). */\r\nexport interface ImageAttachmentRef {\r\n attachmentId: string;\r\n mediaType: string;\r\n bytes: number;\r\n width: number;\r\n height: number;\r\n name?: string;\r\n originalDimensions?: { width: number; height: number };\r\n}\r\n\r\n/** One durable image ready for session injection. */\r\nexport interface AdmittedImage {\r\n ref: ImageAttachmentRef;\r\n /** Where the bytes physically landed: \"attachments\" (dsh store) or \"file\" (fallback dir). */\r\n storage: \"attachments\" | \"file\";\r\n /** Fallback file path when storage === \"file\". */\r\n filePath?: string;\r\n}\r\n\r\n/** Result of the deliver step. */\r\nexport interface DeliveryReceipt {\r\n ok: boolean;\r\n sessionId: string | null;\r\n mode: \"followup\" | \"steer\" | \"none\";\r\n reason?: string;\r\n}\r\n\r\n/** Persistent paired-device record. The raw token never touches disk — only its SHA-256. */\r\nexport interface DeviceRecord {\r\n deviceId: string;\r\n name: string;\r\n model: string;\r\n tokenHash: string;\r\n firstPairedAt: number;\r\n lastSeenAt: number;\r\n}\r\n\r\n/** Live pairing-code state. */\r\nexport interface PairingState {\r\n code: string;\r\n createdAt: number;\r\n expiresAt: number;\r\n used: boolean;\r\n}\r\n\r\n/** JSON message on the camera websocket (text frames). */\r\nexport type CameraControl =\r\n | { type: \"hello\"; width: number; height: number; fps: number; rotation?: number }\r\n | { type: \"bye\" }\r\n | { type: \"set_preview\"; maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }\r\n | { type: \"capture\"; captureId: string; note?: string }\r\n | { type: \"capture_result\"; captureId: string; status: \"taken\" | \"declined\" | \"failed\"; detail?: string }\r\n // phone→host: make THIS phone the active preview/shutter device\r\n | { type: \"claim_active\" }\r\n // host→phone: another device owns the PC preview; stop/start streaming\r\n | { type: \"pause_preview\" }\r\n | { type: \"resume_preview\" };\r\n\r\n/** JSON message on the view websocket. */\r\nexport type ViewServerMessage =\r\n | { type: \"meta\"; camera: { connected: boolean; width?: number; height?: number; fps?: number; rotation?: number; name?: string }; preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }; paired: boolean }\r\n | { type: \"frame_meta\"; width: number; height: number; rotation?: number }\r\n | { type: \"devices\"; devices: { id: string; name: string; active: boolean }[] }\r\n | { type: \"device\"; online: boolean; name?: string }\r\n | { type: \"capture_pending\"; captureId: string; note?: string }\r\n | { type: \"pending_image\"; attachmentId: string; name?: string }\r\n | { type: \"injected\"; captureId?: string; attachmentId: string; sessionId: string | null; ok: boolean; reason?: string; name?: string }\r\n | { type: \"upload\"; attachmentId: string; storage: string; name?: string }\r\n | { type: \"error\"; code: string; message?: string };\r\n\r\nexport type ViewClientMessage =\r\n | { type: \"capture\"; note?: string }\r\n | { type: \"select_device\"; deviceId: string }\r\n | { type: \"rename_device\"; deviceId: string; name: string }\r\n | { type: \"refresh_pairing\" }\r\n | { type: \"ping\" };\r\n\r\n/** Wire error body. */\r\nexport interface ErrorBody {\r\n error: { code: string; message: string };\r\n}\r\n\r\nexport const ERROR_CODES = {\r\n AUTH_REQUIRED: \"AUTH_REQUIRED\",\r\n PAIR_CODE_INVALID: \"PAIR_CODE_INVALID\",\r\n PAIR_CODE_EXPIRED: \"PAIR_CODE_EXPIRED\",\r\n RATE_LIMITED: \"RATE_LIMITED\",\r\n TYPE_NOT_ALLOWED: \"TYPE_NOT_ALLOWED\",\r\n TOO_LARGE: \"TOO_LARGE\",\r\n BAD_MAGIC: \"BAD_MAGIC\",\r\n STORE_FAILED: \"STORE_FAILED\",\r\n NO_TARGET: \"NO_TARGET\",\r\n NO_CAMERA: \"NO_CAMERA\",\r\n CAPTURE_TIMEOUT: \"CAPTURE_TIMEOUT\",\r\n LOOPBACK_ONLY: \"LOOPBACK_ONLY\",\r\n BAD_REQUEST: \"BAD_REQUEST\",\r\n INTERNAL: \"INTERNAL\",\r\n} as const;\r\n","import { createHash } from \"node:crypto\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { AdmittedImage } from \"../types.js\";\r\n\r\n/** Structural type of the dsh attachment store we consume (docs/dsh-caps.md Q1). */\r\nexport interface AttachmentStoreLike {\r\n saveImages(inputs: ReadonlyArray<{ data: Uint8Array; mediaType: string; name?: string }>): Promise<readonly AdmittedImage[\"ref\"][]>;\r\n}\r\n\r\n/** Magic-byte whitelist check — never trust Content-Type alone. */\r\nexport function magicMatches(mediaType: string, head: Buffer): boolean {\r\n if (mediaType === \"image/jpeg\") return head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;\r\n if (mediaType === \"image/png\") return head.length >= 4 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;\r\n return false;\r\n}\r\n\r\n/**\r\n * Persist one image through the durable attachment seam.\r\n * Falls back to a content-addressed file when the dsh attachments service is\r\n * not mounted (standalone dev / unusual profile), so uploads never vanish.\r\n */\r\nexport async function admitImage(\r\n input: { data: Uint8Array; mediaType: string; name: string },\r\n attachments: AttachmentStoreLike | undefined,\r\n fallbackDir: string,\r\n): Promise<AdmittedImage> {\r\n if (attachments) {\r\n const refs = await attachments.saveImages([{ data: input.data, mediaType: input.mediaType, name: input.name }]);\r\n const ref = refs[0];\r\n if (!ref) throw new Error(\"attachment store returned no reference\");\r\n return { ref, storage: \"attachments\" };\r\n }\r\n const ext = input.mediaType === \"image/png\" ? \"png\" : \"jpg\";\r\n const digest = createHash(\"sha1\").update(input.data).digest(\"hex\").slice(0, 16);\r\n mkdirSync(fallbackDir, { recursive: true });\r\n const filePath = join(fallbackDir, `${digest}-${input.name.replace(/[^\\w.-]+/g, \"_\") || \"image\"}.${ext}`);\r\n writeFileSync(filePath, input.data);\r\n return {\r\n ref: {\r\n attachmentId: `file:${digest}`,\r\n mediaType: input.mediaType,\r\n bytes: input.data.byteLength,\r\n width: 0,\r\n height: 0,\r\n name: input.name,\r\n },\r\n storage: \"file\",\r\n filePath,\r\n };\r\n}\r\n","import * as os from \"node:os\";\r\nimport QRCode from \"qrcode\";\r\nimport type { LensConfig } from \"../types.js\";\r\n\r\nexport interface LanAddress {\r\n ip: string;\r\n iface: string;\r\n}\r\n\r\n/** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */\r\nexport function lanAddresses(): LanAddress[] {\r\n const out: LanAddress[] = [];\r\n for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {\r\n for (const addr of addrs ?? []) {\r\n if (addr.family !== \"IPv4\" || addr.internal) continue;\r\n out.push({ ip: addr.address, iface });\r\n }\r\n }\r\n const rank = (ip: string): number => {\r\n if (ip.startsWith(\"192.168.\")) return 0; // typical Wi-Fi + Android USB tethering\r\n if (ip.startsWith(\"172.\")) return 1; // container/iPhone-USB ranges\r\n if (ip.startsWith(\"10.\")) return 2;\r\n return 3;\r\n };\r\n return out.sort((a, b) => rank(a.ip) - rank(b.ip) || a.ip.localeCompare(b.ip));\r\n}\r\n\r\nexport interface PairingQr {\r\n code: string;\r\n expiresAt: number;\r\n payload: string;\r\n urls: string[];\r\n pngDataUrl: string;\r\n ascii: string;\r\n}\r\n\r\n/** Build the full pairing payload + rendered QR (data URL for browsers, ASCII for terminals). */\r\nexport async function buildPairingQr(code: string, expiresAt: number, config: LensConfig, originOverride?: string): Promise<PairingQr> {\r\n const addrs = lanAddresses();\r\n const host = originOverride ?? addrs[0]?.ip ?? \"127.0.0.1\";\r\n const port = config.server.port;\r\n const payload = `lensmate://pair?v=1&host=${encodeURIComponent(host)}&port=${port}&code=${code}`;\r\n const urls = (addrs.length > 0 ? addrs : [{ ip: \"127.0.0.1\", iface: \"loopback\" }]).map((a) => `http://${a.ip}:${port}`);\r\n const [pngDataUrl, ascii] = await Promise.all([\r\n QRCode.toDataURL(payload, { errorCorrectionLevel: \"M\", margin: 2, width: 320 }),\r\n QRCode.toString(payload, { type: \"terminal\", small: true }),\r\n ]);\r\n return { code, expiresAt, payload, urls, pngDataUrl, ascii };\r\n}\r\n","/**\r\n * The standalone fallback viewfinder page, served at /view.html (loopback only).\r\n * Same protocol as the future Web UI overlay: WS /ws/view frames + capture control.\r\n * Picture-in-Picture turns it into a true always-on-top mini window.\r\n */\r\nexport const VIEW_HTML = `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n<title>PhoneLens 直连取景</title>\r\n<style>\r\n :root { color-scheme: dark; }\r\n * { box-sizing: border-box; }\r\n body { margin: 0; font: 13px/1.5 system-ui, \"Segoe UI\", \"Microsoft YaHei\", sans-serif;\r\n background: #101418; color: #dfe7ee; display: flex; flex-direction: column; height: 100vh; }\r\n header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #171d24; border-bottom: 1px solid #232c36; }\r\n header h1 { font-size: 13px; margin: 0; font-weight: 600; }\r\n #dot { width: 9px; height: 9px; border-radius: 50%; background: #e5484d; transition: background .2s; }\r\n #dot.on { background: #46a758; }\r\n #fps { margin-left: auto; color: #7d8da0; font-variant-numeric: tabular-nums; }\r\n main { flex: 1; display: flex; min-height: 0; }\r\n #stage { flex: 1; display: flex; align-items: center; justify-content: center; background: #0b0e11; position: relative; min-width: 0; }\r\n canvas { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }\r\n #hint { position: absolute; color: #7d8da0; }\r\n aside { width: 240px; border-left: 1px solid #232c36; padding: 12px; overflow: auto; }\r\n aside h2 { font-size: 12px; margin: 0 0 8px; color: #9fb0c3; text-transform: uppercase; letter-spacing: .08em; }\r\n #qrimg { width: 100%; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }\r\n .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }\r\n .row code { background: #1d242d; padding: 2px 8px; border-radius: 6px; letter-spacing: .12em; font-size: 15px; }\r\n .row a { color: #3b7cb5; text-decoration: none; }\r\n .row a:hover { text-decoration: underline; }\r\n button { background: #1f2937; border: 1px solid #2f3b48; color: #dfe7ee; padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }\r\n button:hover { background: #28323e; }\r\n button:disabled { opacity: .45; cursor: default; }\r\n #shoot { padding: 10px; font-size: 14px; background: #2a5d8f; border-color: #3b7cb5; }\r\n #pip { display: none; }\r\n #log { margin-top: 10px; font-size: 12px; color: #8fa1b5; max-height: 160px; overflow: auto; }\r\n #log div { padding: 2px 0; border-bottom: 1px dashed #202832; }\r\n #log .ok { color: #58b368; } #log .err { color: #e5696e; }\r\n</style>\r\n</head>\r\n<body>\r\n<header>\r\n <div id=\"dot\"></div><h1>PhoneLens 直连取景</h1>\r\n <span id=\"dev\"></span>\r\n <span id=\"fps\"></span><span id=\"age\"></span>\r\n</header>\r\n<main>\r\n <div id=\"stage\"><canvas width=\"854\" height=\"480\"></canvas><div id=\"hint\">等待手机连接…</div></div>\r\n <aside>\r\n <h2>配对</h2>\r\n <img id=\"qrimg\" alt=\"配对二维码\">\r\n <div class=\"row\"><code id=\"paircode\"></code><button id=\"refresh\">刷新</button></div>\r\n <div class=\"row\" id=\"urls\"></div>\r\n <div class=\"row\"><a href=\"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\" target=\"_blank\" rel=\"noopener\">📱 手机还没装 App?点此下载(Android APK)</a></div>\r\n <h2 style=\"margin-top:14px\">快门</h2>\r\n <div class=\"row\">\r\n <button id=\"shoot\" disabled>◉ 拍照并注入</button>\r\n <button id=\"pip\">置顶小窗</button>\r\n </div>\r\n <div id=\"log\"></div>\r\n </aside>\r\n</main>\r\n<video id=\"pipvid\" muted playsinline style=\"display:none\"></video>\r\n<script>\r\n'use strict';\r\nconst $ = (id) => document.getElementById(id);\r\nconst canvas = $('stage').querySelector('canvas');\r\nconst ctx2d = canvas.getContext('2d');\r\nconst logEl = $('log');\r\nlet lastFrameAt = 0, frames = 0, fpsTimer = 0, pendingShot = null;\r\nlet streamRotation = 0;\r\n\r\nfunction log(msg, cls) {\r\n const d = document.createElement('div');\r\n if (cls) d.className = cls;\r\n d.textContent = new Date().toLocaleTimeString() + ' ' + msg;\r\n logEl.prepend(d);\r\n while (logEl.childElementCount > 50) logEl.lastChild.remove();\r\n}\r\n\r\nasync function refreshQr() {\r\n try {\r\n const r = await fetch('/qr.json');\r\n const j = await r.json();\r\n $('qrimg').src = j.pngDataUrl;\r\n $('paircode').textContent = j.code;\r\n $('urls').innerHTML = (j.urls || []).map(u => '<span style=\"color:#7d8da0\">' + u + '</span>').join('<br>');\r\n } catch (e) { log('二维码加载失败: ' + e.message, 'err'); }\r\n}\r\n$('refresh').onclick = () => { wsSend({ type: 'refresh_pairing' }); setTimeout(refreshQr, 150); };\r\n\r\nfunction drawFrame(buf) {\r\n const blob = new Blob([buf], { type: 'image/jpeg' });\r\n const url = URL.createObjectURL(blob);\r\n const img = new Image();\r\n img.onload = () => {\r\n const r = ((streamRotation % 360) + 360) % 360;\r\n const swap = r === 90 || r === 270;\r\n const w = swap ? img.height : img.width;\r\n const h = swap ? img.width : img.height;\r\n if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\r\n ctx2d.save();\r\n ctx2d.translate(w / 2, h / 2);\r\n ctx2d.rotate(r * Math.PI / 180);\r\n ctx2d.drawImage(img, -img.width / 2, -img.height / 2);\r\n ctx2d.restore();\r\n URL.revokeObjectURL(url);\r\n $('hint').style.display = 'none';\r\n frames++;\r\n const now = performance.now();\r\n if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }\r\n lastFrameAt = now;\r\n };\r\n img.src = url;\r\n}\r\n\r\nlet ws = null;\r\nfunction connect() {\r\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\r\n ws = new WebSocket(proto + '://' + location.host + '/ws/view');\r\n ws.binaryType = 'arraybuffer';\r\n ws.onopen = () => { log('已连接'); refreshQr(); };\r\n ws.onclose = () => { $('dot').className = ''; setTimeout(connect, 1500); };\r\n ws.onerror = () => {};\r\n ws.onmessage = (ev) => {\r\n if (ev.data instanceof ArrayBuffer) { drawFrame(ev.data); return; }\r\n const m = JSON.parse(ev.data);\r\n switch (m.type) {\r\n case 'meta':\r\n $('dot').className = m.camera.connected ? 'on' : '';\r\n $('dev').textContent = m.camera.name || (m.camera.connected ? '已连接' : '未连接');\r\n $('shoot').disabled = !m.camera.connected;\r\n if (m.camera.rotation !== undefined) streamRotation = m.camera.rotation;\r\n $('hint').style.display = m.camera.connected ? 'none' : '';\r\n break;\r\n case 'frame_meta':\r\n if (m.rotation !== undefined) streamRotation = m.rotation;\r\n break;\r\n case 'device':\r\n $('dot').className = m.online ? 'on' : '';\r\n $('dev').textContent = m.online ? (m.name || '已连接') : '未连接';\r\n $('shoot').disabled = !m.online;\r\n $('hint').style.display = m.online ? 'none' : '';\r\n $('hint').textContent = '等待手机连接…';\r\n break;\r\n case 'capture_pending':\r\n pendingShot = m.captureId;\r\n log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');\r\n break;\r\n case 'injected':\r\n pendingShot = null;\r\n if (m.ok) log('已注入会话 ' + (m.sessionId || '').slice(0, 8) + (m.name ? ' · ' + m.name : ''), 'ok');\r\n else log('入库成功,注入未完成: ' + (m.reason || ''), 'err');\r\n break;\r\n case 'upload':\r\n log('图片已入库 ' + (m.name || ''), 'ok');\r\n break;\r\n case 'error':\r\n log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');\r\n break;\r\n }\r\n };\r\n}\r\nfunction wsSend(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }\r\n\r\n$('shoot').onclick = () => wsSend({ type: 'capture', note: '' });\r\n\r\n// PiP: mirror the canvas into a video stream, then request Picture-in-Picture.\r\n$('pip').onclick = async () => {\r\n try {\r\n const stream = canvas.captureStream(10);\r\n const v = $('pipvid');\r\n v.srcObject = stream;\r\n await v.play();\r\n await v.requestPictureInPicture();\r\n } catch (e) { log('画中画失败: ' + e.message, 'err'); }\r\n};\r\n\r\nsetInterval(() => {\r\n if (document.pictureInPictureElement) return;\r\n if (performance.now() - lastFrameAt > 4000 && lastFrameAt > 0) $('hint').style.display = '';\r\n $('age').textContent = lastFrameAt > 0 ? ' · ' + ((performance.now() - lastFrameAt) / 1000).toFixed(1) + 's前' : '';\r\n}, 1000);\r\nif (document.pictureInPictureEnabled) $('pip').style.display = '';\r\n\r\nconnect();\r\n</script>\r\n</body>\r\n</html>\r\n`;\r\n","import type { IncomingMessage } from \"node:http\";\r\n\r\n/** Sliding-window rate limiter (in-memory; resets with the process). */\r\nexport class RateLimiter {\r\n private hits = new Map<string, number[]>();\r\n\r\n constructor(private readonly windowMs: number, private readonly max: number) {}\r\n\r\n /** Record one hit and report whether the key is still within budget. */\r\n allow(key: string): boolean {\r\n const now = Date.now();\r\n const cutoff = now - this.windowMs;\r\n const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);\r\n if (list.length >= this.max) {\r\n this.hits.set(key, list);\r\n return false;\r\n }\r\n list.push(now);\r\n this.hits.set(key, list);\r\n return true;\r\n }\r\n}\r\n\r\nconst LOOPBACK = new Set([\"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\"]);\r\n\r\n/** Whether the connection originates from this machine (loopback). */\r\nexport function isLoopback(req: IncomingMessage): boolean {\r\n const addr = req.socket.remoteAddress ?? \"\";\r\n return LOOPBACK.has(addr);\r\n}\r\n\r\n/**\r\n * CORS for the dsh Web UI page (served on another loopback port) so it can\r\n * fetch /qr.json and /status from this receiver. Only same-machine origins\r\n * are echoed — a foreign page must not read the pairing code, and its\r\n * cross-origin JSON POST fails the preflight we never answer.\r\n */\r\nexport function corsFor(req: IncomingMessage): Record<string, string> {\r\n const origin = req.headers.origin;\r\n if (typeof origin !== \"string\" || origin === \"\") return {};\r\n try {\r\n const u = new URL(origin);\r\n const host = u.hostname.replace(/^\\[|\\]$/g, \"\");\r\n if (u.protocol === \"http:\" && LOOPBACK.has(host)) {\r\n return {\r\n \"access-control-allow-origin\": origin,\r\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\r\n \"access-control-allow-headers\": \"content-type, x-lm-device, x-lm-token\",\r\n \"access-control-max-age\": \"600\",\r\n };\r\n }\r\n } catch {\r\n /* malformed origin: no CORS headers */\r\n }\r\n return {};\r\n}\r\n\r\n/** Extract a stable client key for rate limiting. */\r\nexport function clientKey(req: IncomingMessage): string {\r\n return req.socket.remoteAddress ?? \"unknown\";\r\n}\r\n\r\n/** Read the device auth headers, if present. */\r\nexport function deviceAuth(req: IncomingMessage): { deviceId: string; token: string } | null {\r\n const deviceId = req.headers[\"x-lm-device\"];\r\n const token = req.headers[\"x-lm-token\"];\r\n if (typeof deviceId !== \"string\" || typeof token !== \"string\" || !deviceId || !token) return null;\r\n return { deviceId, token };\r\n}\r\n\r\n/** Query-string parser (URLSearchParams handles plus-encoding for notes). */\r\nexport function queryOf(req: IncomingMessage): URLSearchParams {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n return url.searchParams;\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { IncomingMessage, Server as HttpServer, ServerResponse } from \"node:http\";\r\nimport { createServer } from \"node:http\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { readFile, readdir, stat, unlink } from \"node:fs/promises\";\r\nimport { join } from \"node:path\";\r\nimport type { Duplex } from \"node:stream\";\r\nimport { WebSocketServer, type WebSocket } from \"ws\";\r\nimport QRCode from \"qrcode\";\r\nimport type { DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport { ERROR_CODES } from \"../types.js\";\r\nimport type { DeliverySink } from \"../inject/deliver.js\";\r\nimport { admitImage, magicMatches, type AttachmentStoreLike } from \"../inject/admit.js\";\r\nimport type { TargetTracker } from \"../inject/target.js\";\r\nimport type { DeviceStore } from \"../store/devices.js\";\r\nimport { hashToken, mintDeviceToken, type PairingStore } from \"../store/pairing.js\";\r\nimport type { ViewHub } from \"./hub.js\";\r\nimport { buildPairingQr } from \"./qr.js\";\r\nimport { VIEW_HTML } from \"./static-view.js\";\r\nimport { clientKey, corsFor, deviceAuth, isLoopback, queryOf, RateLimiter } from \"./auth.js\";\r\n\r\nexport interface ServerDeps {\r\n config: LensConfig;\r\n pairing: PairingStore;\r\n devices: DeviceStore;\r\n hub: ViewHub;\r\n targets: TargetTracker;\r\n sink: DeliverySink;\r\n /** Read the attachment store lazily — the service may mount after us. */\r\n attachments: () => AttachmentStoreLike | undefined;\r\n /** Fallback dir for uploads when the attachment service is absent. */\r\n fallbackDir: string;\r\n /** Browser-fetchable staging dir for images awaiting user send (composer pre-send). */\r\n pendingDir: string;\r\n log: (level: \"info\" | \"warn\" | \"error\", msg: string) => void;\r\n}\r\n\r\nexport interface LensServerHandle {\r\n port: number;\r\n dispose: () => Promise<void>;\r\n}\r\n\r\n/** Boot the receiver: HTTP routes + two websocket endpoints. */\r\nexport async function startLensServer(deps: ServerDeps): Promise<LensServerHandle> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const pairLimiter = new RateLimiter(60_000, 10);\r\n const uploadLimiter = new RateLimiter(60_000, config.limits.uploadsPerMinute);\r\n let currentCameraDeviceId: string | null = null;\r\n const injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null } = { last: null };\r\n\r\n const server: HttpServer = createServer((req, res) => {\r\n handle(deps, req, res, {\r\n pairLimiter,\r\n uploadLimiter,\r\n getCurrentCamera: () => currentCameraDeviceId,\r\n noteInjection: (receipt, attachmentId) => {\r\n injectionBox.last = { at: Date.now(), sessionId: receipt.sessionId, attachmentId, ok: receipt.ok };\r\n },\r\n getLastInjection: () => injectionBox.last,\r\n }).catch((error) => {\r\n log(\"error\", `request failed: ${String(error)}`);\r\n if (!res.headersSent) sendJson(res, 500, { error: { code: ERROR_CODES.INTERNAL, message: String(error) } });\r\n else res.destroy();\r\n });\r\n });\r\n\r\n const wss = new WebSocketServer({ noServer: true, maxPayload: config.limits.previewFrameMaxBytes * 4 });\r\n server.on(\"upgrade\", (req: IncomingMessage, socket: Duplex, head: Buffer) => {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const done = (status: number, message: string) => {\r\n socket.write(`HTTP/1.1 ${status} ${message}\\r\\nConnection: close\\r\\n\\r\\n`);\r\n socket.destroy();\r\n };\r\n if (url.pathname === \"/ws/camera\") {\r\n const q = url.searchParams;\r\n const deviceId = q.get(\"deviceId\") ?? \"\";\r\n const token = q.get(\"token\") ?? \"\";\r\n const record = devices.authenticate(deviceId, token);\r\n if (!record) return done(401, \"Unauthorized\");\r\n wss.handleUpgrade(req, socket, head, (ws) => {\r\n currentCameraDeviceId = record.deviceId;\r\n hub.attachCamera(record.deviceId, ws, record.name);\r\n });\r\n return;\r\n }\r\n if (url.pathname === \"/ws/view\") {\r\n if (!isLoopback(req)) return done(403, \"Forbidden\");\r\n wss.handleUpgrade(req, socket, head, (ws) => hub.attachView(ws as WebSocket, {\r\n onRefreshPairing: () => pairing.refresh(),\r\n onRenameDevice: (deviceId, name) => {\r\n const updated = devices.rename(deviceId, name);\r\n if (updated) hub.renameDevice(deviceId, updated.name);\r\n },\r\n }));\r\n return;\r\n }\r\n done(404, \"Not Found\");\r\n });\r\n\r\n const heartbeat = setInterval(() => hub.pingAll(), 20_000);\r\n\r\n await new Promise<void>((resolve, reject) => {\r\n server.once(\"error\", reject);\r\n server.listen(config.server.port, config.server.host, () => resolve());\r\n });\r\n log(\"info\", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);\r\n\r\n return {\r\n port: config.server.port,\r\n dispose: async () => {\r\n clearInterval(heartbeat);\r\n hub.detachAll();\r\n for (const client of wss.clients) client.terminate();\r\n await new Promise<void>((resolve) => server.close(() => resolve()));\r\n },\r\n };\r\n}\r\n\r\ntype RouteCtx = {\r\n pairLimiter: RateLimiter;\r\n uploadLimiter: RateLimiter;\r\n getCurrentCamera: () => string | null;\r\n noteInjection: (receipt: DeliveryReceipt, attachmentId: string) => void;\r\n getLastInjection: () => { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null;\r\n};\r\n\r\nasync function handle(deps: ServerDeps, req: IncomingMessage, res: ServerResponse, ctx: RouteCtx): Promise<void> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const path = url.pathname.replace(/\\/+$/, \"\") || \"/\";\r\n const method = req.method ?? \"GET\";\r\n const loop = isLoopback(req);\r\n const cors = corsFor(req);\r\n\r\n // Preflight from the Web UI page (another loopback origin).\r\n if (method === \"OPTIONS\") {\r\n res.writeHead(204, cors);\r\n res.end();\r\n return;\r\n }\r\n\r\n // ── open endpoints ────────────────────────────────────────────────────────\r\n if (method === \"GET\" && path === \"/info\") {\r\n return sendJson(res, 200, { name: \"PhoneLens 直连取景\", version: \"0.3.5\", requiresPairing: true }, cors);\r\n }\r\n\r\n // ── loopback-only endpoints (preview page, QR, view stream) ──────────────\r\n if (!loop && (path === \"/\" || path === \"/view.html\" || path === \"/qr.json\" || path === \"/qr.png\" || path === \"/app-qr.json\")) {\r\n return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, \"preview surface is loopback-only\");\r\n }\r\n if (method === \"GET\" && (path === \"/\" || path === \"/view.html\")) {\r\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\", \"cache-control\": \"no-store\" });\r\n res.end(VIEW_HTML);\r\n return;\r\n }\r\n if (method === \"GET\" && path === \"/qr.json\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n return sendJson(res, 200, { code: qr.code, expiresAt: qr.expiresAt, payload: qr.payload, urls: qr.urls, pngDataUrl: qr.pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n if (method === \"GET\" && path === \"/qr.png\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n const b64 = qr.pngDataUrl.slice(qr.pngDataUrl.indexOf(\",\") + 1);\r\n res.writeHead(200, { \"content-type\": \"image/png\", \"cache-control\": \"no-store\" });\r\n res.end(Buffer.from(b64, \"base64\"));\r\n return;\r\n }\r\n // App-download QR: encodes the APK URL (Gitee by default) so a phone can\r\n // scan it straight from the Web UI overlay and start the download.\r\n if (method === \"GET\" && path === \"/app-qr.json\") {\r\n const target = config.app.downloadUrl || config.app.giteeUrl;\r\n const pngDataUrl = await QRCode.toDataURL(target, { errorCorrectionLevel: \"M\", margin: 2, width: 320 });\r\n return sendJson(res, 200, { url: target, gitee: config.app.giteeUrl, github: config.app.githubUrl, pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n\r\n // ── pairing ──────────────────────────────────────────────────────────────\r\n // Rate limit only FAILED attempts (anti-brute-force); a legitimate new\r\n // device entering a valid code is never throttled, so switching phones\r\n // \"just works\" instead of hitting \"too many pairing attempts\".\r\n if (method === \"POST\" && path === \"/pair\") {\r\n const body = await readJsonBody(req, 4096);\r\n const code = str(body?.code);\r\n const device = (body?.device ?? {}) as Record<string, unknown>;\r\n const deviceId = str(device.id);\r\n const name = str(device.name) ?? \"unnamed device\";\r\n const model = str(device.model) ?? \"\";\r\n if (!code || !deviceId) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"code and device.id are required\");\r\n const verdict = pairing.verify(code);\r\n if (!verdict.ok) {\r\n if (!ctx.pairLimiter.allow(clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"too many pairing attempts\");\r\n return sendError(res, 401, verdict.reason === \"expired\" ? ERROR_CODES.PAIR_CODE_EXPIRED : ERROR_CODES.PAIR_CODE_INVALID, `pairing code ${verdict.reason}`);\r\n }\r\n const token = mintDeviceToken();\r\n devices.upsert({ deviceId, name, model, tokenHash: hashToken(token) });\r\n log(\"info\", `paired device \"${name}\" (${deviceId.slice(0, 8)})`);\r\n return sendJson(res, 200, {\r\n token,\r\n serverInfo: { preview: config.preview, limits: config.limits },\r\n });\r\n }\r\n\r\n // ── authenticated endpoints: loopback or paired device ───────────────────\r\n const auth = loop ? { ok: true } : deviceAuth(req) && devices.authenticate(deviceAuth(req)!.deviceId, deviceAuth(req)!.token) ? { ok: true } : { ok: false };\r\n if (!auth.ok) return sendError(res, 401, ERROR_CODES.AUTH_REQUIRED, \"pair this device first\");\r\n\r\n if (method === \"POST\" && path === \"/upload\") {\r\n const q = queryOf(req);\r\n if (!ctx.uploadLimiter.allow(loop ? \"loopback\" : clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"upload rate exceeded\");\r\n const mediaType = (req.headers[\"content-type\"] ?? \"\").split(\";\")[0]!.trim();\r\n if (!config.limits.allowedTypes.includes(mediaType)) {\r\n return sendError(res, 415, ERROR_CODES.TYPE_NOT_ALLOWED, `allowed: ${config.limits.allowedTypes.join(\", \")}`);\r\n }\r\n const declared = Number(req.headers[\"content-length\"] ?? \"0\");\r\n if (declared > config.limits.maxUploadBytes) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n const { buf, truncated } = await readRawBody(req, config.limits.maxUploadBytes);\r\n if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, \"bytes do not match the declared type\");\r\n\r\n const captureId = q.get(\"captureId\");\r\n const note = q.get(\"note\") ?? (captureId ? hub.noteFor(captureId) : undefined);\r\n if (captureId) hub.consumeCapture(captureId);\r\n const name = (q.get(\"name\") ?? `shot_${new Date().toISOString().replace(/[:.]/g, \"-\")}.${mediaType === \"image/png\" ? \"png\" : \"jpg\"}`).slice(0, 120);\r\n\r\n let admitted;\r\n try {\r\n admitted = await admitImage({ data: buf, mediaType, name }, deps.attachments(), deps.fallbackDir);\r\n } catch (error) {\r\n log(\"error\", `admit failed: ${String(error)}`);\r\n return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));\r\n }\r\n log(\"info\", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);\r\n\r\n // keep the local archive bounded (retention: maxStoredUploads oldest-first)\r\n if (admitted.storage === \"file\") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log(\"warn\", `upload pruning failed: ${String(error)}`));\r\n\r\n // Phase 2 (rev): PRE-SEND semantics. The phone's photo is staged for the\r\n // dsh composer, not injected straight into the model — the browser client\r\n // fetches this staging copy and drops it into the composer draft; the user\r\n // types text and hits send. So no agent.followup here.\r\n const pendingPath = join(deps.pendingDir, `${admitted.ref.attachmentId}.jpg`);\r\n try {\r\n mkdirSync(deps.pendingDir, { recursive: true });\r\n writeFileSync(pendingPath, buf);\r\n log(\"info\", `staged for composer: ${pendingPath}`);\r\n } catch (error) {\r\n log(\"warn\", `staging failed: ${String(error)}`);\r\n }\r\n hub.broadcastToViews({ type: \"pending_image\", attachmentId: admitted.ref.attachmentId, name });\r\n // no direct delivery; the client owns placing it in the composer\r\n\r\n return sendJson(res, 200, {\r\n ok: true,\r\n attachmentId: admitted.ref.attachmentId,\r\n width: admitted.ref.width,\r\n height: admitted.ref.height,\r\n bytes: admitted.ref.bytes,\r\n storage: admitted.storage,\r\n delivered: null,\r\n deliverReason: \"staged-in-composer\",\r\n });\r\n }\r\n\r\n // Browser client fetches the staged photo to place into the composer draft.\r\n if (method === \"GET\" && path.startsWith(\"/pending/\") && loop) {\r\n const id = path.slice(\"/pending/\".length).split(\"/\")[0] ?? \"\";\r\n const file = join(deps.pendingDir, `${id}.jpg`);\r\n try {\r\n const data = await readFile(file);\r\n res.writeHead(200, { \"content-type\": \"image/jpeg\", \"cache-control\": \"no-store\", ...cors });\r\n res.end(data);\r\n return;\r\n } catch {\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\r\n }\r\n }\r\n\r\n if (method === \"GET\" && path === \"/status\") {\r\n const cameraDeviceId = ctx.getCurrentCamera();\r\n return sendJson(res, 200, {\r\n devices: devices.list().map((d) => ({ id: d.deviceId, name: d.name, model: d.model, online: d.deviceId === cameraDeviceId, streaming: d.deviceId === cameraDeviceId, lastSeenAt: d.lastSeenAt })),\r\n camera: hub.stats(),\r\n preview: config.preview,\r\n target: { mode: targets.mode(), sessionId: targets.resolve()?.sessionId ?? null },\r\n lastInjection: ctx.getLastInjection(),\r\n }, cors);\r\n }\r\n\r\n if (method === \"GET\" && path === \"/targets\") {\r\n return sendJson(res, 200, { targets: targets.list(), default: targets.resolve()?.sessionId ?? null }, cors);\r\n }\r\n\r\n // View-side control also arrives over /ws/view; a loopback HTTP trigger is\r\n // handy for smoke tests.\r\n if (method === \"POST\" && path === \"/capture\" && loop) {\r\n const captureId = randomUUID();\r\n const ok = hub.requestCapture(captureId, queryOf(req).get(\"note\") ?? undefined);\r\n if (!ok) return sendError(res, 409, ERROR_CODES.NO_CAMERA, \"no camera uplink connected\");\r\n return sendJson(res, 202, { captureId });\r\n }\r\n\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);\r\n}\r\n\r\n// ── helpers ─────────────────────────────────────────────────────────────────\r\n\r\nfunction sendJson(res: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {\r\n res.writeHead(status, { \"content-type\": \"application/json; charset=utf-8\", ...(extraHeaders ?? {}) });\r\n res.end(JSON.stringify(body));\r\n}\r\n\r\nfunction sendError(res: ServerResponse, status: number, code: string, message: string): void {\r\n sendJson(res, status, { error: { code, message } });\r\n}\r\n\r\nfunction str(v: unknown): string | undefined {\r\n return typeof v === \"string\" ? v : undefined;\r\n}\r\n\r\nasync function readJsonBody(req: IncomingMessage, limit: number): Promise<Record<string, unknown> | null> {\r\n const { buf } = await readRawBody(req, limit);\r\n try {\r\n return JSON.parse(buf.toString(\"utf8\")) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction readRawBody(req: IncomingMessage, maxBytes: number): Promise<{ buf: Buffer; truncated: boolean }> {\r\n return new Promise((resolve, reject) => {\r\n const chunks: Buffer[] = [];\r\n let size = 0;\r\n let truncated = false;\r\n req.on(\"data\", (chunk: Buffer) => {\r\n size += chunk.byteLength;\r\n if (size > maxBytes) {\r\n truncated = true;\r\n chunks.length = 0;\r\n req.destroy();\r\n resolve({ buf: Buffer.alloc(0), truncated });\r\n return;\r\n }\r\n chunks.push(chunk);\r\n });\r\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), truncated }));\r\n req.on(\"error\", reject);\r\n });\r\n}\r\n\r\n/** Delete the oldest files beyond `max` in a directory (mtime order). */\r\nasync function pruneUploads(dir: string, max: number): Promise<void> {\r\n if (max <= 0) return;\r\n const names = await readdir(dir);\r\n if (names.length <= max) return;\r\n const entries = await Promise.all(\r\n names.map(async (name) => {\r\n let mtime = 0;\r\n try {\r\n mtime = (await stat(join(dir, name))).mtimeMs;\r\n } catch {\r\n mtime = 0;\r\n }\r\n return { name, mtime };\r\n }),\r\n );\r\n entries.sort((a, b) => b.mtime - a.mtime);\r\n for (const entry of entries.slice(max)) {\r\n await unlink(join(dir, entry.name)).catch(() => {});\r\n }\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { WebSocket } from \"ws\";\r\nimport type { CameraControl, LensConfig, ViewClientMessage, ViewServerMessage } from \"../types.js\";\r\n\r\n/** Per-device camera uplink state; multiple phones coexist without kicking. */\r\ninterface CamState {\r\n ws: WebSocket;\r\n name: string;\r\n meta: { width?: number; height?: number; fps?: number; rotation?: number };\r\n lastFrame: Buffer | null;\r\n lastFrameAt: number;\r\n frameCount: number;\r\n windowStart: number;\r\n measuredFps: number;\r\n}\r\n\r\n/**\r\n * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by\r\n * deviceId), N loopback view downlinks. The \"active\" device is auto-selected\r\n * as the last one to send a frame; the view side can switch it via\r\n * `select_device`. Only the active device's frames are fanned out — a second\r\n * phone connecting no longer kicks the first, so several paired phones coexist\r\n * and the user picks which one to watch / shoot from.\r\n */\r\nexport class ViewHub {\r\n private cameras = new Map<string, CamState>();\r\n private activeDeviceId: string | null = null;\r\n private views = new Set<WebSocket>();\r\n /** captureId → { note, requestedAt } until the matching upload lands or timeout. */\r\n private pendingCaptures = new Map<string, { note?: string; requestedAt: number }>();\r\n private readonly captureTimeoutMs = 60_000;\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {}\r\n\r\n // ── camera side ───────────────────────────────────────────────────────────\r\n\r\n attachCamera(deviceId: string, ws: WebSocket, name: string): void {\r\n const prev = this.cameras.get(deviceId);\r\n if (prev) {\r\n try {\r\n prev.ws.close(1000, \"new-instance\");\r\n } catch {}\r\n this.cameras.delete(deviceId);\r\n }\r\n const cam: CamState = { ws, name, meta: {}, lastFrame: null, lastFrameAt: 0, frameCount: 0, windowStart: Date.now(), measuredFps: 0 };\r\n this.cameras.set(deviceId, cam);\r\n if (this.activeDeviceId === null) this.activeDeviceId = deviceId;\r\n // only the ACTIVE device streams to the PC; others pause immediately\r\n if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: \"resume_preview\" });\r\n else this.sendControl(deviceId, { type: \"pause_preview\" });\r\n this.log(\"info\", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);\r\n\r\n ws.on(\"close\", () => {\r\n if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) {\r\n this.ingestFrame(deviceId, data as Buffer);\r\n return;\r\n }\r\n this.onCameraControl(deviceId, safeJson(data.toString()));\r\n });\r\n // greet the new device to views\r\n this.broadcastDevices();\r\n }\r\n\r\n detachCamera(deviceId: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) this.cameras.delete(deviceId);\r\n if (this.activeDeviceId === deviceId) {\r\n const next = [...this.cameras.keys()].at(-1) ?? null;\r\n this.activeDeviceId = next;\r\n if (next) this.sendControl(next, { type: \"resume_preview\" });\r\n this.pushActiveFrameToViews();\r\n }\r\n this.broadcastDevices();\r\n }\r\n\r\n detachAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n try {\r\n cam.ws.close(1000, \"server-dispose\");\r\n } catch {}\r\n }\r\n this.cameras.clear();\r\n this.activeDeviceId = null;\r\n this.broadcastDevices();\r\n }\r\n\r\n pingAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();\r\n }\r\n }\r\n\r\n private onCameraControl(deviceId: string, msg: CameraControl | null): void {\r\n if (!msg) return;\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam) return;\r\n switch (msg.type) {\r\n case \"hello\":\r\n cam.meta = { width: msg.width, height: msg.height, fps: msg.fps, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) };\r\n if (this.activeDeviceId === deviceId) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: msg.width, height: msg.height, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) });\r\n }\r\n break;\r\n case \"bye\":\r\n this.detachCamera(deviceId);\r\n break;\r\n case \"claim_active\":\r\n // phone asked to become the active device → switch the view to it.\r\n this.selectDevice(deviceId);\r\n break;\r\n case \"capture_result\":\r\n if (msg.status !== \"taken\") {\r\n this.pendingCaptures.delete(msg.captureId);\r\n this.broadcastToViews({ type: \"error\", code: \"CAPTURE_DECLINED\", message: `phone reported ${msg.status}${msg.detail ? `: ${msg.detail}` : \"\"}` });\r\n }\r\n break;\r\n default:\r\n break;\r\n }\r\n }\r\n\r\n private ingestFrame(deviceId: string, frame: Buffer): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam || frame.byteLength === 0) return;\r\n if (frame.byteLength > this.config.limits.previewFrameMaxBytes) {\r\n this.log(\"warn\", `dropping oversized preview frame from ${deviceId.slice(0, 8)} (${frame.byteLength}B > ${this.config.limits.previewFrameMaxBytes}B)`);\r\n return;\r\n }\r\n const now = Date.now();\r\n cam.lastFrame = frame;\r\n cam.lastFrameAt = now;\r\n cam.frameCount++;\r\n if (now - cam.windowStart >= 2000) {\r\n cam.measuredFps = (cam.frameCount * 1000) / (now - cam.windowStart);\r\n cam.windowStart = now;\r\n cam.frameCount = 0;\r\n }\r\n // only the ACTIVE device drives the view; others just keep their own state\r\n if (this.activeDeviceId === deviceId) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(frame, { binary: true });\r\n }\r\n }\r\n }\r\n\r\n // ── view side ─────────────────────────────────────────────────────────────\r\n\r\n attachView(ws: WebSocket, hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void } = {}): void {\r\n this.views.add(ws);\r\n ws.on(\"close\", () => this.views.delete(ws));\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) return;\r\n const msg = parseViewClient(data.toString());\r\n if (!msg) return;\r\n if (msg.type === \"capture\") {\r\n const captureId = randomUUID();\r\n if (!this.requestCapture(captureId, msg.note)) {\r\n this.broadcastToViews({ type: \"error\", code: \"NO_CAMERA\", message: \"no camera uplink connected\" });\r\n }\r\n } else if (msg.type === \"select_device\") {\r\n this.selectDevice(msg.deviceId);\r\n } else if (msg.type === \"rename_device\") {\r\n hooks.onRenameDevice?.(msg.deviceId, msg.name);\r\n } else if (msg.type === \"refresh_pairing\") {\r\n hooks.onRefreshPairing?.();\r\n }\r\n });\r\n const active = this.activeCam();\r\n ws.send(\r\n JSON.stringify({\r\n type: \"meta\",\r\n camera: active ? { connected: true, name: active.name, ...active.meta } : { connected: false },\r\n preview: this.config.preview,\r\n paired: true,\r\n } satisfies ViewServerMessage),\r\n );\r\n this.broadcastDevicesTo(ws);\r\n if (active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });\r\n }\r\n\r\n viewCount(): number {\r\n return this.views.size;\r\n }\r\n\r\n /** Update one device's display name and re-broadcast the device list. */\r\n renameDevice(deviceId: string, name: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) cam.name = name;\r\n this.broadcastDevices();\r\n }\r\n\r\n /** Switch which phone's frames / shutter the view follows. */\r\n selectDevice(deviceId: string): void {\r\n if (!this.cameras.has(deviceId)) return;\r\n const prevActive = this.activeDeviceId;\r\n if (prevActive === deviceId) return;\r\n this.activeDeviceId = deviceId;\r\n if (prevActive) this.sendControl(prevActive, { type: \"pause_preview\" });\r\n this.sendControl(deviceId, { type: \"resume_preview\" });\r\n this.broadcastDevices();\r\n this.pushActiveFrameToViews();\r\n }\r\n\r\n /** Send one control message to a specific phone uplink. */\r\n private sendControl(deviceId: string, msg: CameraControl): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam && cam.ws.readyState === cam.ws.OPEN) cam.ws.send(JSON.stringify(msg));\r\n }\r\n\r\n private pushActiveFrameToViews(): void {\r\n const active = this.activeCam();\r\n if (active?.lastFrame) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });\r\n }\r\n }\r\n // re-announce frame size/rotation for the newly selected device\r\n if (active) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: active.meta.width ?? 0, height: active.meta.height ?? 0, ...(active.meta.rotation !== void 0 ? { rotation: active.meta.rotation } : {}) });\r\n }\r\n }\r\n\r\n private activeCam(): CamState | undefined {\r\n return this.activeDeviceId ? this.cameras.get(this.activeDeviceId) : undefined;\r\n }\r\n\r\n private broadcastDevices(): void {\r\n for (const view of this.views) this.broadcastDevicesTo(view);\r\n }\r\n\r\n private broadcastDevicesTo(view: WebSocket): void {\r\n if (view.readyState !== view.OPEN) return;\r\n view.send(\r\n JSON.stringify({\r\n type: \"devices\",\r\n devices: [...this.cameras.values()].map((c, i) => {\r\n const id = [...this.cameras.keys()][i]!;\r\n return { id, name: c.name, active: id === this.activeDeviceId };\r\n }),\r\n } satisfies ViewServerMessage),\r\n );\r\n }\r\n\r\n // ── capture correlation ───────────────────────────────────────────────────\r\n\r\n /** Ask the ACTIVE phone to shoot. Returns the captureId, or null when none. */\r\n requestCapture(captureId: string, note?: string): string | null {\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return null;\r\n this.pendingCaptures.set(captureId, { note, requestedAt: Date.now() });\r\n active.ws.send(JSON.stringify({ type: \"capture\", captureId, ...(note ? { note } : {}) } satisfies CameraControl));\r\n this.broadcastToViews({ type: \"capture_pending\", captureId, ...(note ? { note } : {}) });\r\n this.gcCaptures();\r\n return captureId;\r\n }\r\n\r\n consumeCapture(captureId: string): { note?: string } | null {\r\n const pending = this.pendingCaptures.get(captureId);\r\n if (!pending) return null;\r\n this.pendingCaptures.delete(captureId);\r\n return { note: pending.note };\r\n }\r\n\r\n noteFor(captureId: string): string | undefined {\r\n return this.pendingCaptures.get(captureId)?.note;\r\n }\r\n\r\n private gcCaptures(): void {\r\n const cutoff = Date.now() - this.captureTimeoutMs;\r\n for (const [id, p] of this.pendingCaptures) {\r\n if (p.requestedAt < cutoff) this.pendingCaptures.delete(id);\r\n }\r\n }\r\n\r\n broadcastToViews(msg: ViewServerMessage): void {\r\n const text = JSON.stringify(msg);\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(text);\r\n }\r\n }\r\n\r\n stats(): { connected: boolean; fps: number; lastFrameAt: number; views: number; devices: number } {\r\n const active = this.activeCam();\r\n return {\r\n connected: this.cameras.size > 0,\r\n fps: active ? Math.round(active.measuredFps * 10) / 10 : 0,\r\n lastFrameAt: active?.lastFrameAt ?? 0,\r\n views: this.views.size,\r\n devices: this.cameras.size,\r\n };\r\n }\r\n}\r\n\r\nfunction safeJson(text: string): CameraControl | null {\r\n try {\r\n return JSON.parse(text) as CameraControl;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction parseViewClient(text: string): ViewClientMessage | null {\r\n try {\r\n return JSON.parse(text) as ViewClientMessage;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n","import { join } from \"node:path\";\r\nimport { homedir } from \"node:os\";\r\nimport { env } from \"node:process\";\r\n\r\n/** Where pairing records + fallback uploads live (mirrors dsh-home conventions). */\r\nexport function lensDataDir(): string {\r\n const home = env.DSH_HOME ?? join(homedir(), \".dsh\");\r\n return join(home, \"phone-lens\");\r\n}\r\n","import { join } from \"node:path\";\r\nimport { Service } from \"@deepseek-ai/cordis\";\r\nimport z from \"@deepseek-ai/schemastery\";\r\nimport { normalizeConfig } from \"./config.js\";\r\nimport { HostDeliverySink, type EventedAgent } from \"./inject/host-sink.js\";\r\nimport { TargetTracker } from \"./inject/target.js\";\r\nimport { DeviceStore } from \"./store/devices.js\";\r\nimport { PairingStore } from \"./store/pairing.js\";\r\nimport { startLensServer, type LensServerHandle } from \"./server/http.js\";\r\nimport { ViewHub } from \"./server/hub.js\";\r\nimport { buildPairingQr } from \"./server/qr.js\";\r\nimport type { AttachmentStoreLike } from \"./inject/admit.js\";\r\nimport { lensDataDir } from \"./paths.js\";\r\n\r\ntype LogLevel = \"info\" | \"warn\" | \"error\";\r\n\r\n/**\r\n * phone-lens host plugin.\r\n *\r\n * Boots the receiver inside the dsh process so uploads can flow straight\r\n * into `ctx.attachments` (and, from Phase 2 on, into agent inboxes) with no\r\n * extra IPC. Everything registered here unwinds with the fiber.\r\n */\r\nexport default class PhoneLens extends Service {\r\n static inject: string[] = [];\r\n static Config = z.any();\r\n\r\n constructor(ctx: any, rawConfig: unknown) {\r\n super(ctx, \"phoneLens\");\r\n const config = normalizeConfig(rawConfig);\r\n const dataDir = lensDataDir();\r\n const log = (level: LogLevel, msg: string) => {\r\n const target = (ctx.logger as Record<LogLevel, (m: string) => void> | undefined) ?? console;\r\n target[level]?.(`[phone-lens] ${msg}`);\r\n };\r\n\r\n const pairing = new PairingStore(config.pairing.codeTtlMs);\r\n const devices = new DeviceStore(dataDir);\r\n const hub = new ViewHub(config, (level, msg) => log(level, msg));\r\n const targets = new TargetTracker(config);\r\n // Phase 2: real delivery into a live dsh session; the LoggingSink remains\r\n // the standalone/dev fallback. Agent events keep the sink's active target.\r\n const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));\r\n const attachments = (): AttachmentStoreLike | undefined => ctx.get?.(\"attachments\") as AttachmentStoreLike | undefined;\r\n\r\n // agent wiring (scope-filtered events fire on the root ctx for global\r\n // listeners): the sink tracks the last-active session for delivery.\r\n const onAgent = (agent: EventedAgent) => sink.track(agent);\r\n const offAgent = (agent: EventedAgent) => sink.untrack(agent);\r\n ctx.on?.(\"agent/session-start\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/inbox/inserted\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/status\", (payload: { agent: EventedAgent; status: string }) => {\r\n if (payload.status === \"running\") onAgent(payload.agent);\r\n });\r\n ctx.on?.(\"agent/disposed\", (payload: { agent: EventedAgent }) => offAgent(payload.agent));\r\n\r\n let handle: LensServerHandle | null = null;\r\n startLensServer({\r\n config,\r\n pairing,\r\n devices,\r\n hub,\r\n targets,\r\n sink,\r\n attachments,\r\n fallbackDir: join(dataDir, \"uploads\"),\r\n pendingDir: join(dataDir, \"pending\"),\r\n log,\r\n })\r\n .then(async (h) => {\r\n handle = h;\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n // ASCII QR goes to the real stdout; prefixing it through the logger\r\n // would mangle the block characters.\r\n process.stdout.write(`\\n[phone-lens] 手机扫码配对(或浏览器打开 http://127.0.0.1:${h.port}/view.html):\\n${qr.ascii}\\n[phone-lens] 备用地址: ${qr.urls.join(\" \")}\\n\\n`);\r\n })\r\n .catch((error: unknown) => {\r\n log(\"error\", `receiver failed to start: ${String(error)} (check port ${config.server.port})`);\r\n });\r\n\r\n ctx.effect(() => () => {\r\n void handle?.dispose();\r\n handle = null;\r\n }, \"phone-lens.server()\");\r\n }\r\n}\r\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["raw: unknown","t: unknown","value: unknown","fallback: number","min: number","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","agent: EventedAgent","admitted: AdmittedImage","note: string | undefined","mode: \"followup\" | \"steer\"","content: unknown[]","config: LensConfig","ttlMs: number","submitted: string","now: number","token: string","recordHash: string","presentedToken: string","dataDir: string","record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">","full: DeviceRecord","deviceId: string","name: string","token: string","mediaType: string","head: Buffer","input: { data: Uint8Array; mediaType: string; name: string }","attachments: AttachmentStoreLike | undefined","fallbackDir: string","out: LanAddress[]","ip: string","code: string","expiresAt: number","config: LensConfig","originOverride?: string","windowMs: number","max: number","key: string","req: IncomingMessage","deps: ServerDeps","currentCameraDeviceId: string | null","injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null }","server: HttpServer","req: IncomingMessage","socket: Duplex","head: Buffer","status: number","message: string","res: ServerResponse","ctx: RouteCtx","body: unknown","extraHeaders?: Record<string, string>","code: string","v: unknown","limit: number","maxBytes: number","chunks: Buffer[]","chunk: Buffer","dir: string","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","deviceId: string","ws: WebSocket","name: string","cam: CamState","msg: CameraControl | null","frame: Buffer","hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void }","msg: CameraControl","view: WebSocket","captureId: string","note?: string","msg: ViewServerMessage","text: string","ctx: any","rawConfig: unknown","level: LogLevel","msg: string","agent: EventedAgent","payload: { agent: EventedAgent }","payload: { agent: EventedAgent; status: string }","handle: LensServerHandle | null","error: unknown"],"sources":["../src/config.ts","../src/inject/host-sink.ts","../src/inject/target.ts","../src/store/pairing.ts","../src/store/devices.ts","../src/types.ts","../src/inject/admit.ts","../src/server/qr.ts","../src/server/static-view.ts","../src/server/auth.ts","../src/server/http.ts","../src/server/hub.ts","../src/paths.ts","../src/index.ts"],"sourcesContent":["import type { LensConfig } from \"./types.js\";\r\n\r\n/** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */\r\nexport function normalizeConfig(raw: unknown): LensConfig {\r\n const r = (raw ?? {}) as Record<string, any>;\r\n const server = (r.server ?? {}) as Record<string, any>;\r\n const limits = (r.limits ?? {}) as Record<string, any>;\r\n const pairing = (r.pairing ?? {}) as Record<string, any>;\r\n const preview = (r.preview ?? {}) as Record<string, any>;\r\n const inject = (r.inject ?? {}) as Record<string, any>;\r\n const target = (r.target ?? {}) as Record<string, any>;\r\n const app = (r.app ?? {}) as Record<string, any>;\r\n // App download sources: Gitee first (CN-friendly), GitHub as the fallback link.\r\n // Points at the Gitee release carrying the newest APK — the App itself has\r\n // not changed since v0.2.0; bump this only when a new APK ships.\r\n const GITEE_APK = \"https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk\";\r\n const GITHUB_APK = \"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\";\r\n const giteeUrl = typeof app.giteeUrl === \"string\" && app.giteeUrl ? app.giteeUrl : GITEE_APK;\r\n const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t: unknown): t is string => typeof t === \"string\") : undefined;\r\n const mode = inject.mode === \"steer\" ? \"steer\" : \"followup\";\r\n return {\r\n server: {\r\n host: typeof server.host === \"string\" && server.host ? server.host : \"0.0.0.0\",\r\n port: Number.isInteger(server.port) && server.port! > 0 && server.port! < 65536 ? server.port! : 8791,\r\n },\r\n limits: {\r\n maxUploadBytes: positiveInt(limits.maxUploadBytes, 10 * 1024 * 1024),\r\n allowedTypes: allowed && allowed.length > 0 ? allowed : [\"image/jpeg\", \"image/png\"],\r\n uploadsPerMinute: positiveInt(limits.uploadsPerMinute, 10),\r\n previewFrameMaxBytes: positiveInt(limits.previewFrameMaxBytes, 512 * 1024),\r\n // keep the local upload archive bounded: oldest files are pruned past this\r\n maxStoredUploads: positiveInt(limits.maxStoredUploads, 200),\r\n },\r\n pairing: { codeTtlMs: positiveInt(pairing.codeTtlMs, 15 * 60 * 1000) },\r\n preview: {\r\n maxWidth: positiveInt(preview.maxWidth, 854),\r\n maxHeight: positiveInt(preview.maxHeight, 480),\r\n fps: clampInt(preview.fps, 1, 30, 10),\r\n jpegQuality: clampInt(preview.jpegQuality, 20, 95, 70),\r\n },\r\n inject: {\r\n mode,\r\n notePrefix: typeof inject.notePrefix === \"string\" ? inject.notePrefix : \"[手机拍照]\",\r\n },\r\n target: {\r\n mode: target.mode === \"pinned\" ? \"pinned\" : \"latest\",\r\n pinnedSessionId: typeof target.pinnedSessionId === \"string\" && target.pinnedSessionId ? target.pinnedSessionId : null,\r\n },\r\n app: {\r\n giteeUrl,\r\n githubUrl: typeof app.githubUrl === \"string\" && app.githubUrl ? app.githubUrl : GITHUB_APK,\r\n // The QR encodes this URL; defaults to the Gitee release asset.\r\n downloadUrl: typeof app.downloadUrl === \"string\" && app.downloadUrl ? app.downloadUrl : giteeUrl,\r\n },\r\n };\r\n}\r\n\r\nfunction positiveInt(value: unknown, fallback: number): number {\r\n return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback;\r\n}\r\n\r\nfunction clampInt(value: unknown, min: number, max: number, fallback: number): number {\r\n if (!Number.isInteger(value)) return fallback;\r\n const n = value as number;\r\n return Math.min(max, Math.max(min, n));\r\n}\r\n","import { createUserMessage } from \"@deepseek-ai/dsh-llm\";\r\nimport type { AdmittedImage, DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport type { DeliverySink } from \"./deliver.js\";\r\n\r\n/** Minimal structural view of a live dsh agent (docs/dsh-caps.md Q1/Q3). */\r\ninterface LiveAgent {\r\n id: string;\r\n session: { id: string };\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n ctx?: { sessions?: unknown };\r\n}\r\n\r\nexport interface EventedAgent {\r\n send(message: unknown, target: \"next-turn\" | \"next-step\", wakeup: boolean): void;\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n session: { id: string };\r\n id?: string;\r\n}\r\n\r\n/**\r\n * Phase 2 delivery seam: turns a stored image into a durable dsh user message\r\n * attached to the current/last-active session, so the model sees it on the\r\n * next turn.\r\n *\r\n * The active session is tracked from live agent events (inbox inserts and\r\n * `running` status flips). With no live session the image stays stored but\r\n * no delivery occurs — a real state, not an error.\r\n */\r\nexport class HostDeliverySink implements DeliverySink {\r\n private lastActiveId: string | null = null;\r\n private agents = new Map<string, EventedAgent>();\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n // subscribe via injected listeners; the caller wires these to the context\r\n // because the sink must stay cordis-agnostic for standalone tests.\r\n }\r\n\r\n /** Track an agent that appeared / got input. Call from ctx event handlers. */\r\n track(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.set(id, agent);\r\n this.lastActiveId = id;\r\n this.log(\"info\", `tracking agent ${id} (active target)`);\r\n }\r\n\r\n /** Forget a disposed agent. */\r\n untrack(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.delete(id);\r\n if (this.lastActiveId === id) this.lastActiveId = this.agents.keys().next().value ?? null;\r\n }\r\n\r\n /** The session an upload targets right now, if any. */\r\n private resolve(): EventedAgent | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n const pinned = [...this.agents.values()].find((a) => String(a.session.id) === this.config.target.pinnedSessionId);\r\n if (pinned) return pinned;\r\n this.log(\"warn\", `pinned session ${this.config.target.pinnedSessionId} has no live agent`);\r\n }\r\n return (this.lastActiveId ? this.agents.get(this.lastActiveId) : null) ?? null;\r\n }\r\n\r\n async deliver(admitted: AdmittedImage, note: string | undefined, mode: \"followup\" | \"steer\"): Promise<DeliveryReceipt> {\r\n const agent = this.resolve();\r\n if (!agent) {\r\n return { ok: false, sessionId: null, mode: \"none\", reason: \"no active session\" };\r\n }\r\n try {\r\n const content: unknown[] = [\r\n { type: \"text\", text: note ?? this.config.inject.notePrefix },\r\n { type: \"image\", attachment: admitted.ref },\r\n ];\r\n const message = createUserMessage({\r\n content,\r\n source: { kind: \"plugin\", plugin: \"phone-lens\" },\r\n });\r\n this.log(\"info\", `delivering image (${admitted.ref.attachmentId}) to session ${agent.session.id} via ${mode}`);\r\n if (mode === \"steer\") agent.steer(message);\r\n else agent.followup(message);\r\n return { ok: true, sessionId: String(agent.session.id), mode };\r\n } catch (e) {\r\n this.log(\"warn\", `delivery failed: ${String(e)}`);\r\n return { ok: false, sessionId: null, mode: \"none\", reason: String(e) };\r\n }\r\n }\r\n}\r\n","import type { LensConfig } from \"../types.js\";\r\n\r\nexport interface SessionTarget {\r\n sessionId: string;\r\n title: string;\r\n active: boolean;\r\n running: boolean;\r\n}\r\n\r\n/**\r\n * Resolves the session an upload should be injected into.\r\n *\r\n * Phase 1: pure config view, no live agents (the agent-event wiring that\r\n * feeds `latest` lands in Phase 2 — see architecture.md §2 D4).\r\n */\r\nexport class TargetTracker {\r\n constructor(private readonly config: LensConfig) {}\r\n\r\n /** The session an incoming image would target right now, if any. */\r\n resolve(): SessionTarget | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n return { sessionId: this.config.target.pinnedSessionId, title: \"(pinned)\", active: true, running: false };\r\n }\r\n return null;\r\n }\r\n\r\n /** Choosable targets for GET /targets. */\r\n list(): SessionTarget[] {\r\n const pinned = this.resolve();\r\n return pinned ? [pinned] : [];\r\n }\r\n\r\n mode(): string {\r\n return this.config.target.mode;\r\n }\r\n}\r\n","import { createHash, randomBytes, randomInt, timingSafeEqual } from \"node:crypto\";\r\n\r\n/** One-shot pairing-code lifecycle: create / verify / burn, with TTL. */\r\nexport class PairingStore {\r\n private code: string | null = null;\r\n private createdAt = 0;\r\n private expiresAt = 0;\r\n private used = false;\r\n\r\n constructor(private readonly ttlMs: number) {}\r\n\r\n /** Current live code, or a freshly minted one. */\r\n current(): { code: string; expiresAt: number } {\r\n const now = Date.now();\r\n if (this.code === null || this.used || now >= this.expiresAt) this.mint(now);\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /** Force a fresh code (manual refresh). Invalidates the previous one. */\r\n refresh(): { code: string; expiresAt: number } {\r\n this.mint(Date.now());\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /**\r\n * Verify a submitted code and burn it on success.\r\n * Constant-time compare; expired codes read as invalid.\r\n */\r\n verify(submitted: string): { ok: true } | { ok: false; reason: \"invalid\" | \"expired\" } {\r\n if (this.code === null || this.used) return { ok: false, reason: \"invalid\" };\r\n const now = Date.now();\r\n if (now >= this.expiresAt) return { ok: false, reason: \"expired\" };\r\n const a = Buffer.from(submitted);\r\n const b = Buffer.from(this.code);\r\n const same = a.length === b.length && timingSafeEqual(a, b);\r\n if (!same) return { ok: false, reason: \"invalid\" };\r\n this.used = true;\r\n return { ok: true };\r\n }\r\n\r\n private mint(now: number) {\r\n // 8 decimal digits, zero-padded (human-typable fallback).\r\n this.code = String(randomInt(0, 100_000_000)).padStart(8, \"0\");\r\n this.createdAt = now;\r\n this.expiresAt = now + this.ttlMs;\r\n this.used = false;\r\n }\r\n}\r\n\r\n/** Mint a per-device token. Only its SHA-256 may be persisted. */\r\nexport function mintDeviceToken(): string {\r\n return randomBytes(32).toString(\"hex\");\r\n}\r\n\r\nexport function hashToken(token: string): string {\r\n return createHash(\"sha256\").update(token).digest(\"hex\");\r\n}\r\n\r\n/** Constant-time token-hash comparison. */\r\nexport function tokenHashMatches(recordHash: string, presentedToken: string): boolean {\r\n const a = Buffer.from(recordHash, \"hex\");\r\n const b = Buffer.from(hashToken(presentedToken), \"hex\");\r\n return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { DeviceRecord } from \"../types.js\";\r\nimport { tokenHashMatches } from \"./pairing.js\";\r\n\r\n/**\r\n * Paired-device registry, persisted as JSON under the plugin data dir.\r\n * Records carry token HASHES only; the raw token exists solely in the\r\n * pairing response and the phone's secure storage.\r\n */\r\nexport class DeviceStore {\r\n private devices = new Map<string, DeviceRecord>();\r\n private readonly file: string;\r\n private dirty = false;\r\n\r\n constructor(dataDir: string) {\r\n mkdirSync(dataDir, { recursive: true });\r\n this.file = join(dataDir, \"devices.json\");\r\n this.load();\r\n }\r\n\r\n private load() {\r\n if (!existsSync(this.file)) return;\r\n try {\r\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as { devices?: DeviceRecord[] };\r\n for (const d of raw.devices ?? []) {\r\n if (typeof d?.deviceId === \"string\" && typeof d?.tokenHash === \"string\") {\r\n this.devices.set(d.deviceId, { ...d, name: d.name ?? \"unknown\", model: d.model ?? \"\", firstPairedAt: d.firstPairedAt ?? Date.now(), lastSeenAt: d.lastSeenAt ?? 0 });\r\n }\r\n }\r\n } catch {\r\n // Corrupt registry: start empty rather than refuse to boot.\r\n }\r\n }\r\n\r\n private persist() {\r\n const tmp = `${this.file}.tmp`;\r\n writeFileSync(tmp, JSON.stringify({ version: 1, devices: [...this.devices.values()] }, null, 2), \"utf8\");\r\n renameSync(tmp, this.file);\r\n }\r\n\r\n /** Register/re-register a device, uniquifying the name against others. */\r\n upsert(record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">): DeviceRecord {\r\n const prior = this.devices.get(record.deviceId);\r\n const full: DeviceRecord = {\r\n ...record,\r\n name: this.uniquifyName(record.deviceId, record.name),\r\n firstPairedAt: prior?.firstPairedAt ?? Date.now(),\r\n lastSeenAt: Date.now(),\r\n };\r\n this.devices.set(record.deviceId, full);\r\n this.dirty = true;\r\n this.persist();\r\n return full;\r\n }\r\n\r\n /** Make a display name unique among other devices (x, x (2), x (3), …). */\r\n private uniquifyName(deviceId: string, name: string): string {\r\n const others = [...this.devices.values()].map((d) => d.name).filter((n, i, arr) => arr.indexOf(n) === i);\r\n const base = name.trim() || \"Android 设备\";\r\n if (!others.includes(base)) return base;\r\n for (let n = 2; ; n++) {\r\n const candidate = `${base} (${n})`;\r\n if (!others.includes(candidate)) return candidate;\r\n }\r\n }\r\n\r\n /** Rename a device (user-editable in the web UI); returns the updated record. */\r\n rename(deviceId: string, name: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n record.name = name.trim() || record.name;\r\n this.dirty = true;\r\n this.persist();\r\n return record;\r\n }\r\n\r\n /** Authenticate deviceId + token; refreshes lastSeenAt on success. */\r\n authenticate(deviceId: string, token: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n if (!tokenHashMatches(record.tokenHash, token)) return null;\r\n record.lastSeenAt = Date.now();\r\n this.dirty = true;\r\n return record;\r\n }\r\n\r\n remove(deviceId: string): boolean {\r\n const had = this.devices.delete(deviceId);\r\n if (had) this.persist();\r\n return had;\r\n }\r\n\r\n list(): DeviceRecord[] {\r\n return [...this.devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);\r\n }\r\n\r\n count(): number {\r\n return this.devices.size;\r\n }\r\n}\r\n","/** Shared vocabulary for the phone-lens host half. */\r\n\r\n/** Normalized plugin config (see cordis.patch.yml / config.ts defaults). */\r\nexport interface LensConfig {\r\n server: { host: string; port: number };\r\n limits: {\r\n maxUploadBytes: number;\r\n allowedTypes: string[];\r\n uploadsPerMinute: number;\r\n previewFrameMaxBytes: number;\r\n maxStoredUploads: number;\r\n };\r\n pairing: { codeTtlMs: number };\r\n preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number };\r\n inject: { mode: \"followup\" | \"steer\"; notePrefix: string };\r\n target: { mode: \"latest\" | \"pinned\"; pinnedSessionId: string | null };\r\n /** Where the phone App APK can be fetched from (QR + fallback links). */\r\n app: { downloadUrl: string; giteeUrl: string; githubUrl: string };\r\n}\r\n\r\n/** Minimal structural view of a dsh ImageAttachmentRef (docs/dsh-caps.md Q1). */\r\nexport interface ImageAttachmentRef {\r\n attachmentId: string;\r\n mediaType: string;\r\n bytes: number;\r\n width: number;\r\n height: number;\r\n name?: string;\r\n originalDimensions?: { width: number; height: number };\r\n}\r\n\r\n/** One durable image ready for session injection. */\r\nexport interface AdmittedImage {\r\n ref: ImageAttachmentRef;\r\n /** Where the bytes physically landed: \"attachments\" (dsh store) or \"file\" (fallback dir). */\r\n storage: \"attachments\" | \"file\";\r\n /** Fallback file path when storage === \"file\". */\r\n filePath?: string;\r\n}\r\n\r\n/** Result of the deliver step. */\r\nexport interface DeliveryReceipt {\r\n ok: boolean;\r\n sessionId: string | null;\r\n mode: \"followup\" | \"steer\" | \"none\";\r\n reason?: string;\r\n}\r\n\r\n/** Persistent paired-device record. The raw token never touches disk — only its SHA-256. */\r\nexport interface DeviceRecord {\r\n deviceId: string;\r\n name: string;\r\n model: string;\r\n tokenHash: string;\r\n firstPairedAt: number;\r\n lastSeenAt: number;\r\n}\r\n\r\n/** Live pairing-code state. */\r\nexport interface PairingState {\r\n code: string;\r\n createdAt: number;\r\n expiresAt: number;\r\n used: boolean;\r\n}\r\n\r\n/** JSON message on the camera websocket (text frames). */\r\nexport type CameraControl =\r\n | { type: \"hello\"; width: number; height: number; fps: number; rotation?: number }\r\n | { type: \"bye\" }\r\n | { type: \"set_preview\"; maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }\r\n | { type: \"capture\"; captureId: string; note?: string }\r\n | { type: \"capture_result\"; captureId: string; status: \"taken\" | \"declined\" | \"failed\"; detail?: string }\r\n // phone→host: make THIS phone the active preview/shutter device\r\n | { type: \"claim_active\" }\r\n // host→phone: another device owns the PC preview; stop/start streaming\r\n | { type: \"pause_preview\" }\r\n | { type: \"resume_preview\" };\r\n\r\n/** JSON message on the view websocket. */\r\nexport type ViewServerMessage =\r\n | { type: \"meta\"; camera: { connected: boolean; width?: number; height?: number; fps?: number; rotation?: number; name?: string }; preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }; paired: boolean }\r\n | { type: \"frame_meta\"; width: number; height: number; rotation?: number }\r\n | { type: \"devices\"; devices: { id: string; name: string; active: boolean }[] }\r\n | { type: \"device\"; online: boolean; name?: string }\r\n | { type: \"capture_pending\"; captureId: string; note?: string }\r\n | { type: \"pending_image\"; attachmentId: string; name?: string }\r\n | { type: \"injected\"; captureId?: string; attachmentId: string; sessionId: string | null; ok: boolean; reason?: string; name?: string }\r\n | { type: \"upload\"; attachmentId: string; storage: string; name?: string }\r\n | { type: \"error\"; code: string; message?: string };\r\n\r\nexport type ViewClientMessage =\r\n | { type: \"capture\"; note?: string }\r\n | { type: \"select_device\"; deviceId: string }\r\n | { type: \"rename_device\"; deviceId: string; name: string }\r\n | { type: \"refresh_pairing\" }\r\n | { type: \"ping\" };\r\n\r\n/** Wire error body. */\r\nexport interface ErrorBody {\r\n error: { code: string; message: string };\r\n}\r\n\r\nexport const ERROR_CODES = {\r\n AUTH_REQUIRED: \"AUTH_REQUIRED\",\r\n PAIR_CODE_INVALID: \"PAIR_CODE_INVALID\",\r\n PAIR_CODE_EXPIRED: \"PAIR_CODE_EXPIRED\",\r\n RATE_LIMITED: \"RATE_LIMITED\",\r\n TYPE_NOT_ALLOWED: \"TYPE_NOT_ALLOWED\",\r\n TOO_LARGE: \"TOO_LARGE\",\r\n BAD_MAGIC: \"BAD_MAGIC\",\r\n STORE_FAILED: \"STORE_FAILED\",\r\n NO_TARGET: \"NO_TARGET\",\r\n NO_CAMERA: \"NO_CAMERA\",\r\n CAPTURE_TIMEOUT: \"CAPTURE_TIMEOUT\",\r\n LOOPBACK_ONLY: \"LOOPBACK_ONLY\",\r\n BAD_REQUEST: \"BAD_REQUEST\",\r\n INTERNAL: \"INTERNAL\",\r\n} as const;\r\n","import { createHash } from \"node:crypto\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { AdmittedImage } from \"../types.js\";\r\n\r\n/** Structural type of the dsh attachment store we consume (docs/dsh-caps.md Q1). */\r\nexport interface AttachmentStoreLike {\r\n saveImages(inputs: ReadonlyArray<{ data: Uint8Array; mediaType: string; name?: string }>): Promise<readonly AdmittedImage[\"ref\"][]>;\r\n}\r\n\r\n/** Magic-byte whitelist check — never trust Content-Type alone. */\r\nexport function magicMatches(mediaType: string, head: Buffer): boolean {\r\n if (mediaType === \"image/jpeg\") return head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;\r\n if (mediaType === \"image/png\") return head.length >= 4 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;\r\n return false;\r\n}\r\n\r\n/**\r\n * Persist one image through the durable attachment seam.\r\n * Falls back to a content-addressed file when the dsh attachments service is\r\n * not mounted (standalone dev / unusual profile), so uploads never vanish.\r\n */\r\nexport async function admitImage(\r\n input: { data: Uint8Array; mediaType: string; name: string },\r\n attachments: AttachmentStoreLike | undefined,\r\n fallbackDir: string,\r\n): Promise<AdmittedImage> {\r\n if (attachments) {\r\n const refs = await attachments.saveImages([{ data: input.data, mediaType: input.mediaType, name: input.name }]);\r\n const ref = refs[0];\r\n if (!ref) throw new Error(\"attachment store returned no reference\");\r\n return { ref, storage: \"attachments\" };\r\n }\r\n const ext = input.mediaType === \"image/png\" ? \"png\" : \"jpg\";\r\n const digest = createHash(\"sha1\").update(input.data).digest(\"hex\").slice(0, 16);\r\n mkdirSync(fallbackDir, { recursive: true });\r\n const filePath = join(fallbackDir, `${digest}-${input.name.replace(/[^\\w.-]+/g, \"_\") || \"image\"}.${ext}`);\r\n writeFileSync(filePath, input.data);\r\n return {\r\n ref: {\r\n attachmentId: `file:${digest}`,\r\n mediaType: input.mediaType,\r\n bytes: input.data.byteLength,\r\n width: 0,\r\n height: 0,\r\n name: input.name,\r\n },\r\n storage: \"file\",\r\n filePath,\r\n };\r\n}\r\n","import * as os from \"node:os\";\r\nimport QRCode from \"qrcode\";\r\nimport type { LensConfig } from \"../types.js\";\r\n\r\nexport interface LanAddress {\r\n ip: string;\r\n iface: string;\r\n}\r\n\r\n/** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */\r\nexport function lanAddresses(): LanAddress[] {\r\n const out: LanAddress[] = [];\r\n for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {\r\n for (const addr of addrs ?? []) {\r\n if (addr.family !== \"IPv4\" || addr.internal) continue;\r\n out.push({ ip: addr.address, iface });\r\n }\r\n }\r\n const rank = (ip: string): number => {\r\n if (ip.startsWith(\"192.168.\")) return 0; // typical Wi-Fi + Android USB tethering\r\n if (ip.startsWith(\"172.\")) return 1; // container/iPhone-USB ranges\r\n if (ip.startsWith(\"10.\")) return 2;\r\n return 3;\r\n };\r\n return out.sort((a, b) => rank(a.ip) - rank(b.ip) || a.ip.localeCompare(b.ip));\r\n}\r\n\r\nexport interface PairingQr {\r\n code: string;\r\n expiresAt: number;\r\n payload: string;\r\n urls: string[];\r\n pngDataUrl: string;\r\n ascii: string;\r\n}\r\n\r\n/** Build the full pairing payload + rendered QR (data URL for browsers, ASCII for terminals). */\r\nexport async function buildPairingQr(code: string, expiresAt: number, config: LensConfig, originOverride?: string): Promise<PairingQr> {\r\n const addrs = lanAddresses();\r\n const host = originOverride ?? addrs[0]?.ip ?? \"127.0.0.1\";\r\n const port = config.server.port;\r\n const payload = `lensmate://pair?v=1&host=${encodeURIComponent(host)}&port=${port}&code=${code}`;\r\n const urls = (addrs.length > 0 ? addrs : [{ ip: \"127.0.0.1\", iface: \"loopback\" }]).map((a) => `http://${a.ip}:${port}`);\r\n const [pngDataUrl, ascii] = await Promise.all([\r\n QRCode.toDataURL(payload, { errorCorrectionLevel: \"M\", margin: 2, width: 320 }),\r\n QRCode.toString(payload, { type: \"terminal\", small: true }),\r\n ]);\r\n return { code, expiresAt, payload, urls, pngDataUrl, ascii };\r\n}\r\n","/**\r\n * The standalone fallback viewfinder page, served at /view.html (loopback only).\r\n * Same protocol as the future Web UI overlay: WS /ws/view frames + capture control.\r\n * Picture-in-Picture turns it into a true always-on-top mini window.\r\n */\r\nexport const VIEW_HTML = `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n<title>PhoneLens 直连取景</title>\r\n<style>\r\n :root { color-scheme: dark; }\r\n * { box-sizing: border-box; }\r\n body { margin: 0; font: 13px/1.5 system-ui, \"Segoe UI\", \"Microsoft YaHei\", sans-serif;\r\n background: #101418; color: #dfe7ee; display: flex; flex-direction: column; height: 100vh; }\r\n header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #171d24; border-bottom: 1px solid #232c36; }\r\n header h1 { font-size: 13px; margin: 0; font-weight: 600; }\r\n #dot { width: 9px; height: 9px; border-radius: 50%; background: #e5484d; transition: background .2s; }\r\n #dot.on { background: #46a758; }\r\n #fps { margin-left: auto; color: #7d8da0; font-variant-numeric: tabular-nums; }\r\n main { flex: 1; display: flex; min-height: 0; }\r\n #stage { flex: 1; display: flex; align-items: center; justify-content: center; background: #0b0e11; position: relative; min-width: 0; }\r\n canvas { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }\r\n #hint { position: absolute; color: #7d8da0; }\r\n aside { width: 240px; border-left: 1px solid #232c36; padding: 12px; overflow: auto; }\r\n aside h2 { font-size: 12px; margin: 0 0 8px; color: #9fb0c3; text-transform: uppercase; letter-spacing: .08em; }\r\n #qrimg { width: 100%; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }\r\n .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }\r\n .row code { background: #1d242d; padding: 2px 8px; border-radius: 6px; letter-spacing: .12em; font-size: 15px; }\r\n .row a { color: #3b7cb5; text-decoration: none; }\r\n .row a:hover { text-decoration: underline; }\r\n button { background: #1f2937; border: 1px solid #2f3b48; color: #dfe7ee; padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }\r\n button:hover { background: #28323e; }\r\n button:disabled { opacity: .45; cursor: default; }\r\n #shoot { padding: 10px; font-size: 14px; background: #2a5d8f; border-color: #3b7cb5; }\r\n #pip { display: none; }\r\n #log { margin-top: 10px; font-size: 12px; color: #8fa1b5; max-height: 160px; overflow: auto; }\r\n #log div { padding: 2px 0; border-bottom: 1px dashed #202832; }\r\n #log .ok { color: #58b368; } #log .err { color: #e5696e; }\r\n</style>\r\n</head>\r\n<body>\r\n<header>\r\n <div id=\"dot\"></div><h1>PhoneLens 直连取景</h1>\r\n <span id=\"dev\"></span>\r\n <span id=\"fps\"></span><span id=\"age\"></span>\r\n</header>\r\n<main>\r\n <div id=\"stage\"><canvas width=\"854\" height=\"480\"></canvas><div id=\"hint\">等待手机连接…</div></div>\r\n <aside>\r\n <h2>配对</h2>\r\n <img id=\"qrimg\" alt=\"配对二维码\">\r\n <div class=\"row\"><code id=\"paircode\"></code><button id=\"refresh\">刷新</button></div>\r\n <div class=\"row\" id=\"urls\"></div>\r\n <div class=\"row\"><a href=\"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\" target=\"_blank\" rel=\"noopener\">📱 手机还没装 App?点此下载(Android APK)</a></div>\r\n <h2 style=\"margin-top:14px\">快门</h2>\r\n <div class=\"row\">\r\n <button id=\"shoot\" disabled>◉ 拍照并注入</button>\r\n <button id=\"pip\">置顶小窗</button>\r\n </div>\r\n <div id=\"log\"></div>\r\n </aside>\r\n</main>\r\n<video id=\"pipvid\" muted playsinline style=\"display:none\"></video>\r\n<script>\r\n'use strict';\r\nconst $ = (id) => document.getElementById(id);\r\nconst canvas = $('stage').querySelector('canvas');\r\nconst ctx2d = canvas.getContext('2d');\r\nconst logEl = $('log');\r\nlet lastFrameAt = 0, frames = 0, fpsTimer = 0, pendingShot = null;\r\nlet streamRotation = 0;\r\n\r\nfunction log(msg, cls) {\r\n const d = document.createElement('div');\r\n if (cls) d.className = cls;\r\n d.textContent = new Date().toLocaleTimeString() + ' ' + msg;\r\n logEl.prepend(d);\r\n while (logEl.childElementCount > 50) logEl.lastChild.remove();\r\n}\r\n\r\nasync function refreshQr() {\r\n try {\r\n const r = await fetch('/qr.json');\r\n const j = await r.json();\r\n $('qrimg').src = j.pngDataUrl;\r\n $('paircode').textContent = j.code;\r\n $('urls').innerHTML = (j.urls || []).map(u => '<span style=\"color:#7d8da0\">' + u + '</span>').join('<br>');\r\n } catch (e) { log('二维码加载失败: ' + e.message, 'err'); }\r\n}\r\n$('refresh').onclick = () => { wsSend({ type: 'refresh_pairing' }); setTimeout(refreshQr, 150); };\r\n\r\nfunction drawFrame(buf) {\r\n const blob = new Blob([buf], { type: 'image/jpeg' });\r\n const url = URL.createObjectURL(blob);\r\n const img = new Image();\r\n img.onload = () => {\r\n const r = ((streamRotation % 360) + 360) % 360;\r\n const swap = r === 90 || r === 270;\r\n const w = swap ? img.height : img.width;\r\n const h = swap ? img.width : img.height;\r\n if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\r\n ctx2d.save();\r\n ctx2d.translate(w / 2, h / 2);\r\n ctx2d.rotate(r * Math.PI / 180);\r\n ctx2d.drawImage(img, -img.width / 2, -img.height / 2);\r\n ctx2d.restore();\r\n URL.revokeObjectURL(url);\r\n $('hint').style.display = 'none';\r\n frames++;\r\n const now = performance.now();\r\n if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }\r\n lastFrameAt = now;\r\n };\r\n img.src = url;\r\n}\r\n\r\nlet ws = null;\r\nfunction connect() {\r\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\r\n ws = new WebSocket(proto + '://' + location.host + '/ws/view');\r\n ws.binaryType = 'arraybuffer';\r\n ws.onopen = () => { log('已连接'); refreshQr(); };\r\n ws.onclose = () => { $('dot').className = ''; setTimeout(connect, 1500); };\r\n ws.onerror = () => {};\r\n ws.onmessage = (ev) => {\r\n if (ev.data instanceof ArrayBuffer) { drawFrame(ev.data); return; }\r\n const m = JSON.parse(ev.data);\r\n switch (m.type) {\r\n case 'meta':\r\n $('dot').className = m.camera.connected ? 'on' : '';\r\n $('dev').textContent = m.camera.name || (m.camera.connected ? '已连接' : '未连接');\r\n $('shoot').disabled = !m.camera.connected;\r\n if (m.camera.rotation !== undefined) streamRotation = m.camera.rotation;\r\n $('hint').style.display = m.camera.connected ? 'none' : '';\r\n break;\r\n case 'frame_meta':\r\n if (m.rotation !== undefined) streamRotation = m.rotation;\r\n break;\r\n case 'device':\r\n $('dot').className = m.online ? 'on' : '';\r\n $('dev').textContent = m.online ? (m.name || '已连接') : '未连接';\r\n $('shoot').disabled = !m.online;\r\n $('hint').style.display = m.online ? 'none' : '';\r\n $('hint').textContent = '等待手机连接…';\r\n break;\r\n case 'capture_pending':\r\n pendingShot = m.captureId;\r\n log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');\r\n break;\r\n case 'injected':\r\n pendingShot = null;\r\n if (m.ok) log('已注入会话 ' + (m.sessionId || '').slice(0, 8) + (m.name ? ' · ' + m.name : ''), 'ok');\r\n else log('入库成功,注入未完成: ' + (m.reason || ''), 'err');\r\n break;\r\n case 'upload':\r\n log('图片已入库 ' + (m.name || ''), 'ok');\r\n break;\r\n case 'error':\r\n log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');\r\n break;\r\n }\r\n };\r\n}\r\nfunction wsSend(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }\r\n\r\n$('shoot').onclick = () => wsSend({ type: 'capture', note: '' });\r\n\r\n// PiP: mirror the canvas into a video stream, then request Picture-in-Picture.\r\n$('pip').onclick = async () => {\r\n try {\r\n const stream = canvas.captureStream(10);\r\n const v = $('pipvid');\r\n v.srcObject = stream;\r\n await v.play();\r\n await v.requestPictureInPicture();\r\n } catch (e) { log('画中画失败: ' + e.message, 'err'); }\r\n};\r\n\r\nsetInterval(() => {\r\n if (document.pictureInPictureElement) return;\r\n if (performance.now() - lastFrameAt > 4000 && lastFrameAt > 0) $('hint').style.display = '';\r\n $('age').textContent = lastFrameAt > 0 ? ' · ' + ((performance.now() - lastFrameAt) / 1000).toFixed(1) + 's前' : '';\r\n}, 1000);\r\nif (document.pictureInPictureEnabled) $('pip').style.display = '';\r\n\r\nconnect();\r\n</script>\r\n</body>\r\n</html>\r\n`;\r\n","import type { IncomingMessage } from \"node:http\";\r\n\r\n/** Sliding-window rate limiter (in-memory; resets with the process). */\r\nexport class RateLimiter {\r\n private hits = new Map<string, number[]>();\r\n\r\n constructor(private readonly windowMs: number, private readonly max: number) {}\r\n\r\n /** Record one hit and report whether the key is still within budget. */\r\n allow(key: string): boolean {\r\n const now = Date.now();\r\n const cutoff = now - this.windowMs;\r\n const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);\r\n if (list.length >= this.max) {\r\n this.hits.set(key, list);\r\n return false;\r\n }\r\n list.push(now);\r\n this.hits.set(key, list);\r\n return true;\r\n }\r\n}\r\n\r\nconst LOOPBACK = new Set([\"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\"]);\r\n\r\n/** Whether the connection originates from this machine (loopback). */\r\nexport function isLoopback(req: IncomingMessage): boolean {\r\n const addr = req.socket.remoteAddress ?? \"\";\r\n return LOOPBACK.has(addr);\r\n}\r\n\r\n/**\r\n * CORS for the dsh Web UI page (served on another loopback port) so it can\r\n * fetch /qr.json and /status from this receiver. Only same-machine origins\r\n * are echoed — a foreign page must not read the pairing code, and its\r\n * cross-origin JSON POST fails the preflight we never answer.\r\n */\r\nexport function corsFor(req: IncomingMessage): Record<string, string> {\r\n const origin = req.headers.origin;\r\n if (typeof origin !== \"string\" || origin === \"\") return {};\r\n try {\r\n const u = new URL(origin);\r\n const host = u.hostname.replace(/^\\[|\\]$/g, \"\");\r\n if (u.protocol === \"http:\" && LOOPBACK.has(host)) {\r\n return {\r\n \"access-control-allow-origin\": origin,\r\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\r\n \"access-control-allow-headers\": \"content-type, x-lm-device, x-lm-token\",\r\n \"access-control-max-age\": \"600\",\r\n };\r\n }\r\n } catch {\r\n /* malformed origin: no CORS headers */\r\n }\r\n return {};\r\n}\r\n\r\n/** Extract a stable client key for rate limiting. */\r\nexport function clientKey(req: IncomingMessage): string {\r\n return req.socket.remoteAddress ?? \"unknown\";\r\n}\r\n\r\n/** Read the device auth headers, if present. */\r\nexport function deviceAuth(req: IncomingMessage): { deviceId: string; token: string } | null {\r\n const deviceId = req.headers[\"x-lm-device\"];\r\n const token = req.headers[\"x-lm-token\"];\r\n if (typeof deviceId !== \"string\" || typeof token !== \"string\" || !deviceId || !token) return null;\r\n return { deviceId, token };\r\n}\r\n\r\n/** Query-string parser (URLSearchParams handles plus-encoding for notes). */\r\nexport function queryOf(req: IncomingMessage): URLSearchParams {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n return url.searchParams;\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { IncomingMessage, Server as HttpServer, ServerResponse } from \"node:http\";\r\nimport { createServer } from \"node:http\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { readFile, readdir, stat, unlink } from \"node:fs/promises\";\r\nimport { join } from \"node:path\";\r\nimport type { Duplex } from \"node:stream\";\r\nimport { WebSocketServer, type WebSocket } from \"ws\";\r\nimport QRCode from \"qrcode\";\r\nimport type { DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport { ERROR_CODES } from \"../types.js\";\r\nimport type { DeliverySink } from \"../inject/deliver.js\";\r\nimport { admitImage, magicMatches, type AttachmentStoreLike } from \"../inject/admit.js\";\r\nimport type { TargetTracker } from \"../inject/target.js\";\r\nimport type { DeviceStore } from \"../store/devices.js\";\r\nimport { hashToken, mintDeviceToken, type PairingStore } from \"../store/pairing.js\";\r\nimport type { ViewHub } from \"./hub.js\";\r\nimport { buildPairingQr } from \"./qr.js\";\r\nimport { VIEW_HTML } from \"./static-view.js\";\r\nimport { clientKey, corsFor, deviceAuth, isLoopback, queryOf, RateLimiter } from \"./auth.js\";\r\n\r\nexport interface ServerDeps {\r\n config: LensConfig;\r\n pairing: PairingStore;\r\n devices: DeviceStore;\r\n hub: ViewHub;\r\n targets: TargetTracker;\r\n sink: DeliverySink;\r\n /** Read the attachment store lazily — the service may mount after us. */\r\n attachments: () => AttachmentStoreLike | undefined;\r\n /** Fallback dir for uploads when the attachment service is absent. */\r\n fallbackDir: string;\r\n /** Browser-fetchable staging dir for images awaiting user send (composer pre-send). */\r\n pendingDir: string;\r\n log: (level: \"info\" | \"warn\" | \"error\", msg: string) => void;\r\n}\r\n\r\nexport interface LensServerHandle {\r\n port: number;\r\n dispose: () => Promise<void>;\r\n}\r\n\r\n/** Boot the receiver: HTTP routes + two websocket endpoints. */\r\nexport async function startLensServer(deps: ServerDeps): Promise<LensServerHandle> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const pairLimiter = new RateLimiter(60_000, 10);\r\n const uploadLimiter = new RateLimiter(60_000, config.limits.uploadsPerMinute);\r\n let currentCameraDeviceId: string | null = null;\r\n const injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null } = { last: null };\r\n\r\n const server: HttpServer = createServer((req, res) => {\r\n handle(deps, req, res, {\r\n pairLimiter,\r\n uploadLimiter,\r\n getCurrentCamera: () => currentCameraDeviceId,\r\n noteInjection: (receipt, attachmentId) => {\r\n injectionBox.last = { at: Date.now(), sessionId: receipt.sessionId, attachmentId, ok: receipt.ok };\r\n },\r\n getLastInjection: () => injectionBox.last,\r\n }).catch((error) => {\r\n log(\"error\", `request failed: ${String(error)}`);\r\n if (!res.headersSent) sendJson(res, 500, { error: { code: ERROR_CODES.INTERNAL, message: String(error) } });\r\n else res.destroy();\r\n });\r\n });\r\n\r\n const wss = new WebSocketServer({ noServer: true, maxPayload: config.limits.previewFrameMaxBytes * 4 });\r\n server.on(\"upgrade\", (req: IncomingMessage, socket: Duplex, head: Buffer) => {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const done = (status: number, message: string) => {\r\n socket.write(`HTTP/1.1 ${status} ${message}\\r\\nConnection: close\\r\\n\\r\\n`);\r\n socket.destroy();\r\n };\r\n if (url.pathname === \"/ws/camera\") {\r\n const q = url.searchParams;\r\n const deviceId = q.get(\"deviceId\") ?? \"\";\r\n const token = q.get(\"token\") ?? \"\";\r\n const record = devices.authenticate(deviceId, token);\r\n if (!record) return done(401, \"Unauthorized\");\r\n wss.handleUpgrade(req, socket, head, (ws) => {\r\n currentCameraDeviceId = record.deviceId;\r\n hub.attachCamera(record.deviceId, ws, record.name);\r\n });\r\n return;\r\n }\r\n if (url.pathname === \"/ws/view\") {\r\n if (!isLoopback(req)) return done(403, \"Forbidden\");\r\n wss.handleUpgrade(req, socket, head, (ws) => hub.attachView(ws as WebSocket, {\r\n onRefreshPairing: () => pairing.refresh(),\r\n onRenameDevice: (deviceId, name) => {\r\n const updated = devices.rename(deviceId, name);\r\n if (updated) hub.renameDevice(deviceId, updated.name);\r\n },\r\n }));\r\n return;\r\n }\r\n done(404, \"Not Found\");\r\n });\r\n\r\n const heartbeat = setInterval(() => hub.pingAll(), 20_000);\r\n\r\n await new Promise<void>((resolve, reject) => {\r\n server.once(\"error\", reject);\r\n server.listen(config.server.port, config.server.host, () => resolve());\r\n });\r\n log(\"info\", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);\r\n\r\n return {\r\n port: config.server.port,\r\n dispose: async () => {\r\n clearInterval(heartbeat);\r\n hub.detachAll();\r\n for (const client of wss.clients) client.terminate();\r\n await new Promise<void>((resolve) => server.close(() => resolve()));\r\n },\r\n };\r\n}\r\n\r\ntype RouteCtx = {\r\n pairLimiter: RateLimiter;\r\n uploadLimiter: RateLimiter;\r\n getCurrentCamera: () => string | null;\r\n noteInjection: (receipt: DeliveryReceipt, attachmentId: string) => void;\r\n getLastInjection: () => { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null;\r\n};\r\n\r\nasync function handle(deps: ServerDeps, req: IncomingMessage, res: ServerResponse, ctx: RouteCtx): Promise<void> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const path = url.pathname.replace(/\\/+$/, \"\") || \"/\";\r\n const method = req.method ?? \"GET\";\r\n const loop = isLoopback(req);\r\n const cors = corsFor(req);\r\n\r\n // Preflight from the Web UI page (another loopback origin).\r\n if (method === \"OPTIONS\") {\r\n res.writeHead(204, cors);\r\n res.end();\r\n return;\r\n }\r\n\r\n // ── open endpoints ────────────────────────────────────────────────────────\r\n if (method === \"GET\" && path === \"/info\") {\r\n return sendJson(res, 200, { name: \"PhoneLens 直连取景\", version: \"0.3.5\", requiresPairing: true }, cors);\r\n }\r\n\r\n // ── loopback-only endpoints (preview page, QR, view stream) ──────────────\r\n if (!loop && (path === \"/\" || path === \"/view.html\" || path === \"/qr.json\" || path === \"/qr.png\" || path === \"/app-qr.json\")) {\r\n return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, \"preview surface is loopback-only\");\r\n }\r\n if (method === \"GET\" && (path === \"/\" || path === \"/view.html\")) {\r\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\", \"cache-control\": \"no-store\" });\r\n res.end(VIEW_HTML);\r\n return;\r\n }\r\n if (method === \"GET\" && path === \"/qr.json\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n return sendJson(res, 200, { code: qr.code, expiresAt: qr.expiresAt, payload: qr.payload, urls: qr.urls, pngDataUrl: qr.pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n if (method === \"GET\" && path === \"/qr.png\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n const b64 = qr.pngDataUrl.slice(qr.pngDataUrl.indexOf(\",\") + 1);\r\n res.writeHead(200, { \"content-type\": \"image/png\", \"cache-control\": \"no-store\" });\r\n res.end(Buffer.from(b64, \"base64\"));\r\n return;\r\n }\r\n // App-download QR: encodes the APK URL (Gitee by default) so a phone can\r\n // scan it straight from the Web UI overlay and start the download.\r\n if (method === \"GET\" && path === \"/app-qr.json\") {\r\n const target = config.app.downloadUrl || config.app.giteeUrl;\r\n const pngDataUrl = await QRCode.toDataURL(target, { errorCorrectionLevel: \"M\", margin: 2, width: 320 });\r\n return sendJson(res, 200, { url: target, gitee: config.app.giteeUrl, github: config.app.githubUrl, pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n\r\n // ── pairing ──────────────────────────────────────────────────────────────\r\n // Rate limit only FAILED attempts (anti-brute-force); a legitimate new\r\n // device entering a valid code is never throttled, so switching phones\r\n // \"just works\" instead of hitting \"too many pairing attempts\".\r\n if (method === \"POST\" && path === \"/pair\") {\r\n const body = await readJsonBody(req, 4096);\r\n const code = str(body?.code);\r\n const device = (body?.device ?? {}) as Record<string, unknown>;\r\n const deviceId = str(device.id);\r\n const name = str(device.name) ?? \"unnamed device\";\r\n const model = str(device.model) ?? \"\";\r\n if (!code || !deviceId) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"code and device.id are required\");\r\n const verdict = pairing.verify(code);\r\n if (!verdict.ok) {\r\n if (!ctx.pairLimiter.allow(clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"too many pairing attempts\");\r\n return sendError(res, 401, verdict.reason === \"expired\" ? ERROR_CODES.PAIR_CODE_EXPIRED : ERROR_CODES.PAIR_CODE_INVALID, `pairing code ${verdict.reason}`);\r\n }\r\n const token = mintDeviceToken();\r\n devices.upsert({ deviceId, name, model, tokenHash: hashToken(token) });\r\n log(\"info\", `paired device \"${name}\" (${deviceId.slice(0, 8)})`);\r\n return sendJson(res, 200, {\r\n token,\r\n serverInfo: { preview: config.preview, limits: config.limits },\r\n });\r\n }\r\n\r\n // ── authenticated endpoints: loopback or paired device ───────────────────\r\n const auth = loop ? { ok: true } : deviceAuth(req) && devices.authenticate(deviceAuth(req)!.deviceId, deviceAuth(req)!.token) ? { ok: true } : { ok: false };\r\n if (!auth.ok) return sendError(res, 401, ERROR_CODES.AUTH_REQUIRED, \"pair this device first\");\r\n\r\n if (method === \"POST\" && path === \"/upload\") {\r\n const q = queryOf(req);\r\n if (!ctx.uploadLimiter.allow(loop ? \"loopback\" : clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"upload rate exceeded\");\r\n const mediaType = (req.headers[\"content-type\"] ?? \"\").split(\";\")[0]!.trim();\r\n if (!config.limits.allowedTypes.includes(mediaType)) {\r\n return sendError(res, 415, ERROR_CODES.TYPE_NOT_ALLOWED, `allowed: ${config.limits.allowedTypes.join(\", \")}`);\r\n }\r\n const declared = Number(req.headers[\"content-length\"] ?? \"0\");\r\n if (declared > config.limits.maxUploadBytes) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n const { buf, truncated } = await readRawBody(req, config.limits.maxUploadBytes);\r\n if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, \"bytes do not match the declared type\");\r\n\r\n const captureId = q.get(\"captureId\");\r\n const note = q.get(\"note\") ?? (captureId ? hub.noteFor(captureId) : undefined);\r\n if (captureId) hub.consumeCapture(captureId);\r\n const name = (q.get(\"name\") ?? `shot_${new Date().toISOString().replace(/[:.]/g, \"-\")}.${mediaType === \"image/png\" ? \"png\" : \"jpg\"}`).slice(0, 120);\r\n\r\n let admitted;\r\n try {\r\n admitted = await admitImage({ data: buf, mediaType, name }, deps.attachments(), deps.fallbackDir);\r\n } catch (error) {\r\n log(\"error\", `admit failed: ${String(error)}`);\r\n return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));\r\n }\r\n log(\"info\", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);\r\n\r\n // keep the local archive bounded (retention: maxStoredUploads oldest-first)\r\n if (admitted.storage === \"file\") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log(\"warn\", `upload pruning failed: ${String(error)}`));\r\n\r\n // Phase 2 (rev): PRE-SEND semantics. The phone's photo is staged for the\r\n // dsh composer, not injected straight into the model — the browser client\r\n // fetches this staging copy and drops it into the composer draft; the user\r\n // types text and hits send. So no agent.followup here.\r\n const pendingPath = join(deps.pendingDir, `${admitted.ref.attachmentId}.jpg`);\r\n try {\r\n mkdirSync(deps.pendingDir, { recursive: true });\r\n writeFileSync(pendingPath, buf);\r\n log(\"info\", `staged for composer: ${pendingPath}`);\r\n } catch (error) {\r\n log(\"warn\", `staging failed: ${String(error)}`);\r\n }\r\n hub.broadcastToViews({ type: \"pending_image\", attachmentId: admitted.ref.attachmentId, name });\r\n // no direct delivery; the client owns placing it in the composer\r\n\r\n return sendJson(res, 200, {\r\n ok: true,\r\n attachmentId: admitted.ref.attachmentId,\r\n width: admitted.ref.width,\r\n height: admitted.ref.height,\r\n bytes: admitted.ref.bytes,\r\n storage: admitted.storage,\r\n delivered: null,\r\n deliverReason: \"staged-in-composer\",\r\n });\r\n }\r\n\r\n // Browser client fetches the staged photo to place into the composer draft.\r\n if (method === \"GET\" && path.startsWith(\"/pending/\") && loop) {\r\n const id = path.slice(\"/pending/\".length).split(\"/\")[0] ?? \"\";\r\n const file = join(deps.pendingDir, `${id}.jpg`);\r\n try {\r\n const data = await readFile(file);\r\n res.writeHead(200, { \"content-type\": \"image/jpeg\", \"cache-control\": \"no-store\", ...cors });\r\n res.end(data);\r\n return;\r\n } catch {\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\r\n }\r\n }\r\n\r\n if (method === \"GET\" && path === \"/status\") {\r\n const cameraDeviceId = ctx.getCurrentCamera();\r\n return sendJson(res, 200, {\r\n devices: devices.list().map((d) => ({ id: d.deviceId, name: d.name, model: d.model, online: d.deviceId === cameraDeviceId, streaming: d.deviceId === cameraDeviceId, lastSeenAt: d.lastSeenAt })),\r\n camera: hub.stats(),\r\n preview: config.preview,\r\n target: { mode: targets.mode(), sessionId: targets.resolve()?.sessionId ?? null },\r\n lastInjection: ctx.getLastInjection(),\r\n }, cors);\r\n }\r\n\r\n if (method === \"GET\" && path === \"/targets\") {\r\n return sendJson(res, 200, { targets: targets.list(), default: targets.resolve()?.sessionId ?? null }, cors);\r\n }\r\n\r\n // View-side control also arrives over /ws/view; a loopback HTTP trigger is\r\n // handy for smoke tests.\r\n if (method === \"POST\" && path === \"/capture\" && loop) {\r\n const captureId = randomUUID();\r\n const ok = hub.requestCapture(captureId, queryOf(req).get(\"note\") ?? undefined);\r\n if (!ok) return sendError(res, 409, ERROR_CODES.NO_CAMERA, \"no camera uplink connected\");\r\n return sendJson(res, 202, { captureId });\r\n }\r\n\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);\r\n}\r\n\r\n// ── helpers ─────────────────────────────────────────────────────────────────\r\n\r\nfunction sendJson(res: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {\r\n res.writeHead(status, { \"content-type\": \"application/json; charset=utf-8\", ...(extraHeaders ?? {}) });\r\n res.end(JSON.stringify(body));\r\n}\r\n\r\nfunction sendError(res: ServerResponse, status: number, code: string, message: string): void {\r\n sendJson(res, status, { error: { code, message } });\r\n}\r\n\r\nfunction str(v: unknown): string | undefined {\r\n return typeof v === \"string\" ? v : undefined;\r\n}\r\n\r\nasync function readJsonBody(req: IncomingMessage, limit: number): Promise<Record<string, unknown> | null> {\r\n const { buf } = await readRawBody(req, limit);\r\n try {\r\n return JSON.parse(buf.toString(\"utf8\")) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction readRawBody(req: IncomingMessage, maxBytes: number): Promise<{ buf: Buffer; truncated: boolean }> {\r\n return new Promise((resolve, reject) => {\r\n const chunks: Buffer[] = [];\r\n let size = 0;\r\n let truncated = false;\r\n req.on(\"data\", (chunk: Buffer) => {\r\n size += chunk.byteLength;\r\n if (size > maxBytes) {\r\n truncated = true;\r\n chunks.length = 0;\r\n req.destroy();\r\n resolve({ buf: Buffer.alloc(0), truncated });\r\n return;\r\n }\r\n chunks.push(chunk);\r\n });\r\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), truncated }));\r\n req.on(\"error\", reject);\r\n });\r\n}\r\n\r\n/** Delete the oldest files beyond `max` in a directory (mtime order). */\r\nasync function pruneUploads(dir: string, max: number): Promise<void> {\r\n if (max <= 0) return;\r\n const names = await readdir(dir);\r\n if (names.length <= max) return;\r\n const entries = await Promise.all(\r\n names.map(async (name) => {\r\n let mtime = 0;\r\n try {\r\n mtime = (await stat(join(dir, name))).mtimeMs;\r\n } catch {\r\n mtime = 0;\r\n }\r\n return { name, mtime };\r\n }),\r\n );\r\n entries.sort((a, b) => b.mtime - a.mtime);\r\n for (const entry of entries.slice(max)) {\r\n await unlink(join(dir, entry.name)).catch(() => {});\r\n }\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { WebSocket } from \"ws\";\r\nimport type { CameraControl, LensConfig, ViewClientMessage, ViewServerMessage } from \"../types.js\";\r\n\r\n/** Per-device camera uplink state; multiple phones coexist without kicking. */\r\ninterface CamState {\r\n ws: WebSocket;\r\n name: string;\r\n meta: { width?: number; height?: number; fps?: number; rotation?: number };\r\n lastFrame: Buffer | null;\r\n lastFrameAt: number;\r\n frameCount: number;\r\n windowStart: number;\r\n measuredFps: number;\r\n}\r\n\r\n/**\r\n * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by\r\n * deviceId), N loopback view downlinks. The \"active\" device is auto-selected\r\n * as the last one to send a frame; the view side can switch it via\r\n * `select_device`. Only the active device's frames are fanned out — a second\r\n * phone connecting no longer kicks the first, so several paired phones coexist\r\n * and the user picks which one to watch / shoot from.\r\n */\r\nexport class ViewHub {\r\n private cameras = new Map<string, CamState>();\r\n private activeDeviceId: string | null = null;\r\n private views = new Set<WebSocket>();\r\n /** captureId → { note, requestedAt } until the matching upload lands or timeout. */\r\n private pendingCaptures = new Map<string, { note?: string; requestedAt: number }>();\r\n private readonly captureTimeoutMs = 60_000;\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {}\r\n\r\n // ── camera side ───────────────────────────────────────────────────────────\r\n\r\n attachCamera(deviceId: string, ws: WebSocket, name: string): void {\r\n const prev = this.cameras.get(deviceId);\r\n if (prev) {\r\n try {\r\n prev.ws.close(1000, \"new-instance\");\r\n } catch {}\r\n this.cameras.delete(deviceId);\r\n }\r\n const cam: CamState = { ws, name, meta: {}, lastFrame: null, lastFrameAt: 0, frameCount: 0, windowStart: Date.now(), measuredFps: 0 };\r\n this.cameras.set(deviceId, cam);\r\n if (this.activeDeviceId === null) this.activeDeviceId = deviceId;\r\n // only the ACTIVE device streams to the PC; others pause immediately\r\n if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: \"resume_preview\" });\r\n else this.sendControl(deviceId, { type: \"pause_preview\" });\r\n this.log(\"info\", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);\r\n\r\n ws.on(\"close\", () => {\r\n if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) {\r\n this.ingestFrame(deviceId, data as Buffer);\r\n return;\r\n }\r\n this.onCameraControl(deviceId, safeJson(data.toString()));\r\n });\r\n // greet the new device to views\r\n this.broadcastDevices();\r\n }\r\n\r\n detachCamera(deviceId: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) this.cameras.delete(deviceId);\r\n if (this.activeDeviceId === deviceId) {\r\n const next = [...this.cameras.keys()].at(-1) ?? null;\r\n this.activeDeviceId = next;\r\n if (next) this.sendControl(next, { type: \"resume_preview\" });\r\n this.pushActiveFrameToViews();\r\n }\r\n this.broadcastDevices();\r\n }\r\n\r\n detachAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n try {\r\n cam.ws.close(1000, \"server-dispose\");\r\n } catch {}\r\n }\r\n this.cameras.clear();\r\n this.activeDeviceId = null;\r\n this.broadcastDevices();\r\n }\r\n\r\n pingAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();\r\n }\r\n }\r\n\r\n private onCameraControl(deviceId: string, msg: CameraControl | null): void {\r\n if (!msg) return;\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam) return;\r\n switch (msg.type) {\r\n case \"hello\":\r\n cam.meta = { width: msg.width, height: msg.height, fps: msg.fps, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) };\r\n if (this.activeDeviceId === deviceId) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: msg.width, height: msg.height, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) });\r\n }\r\n break;\r\n case \"bye\":\r\n this.detachCamera(deviceId);\r\n break;\r\n case \"claim_active\":\r\n // phone asked to become the active device → switch the view to it.\r\n this.selectDevice(deviceId);\r\n break;\r\n case \"capture_result\":\r\n if (msg.status !== \"taken\") {\r\n this.pendingCaptures.delete(msg.captureId);\r\n this.broadcastToViews({ type: \"error\", code: \"CAPTURE_DECLINED\", message: `phone reported ${msg.status}${msg.detail ? `: ${msg.detail}` : \"\"}` });\r\n }\r\n break;\r\n default:\r\n break;\r\n }\r\n }\r\n\r\n private ingestFrame(deviceId: string, frame: Buffer): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam || frame.byteLength === 0) return;\r\n if (frame.byteLength > this.config.limits.previewFrameMaxBytes) {\r\n this.log(\"warn\", `dropping oversized preview frame from ${deviceId.slice(0, 8)} (${frame.byteLength}B > ${this.config.limits.previewFrameMaxBytes}B)`);\r\n return;\r\n }\r\n const now = Date.now();\r\n cam.lastFrame = frame;\r\n cam.lastFrameAt = now;\r\n cam.frameCount++;\r\n if (now - cam.windowStart >= 2000) {\r\n cam.measuredFps = (cam.frameCount * 1000) / (now - cam.windowStart);\r\n cam.windowStart = now;\r\n cam.frameCount = 0;\r\n }\r\n // only the ACTIVE device drives the view; others just keep their own state\r\n if (this.activeDeviceId === deviceId) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(frame, { binary: true });\r\n }\r\n }\r\n }\r\n\r\n // ── view side ─────────────────────────────────────────────────────────────\r\n\r\n attachView(ws: WebSocket, hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void } = {}): void {\r\n this.views.add(ws);\r\n ws.on(\"close\", () => this.views.delete(ws));\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) return;\r\n const msg = parseViewClient(data.toString());\r\n if (!msg) return;\r\n if (msg.type === \"capture\") {\r\n const captureId = randomUUID();\r\n if (!this.requestCapture(captureId, msg.note)) {\r\n this.broadcastToViews({ type: \"error\", code: \"NO_CAMERA\", message: \"no camera uplink connected\" });\r\n }\r\n } else if (msg.type === \"select_device\") {\r\n this.selectDevice(msg.deviceId);\r\n } else if (msg.type === \"rename_device\") {\r\n hooks.onRenameDevice?.(msg.deviceId, msg.name);\r\n } else if (msg.type === \"refresh_pairing\") {\r\n hooks.onRefreshPairing?.();\r\n }\r\n });\r\n const active = this.activeCam();\r\n ws.send(\r\n JSON.stringify({\r\n type: \"meta\",\r\n camera: active ? { connected: true, name: active.name, ...active.meta } : { connected: false },\r\n preview: this.config.preview,\r\n paired: true,\r\n } satisfies ViewServerMessage),\r\n );\r\n this.broadcastDevicesTo(ws);\r\n if (active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });\r\n }\r\n\r\n viewCount(): number {\r\n return this.views.size;\r\n }\r\n\r\n /** Update one device's display name and re-broadcast the device list. */\r\n renameDevice(deviceId: string, name: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) cam.name = name;\r\n this.broadcastDevices();\r\n }\r\n\r\n /** Switch which phone's frames / shutter the view follows. */\r\n selectDevice(deviceId: string): void {\r\n if (!this.cameras.has(deviceId)) return;\r\n const prevActive = this.activeDeviceId;\r\n if (prevActive === deviceId) return;\r\n this.activeDeviceId = deviceId;\r\n if (prevActive) this.sendControl(prevActive, { type: \"pause_preview\" });\r\n this.sendControl(deviceId, { type: \"resume_preview\" });\r\n this.broadcastDevices();\r\n this.pushActiveFrameToViews();\r\n }\r\n\r\n /** Send one control message to a specific phone uplink. */\r\n private sendControl(deviceId: string, msg: CameraControl): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam && cam.ws.readyState === cam.ws.OPEN) cam.ws.send(JSON.stringify(msg));\r\n }\r\n\r\n private pushActiveFrameToViews(): void {\r\n const active = this.activeCam();\r\n if (active?.lastFrame) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });\r\n }\r\n }\r\n // re-announce frame size/rotation for the newly selected device\r\n if (active) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: active.meta.width ?? 0, height: active.meta.height ?? 0, ...(active.meta.rotation !== void 0 ? { rotation: active.meta.rotation } : {}) });\r\n }\r\n }\r\n\r\n private activeCam(): CamState | undefined {\r\n return this.activeDeviceId ? this.cameras.get(this.activeDeviceId) : undefined;\r\n }\r\n\r\n private broadcastDevices(): void {\r\n for (const view of this.views) this.broadcastDevicesTo(view);\r\n }\r\n\r\n private broadcastDevicesTo(view: WebSocket): void {\r\n if (view.readyState !== view.OPEN) return;\r\n view.send(\r\n JSON.stringify({\r\n type: \"devices\",\r\n devices: [...this.cameras.values()].map((c, i) => {\r\n const id = [...this.cameras.keys()][i]!;\r\n return { id, name: c.name, active: id === this.activeDeviceId };\r\n }),\r\n } satisfies ViewServerMessage),\r\n );\r\n }\r\n\r\n // ── capture correlation ───────────────────────────────────────────────────\r\n\r\n /** Ask the ACTIVE phone to shoot. Returns the captureId, or null when none. */\r\n requestCapture(captureId: string, note?: string): string | null {\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return null;\r\n this.pendingCaptures.set(captureId, { note, requestedAt: Date.now() });\r\n active.ws.send(JSON.stringify({ type: \"capture\", captureId, ...(note ? { note } : {}) } satisfies CameraControl));\r\n this.broadcastToViews({ type: \"capture_pending\", captureId, ...(note ? { note } : {}) });\r\n this.gcCaptures();\r\n return captureId;\r\n }\r\n\r\n consumeCapture(captureId: string): { note?: string } | null {\r\n const pending = this.pendingCaptures.get(captureId);\r\n if (!pending) return null;\r\n this.pendingCaptures.delete(captureId);\r\n return { note: pending.note };\r\n }\r\n\r\n noteFor(captureId: string): string | undefined {\r\n return this.pendingCaptures.get(captureId)?.note;\r\n }\r\n\r\n private gcCaptures(): void {\r\n const cutoff = Date.now() - this.captureTimeoutMs;\r\n for (const [id, p] of this.pendingCaptures) {\r\n if (p.requestedAt < cutoff) this.pendingCaptures.delete(id);\r\n }\r\n }\r\n\r\n broadcastToViews(msg: ViewServerMessage): void {\r\n const text = JSON.stringify(msg);\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(text);\r\n }\r\n }\r\n\r\n stats(): { connected: boolean; fps: number; lastFrameAt: number; views: number; devices: number } {\r\n const active = this.activeCam();\r\n return {\r\n connected: this.cameras.size > 0,\r\n fps: active ? Math.round(active.measuredFps * 10) / 10 : 0,\r\n lastFrameAt: active?.lastFrameAt ?? 0,\r\n views: this.views.size,\r\n devices: this.cameras.size,\r\n };\r\n }\r\n}\r\n\r\nfunction safeJson(text: string): CameraControl | null {\r\n try {\r\n return JSON.parse(text) as CameraControl;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction parseViewClient(text: string): ViewClientMessage | null {\r\n try {\r\n return JSON.parse(text) as ViewClientMessage;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n","import { join } from \"node:path\";\r\nimport { homedir } from \"node:os\";\r\nimport { env } from \"node:process\";\r\n\r\n/** Where pairing records + fallback uploads live (mirrors dsh-home conventions). */\r\nexport function lensDataDir(): string {\r\n const home = env.DSH_HOME ?? join(homedir(), \".dsh\");\r\n return join(home, \"phone-lens\");\r\n}\r\n","import { join } from \"node:path\";\r\nimport { Service } from \"@deepseek-ai/cordis\";\r\nimport z from \"@deepseek-ai/schemastery\";\r\nimport { normalizeConfig } from \"./config.js\";\r\nimport { HostDeliverySink, type EventedAgent } from \"./inject/host-sink.js\";\r\nimport { TargetTracker } from \"./inject/target.js\";\r\nimport { DeviceStore } from \"./store/devices.js\";\r\nimport { PairingStore } from \"./store/pairing.js\";\r\nimport { startLensServer, type LensServerHandle } from \"./server/http.js\";\r\nimport { ViewHub } from \"./server/hub.js\";\r\nimport { buildPairingQr } from \"./server/qr.js\";\r\nimport type { AttachmentStoreLike } from \"./inject/admit.js\";\r\nimport { lensDataDir } from \"./paths.js\";\r\n\r\ntype LogLevel = \"info\" | \"warn\" | \"error\";\r\n\r\n/**\r\n * phone-lens host plugin.\r\n *\r\n * Boots the receiver inside the dsh process so uploads can flow straight\r\n * into `ctx.attachments` (and, from Phase 2 on, into agent inboxes) with no\r\n * extra IPC. Everything registered here unwinds with the fiber.\r\n */\r\nexport default class PhoneLens extends Service {\r\n static inject: string[] = [];\r\n static Config = z.any();\r\n\r\n constructor(ctx: any, rawConfig: unknown) {\r\n super(ctx, \"phoneLens\");\r\n const config = normalizeConfig(rawConfig);\r\n const dataDir = lensDataDir();\r\n const log = (level: LogLevel, msg: string) => {\r\n const target = (ctx.logger as Record<LogLevel, (m: string) => void> | undefined) ?? console;\r\n target[level]?.(`[phone-lens] ${msg}`);\r\n };\r\n\r\n const pairing = new PairingStore(config.pairing.codeTtlMs);\r\n const devices = new DeviceStore(dataDir);\r\n const hub = new ViewHub(config, (level, msg) => log(level, msg));\r\n const targets = new TargetTracker(config);\r\n // Phase 2: real delivery into a live dsh session; the LoggingSink remains\r\n // the standalone/dev fallback. Agent events keep the sink's active target.\r\n const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));\r\n const attachments = (): AttachmentStoreLike | undefined => ctx.get?.(\"attachments\") as AttachmentStoreLike | undefined;\r\n\r\n // agent wiring (scope-filtered events fire on the root ctx for global\r\n // listeners): the sink tracks the last-active session for delivery.\r\n const onAgent = (agent: EventedAgent) => sink.track(agent);\r\n const offAgent = (agent: EventedAgent) => sink.untrack(agent);\r\n ctx.on?.(\"agent/created\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/inbox/inserted\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/status\", (payload: { agent: EventedAgent; status: string }) => {\r\n if (payload.status === \"running\") onAgent(payload.agent);\r\n });\r\n ctx.on?.(\"agent/disposed\", (payload: { agent: EventedAgent }) => offAgent(payload.agent));\r\n\r\n let handle: LensServerHandle | null = null;\r\n startLensServer({\r\n config,\r\n pairing,\r\n devices,\r\n hub,\r\n targets,\r\n sink,\r\n attachments,\r\n fallbackDir: join(dataDir, \"uploads\"),\r\n pendingDir: join(dataDir, \"pending\"),\r\n log,\r\n })\r\n .then(async (h) => {\r\n handle = h;\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n // ASCII QR goes to the real stdout; prefixing it through the logger\r\n // would mangle the block characters.\r\n process.stdout.write(`\\n[phone-lens] 手机扫码配对(或浏览器打开 http://127.0.0.1:${h.port}/view.html):\\n${qr.ascii}\\n[phone-lens] 备用地址: ${qr.urls.join(\" \")}\\n\\n`);\r\n })\r\n .catch((error: unknown) => {\r\n log(\"error\", `receiver failed to start: ${String(error)} (check port ${config.server.port})`);\r\n });\r\n\r\n ctx.effect(() => () => {\r\n void handle?.dispose();\r\n handle = null;\r\n }, \"phone-lens.server()\");\r\n }\r\n}\r\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "phone-lens",
3
3
  "description": "Phone camera → dsh session: pairing, MJPEG viewfinder, upload-to-attachment pipeline",
4
- "version": "0.3.5",
4
+ "version": "0.3.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",