Skip to content

Commit 7a173ff

Browse files
committed
fix: route vision commands through desktop camera bridge
1 parent d1d95fe commit 7a173ff

6 files changed

Lines changed: 259 additions & 33 deletions

File tree

desktop/main.js

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1-
const { app, BrowserWindow, ipcMain, shell, systemPreferences } = require("electron");
1+
const { app, BrowserWindow, ipcMain, shell, systemPreferences, session } = require("electron");
22
const path = require("path");
33
const fs = require("fs");
44
const os = require("os");
5+
const crypto = require("crypto");
6+
const http = require("http");
57
const childProcess = require("child_process");
68
const pty = require("node-pty");
79

810
const isDev = !app.isPackaged;
911
const repoRoot = path.resolve(__dirname, "..");
1012
const activeSessions = new Map();
13+
const cameraBridge = {
14+
server: null,
15+
url: "",
16+
token: crypto.randomBytes(24).toString("hex"),
17+
latestFrame: "",
18+
updatedAt: 0,
19+
status: "starting",
20+
error: ""
21+
};
1122

1223
function corePath() {
1324
if (isDev) return path.join(repoRoot, process.platform === "win32" ? "yukishell.exe" : "yukishell");
@@ -158,6 +169,67 @@ function createWindow() {
158169
}
159170
}
160171

172+
function sendJson(res, statusCode, payload) {
173+
const body = JSON.stringify(payload);
174+
res.writeHead(statusCode, {
175+
"Content-Type": "application/json",
176+
"Content-Length": Buffer.byteLength(body),
177+
"Cache-Control": "no-store"
178+
});
179+
res.end(body);
180+
}
181+
182+
function startCameraBridge() {
183+
if (cameraBridge.server) return Promise.resolve();
184+
185+
return new Promise((resolve) => {
186+
cameraBridge.server = http.createServer((req, res) => {
187+
const requestUrl = new URL(req.url, "http://127.0.0.1");
188+
189+
if (requestUrl.searchParams.get("token") !== cameraBridge.token) {
190+
sendJson(res, 403, { ok: false, error: "Camera bridge token rejected." });
191+
return;
192+
}
193+
194+
if (requestUrl.pathname === "/health") {
195+
sendJson(res, 200, {
196+
ok: cameraBridge.status === "ready",
197+
status: cameraBridge.status,
198+
error: cameraBridge.error,
199+
updatedAt: cameraBridge.updatedAt
200+
});
201+
return;
202+
}
203+
204+
if (requestUrl.pathname === "/frame") {
205+
if (!cameraBridge.latestFrame) {
206+
sendJson(res, 503, {
207+
ok: false,
208+
status: cameraBridge.status,
209+
error: cameraBridge.error || "Camera frame is not ready yet."
210+
});
211+
return;
212+
}
213+
214+
sendJson(res, 200, {
215+
ok: true,
216+
data: cameraBridge.latestFrame,
217+
updatedAt: cameraBridge.updatedAt
218+
});
219+
return;
220+
}
221+
222+
sendJson(res, 404, { ok: false, error: "Unknown camera bridge endpoint." });
223+
});
224+
225+
cameraBridge.server.listen(0, "127.0.0.1", () => {
226+
const address = cameraBridge.server.address();
227+
cameraBridge.url = `http://127.0.0.1:${address.port}/frame?token=${cameraBridge.token}`;
228+
resolve();
229+
});
230+
});
231+
}
232+
161233
function requestMediaAccess(kind) {
162234
if (process.platform !== "darwin") return;
163235

@@ -171,7 +243,11 @@ function requestMediaAccess(kind) {
171243
}
172244
}
173245

174-
app.whenReady().then(() => {
246+
app.whenReady().then(async () => {
247+
await startCameraBridge();
248+
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
249+
callback(permission === "media");
250+
});
175251
requestMediaAccess("camera");
176252
requestMediaAccess("microphone");
177253
createWindow();
@@ -190,6 +266,30 @@ app.on("window-all-closed", () => {
190266
if (process.platform !== "darwin") app.quit();
191267
});
192268

269+
app.on("before-quit", () => {
270+
if (cameraBridge.server) {
271+
cameraBridge.server.close();
272+
cameraBridge.server = null;
273+
}
274+
});
275+
276+
ipcMain.on("camera:frame", (_event, { data }) => {
277+
if (typeof data !== "string" || data.length === 0) return;
278+
cameraBridge.latestFrame = data;
279+
cameraBridge.updatedAt = Date.now();
280+
cameraBridge.status = "ready";
281+
cameraBridge.error = "";
282+
});
283+
284+
ipcMain.on("camera:status", (_event, payload = {}) => {
285+
cameraBridge.status = payload.status || "unknown";
286+
cameraBridge.error = payload.error || "";
287+
if (cameraBridge.status !== "ready") {
288+
cameraBridge.latestFrame = "";
289+
cameraBridge.updatedAt = 0;
290+
}
291+
});
292+
193293
ipcMain.handle("terminal:create", (event, options = {}) => {
194294
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
195295
const backend = resolveBackend();
@@ -211,6 +311,7 @@ ipcMain.handle("terminal:create", (event, options = {}) => {
211311
YUKISHELL_ENV_FILE: envFilePath(),
212312
YUKISHELL_PYTHON_BIN: pythonBinaryPath(),
213313
YUKISHELL_PYTHONPATH: bundledPythonPath(),
314+
YUKISHELL_CAMERA_BRIDGE_URL: cameraBridge.url,
214315
OPENCV_AVFOUNDATION_SKIP_AUTH: "0",
215316
PYTHONDONTWRITEBYTECODE: "1",
216317
PYTHONPATH: [bundledPythonPath(), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
@@ -261,6 +362,7 @@ ipcMain.handle("app:paths", () => ({
261362
pythonBin: pythonBinaryPath(),
262363
pythonBundleExists: fs.existsSync(bundledPythonPath()),
263364
envFileExists: fs.existsSync(envFilePath()),
365+
cameraBridgeReady: Boolean(cameraBridge.url),
264366
platform: process.platform,
265367
arch: process.arch
266368
}));

desktop/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "yukishell-desktop",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"private": true,
55
"description": "A cross-platform desktop terminal for YukiShell.",
66
"main": "main.js",

desktop/preload.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("yuki", {
55
writeTerminal: (id, data) => ipcRenderer.send("terminal:input", { id, data }),
66
resizeTerminal: (id, cols, rows) => ipcRenderer.send("terminal:resize", { id, cols, rows }),
77
killTerminal: (id) => ipcRenderer.send("terminal:kill", { id }),
8+
updateCameraFrame: (data) => ipcRenderer.send("camera:frame", { data }),
9+
updateCameraStatus: (status) => ipcRenderer.send("camera:status", status),
810
getPaths: () => ipcRenderer.invoke("app:paths"),
911
openExternal: (url) => ipcRenderer.invoke("app:openExternal", url),
1012
onTerminalData: (callback) => {

desktop/src/renderer.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,55 @@ pasteButton.addEventListener("click", async () => {
112112
term.focus();
113113
});
114114

115+
async function startCameraBridge() {
116+
if (!navigator.mediaDevices?.getUserMedia) {
117+
window.yuki.updateCameraStatus({
118+
status: "unavailable",
119+
error: "Camera API is not available in this Electron view."
120+
});
121+
return;
122+
}
123+
124+
try {
125+
const stream = await navigator.mediaDevices.getUserMedia({
126+
video: {
127+
width: { ideal: 1280 },
128+
height: { ideal: 720 },
129+
facingMode: "environment"
130+
},
131+
audio: false
132+
});
133+
134+
const video = document.createElement("video");
135+
video.muted = true;
136+
video.playsInline = true;
137+
video.srcObject = stream;
138+
await video.play();
139+
140+
const canvas = document.createElement("canvas");
141+
const context = canvas.getContext("2d", { alpha: false });
142+
143+
const captureFrame = () => {
144+
if (!video.videoWidth || !video.videoHeight) return;
145+
canvas.width = video.videoWidth;
146+
canvas.height = video.videoHeight;
147+
context.drawImage(video, 0, 0, canvas.width, canvas.height);
148+
const image = canvas.toDataURL("image/jpeg", 0.82).replace(/^data:image\/jpeg;base64,/, "");
149+
window.yuki.updateCameraFrame(image);
150+
};
151+
152+
captureFrame();
153+
window.setInterval(captureFrame, 700);
154+
} catch (error) {
155+
window.yuki.updateCameraStatus({
156+
status: "error",
157+
error: error?.message || "Camera permission was denied."
158+
});
159+
}
160+
}
161+
115162
requestAnimationFrame(() => {
116163
fitAndResize();
117164
startTerminal();
165+
startCameraBridge();
118166
});

0 commit comments

Comments
 (0)