mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
parent
253d0ae3e7
commit
19f80eaf5c
11 changed files with 263 additions and 90 deletions
|
|
@ -292,14 +292,24 @@ final class SystemPermissionManager {
|
||||||
// If CGPreflightScreenCaptureAccess returns false, we need to verify
|
// If CGPreflightScreenCaptureAccess returns false, we need to verify
|
||||||
// because it might be a false negative on some macOS versions
|
// because it might be a false negative on some macOS versions
|
||||||
// Try SCShareableContent with a very short timeout to avoid hanging
|
// Try SCShareableContent with a very short timeout to avoid hanging
|
||||||
|
logger.info("CGPreflightScreenCaptureAccess returned false, checking with SCShareableContent...")
|
||||||
do {
|
do {
|
||||||
_ = try await SCShareableContent.current
|
_ = try await SCShareableContent.current
|
||||||
|
|
||||||
logger.debug("Screen recording permission confirmed via SCShareableContent")
|
logger.info("✅ Screen recording permission confirmed via SCShareableContent")
|
||||||
screenRecordingPermissionCache = (granted: true, timestamp: Date())
|
screenRecordingPermissionCache = (granted: true, timestamp: Date())
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug("Screen recording permission not granted or check timed out: \(error)")
|
logger.error("❌ Screen recording permission check failed: \(error.localizedDescription)")
|
||||||
|
logger.error("Error type: \(type(of: error)), error: \(error)")
|
||||||
|
|
||||||
|
// Don't cache false result if it's a timeout or other non-permission error
|
||||||
|
let errorString = String(describing: error).lowercased()
|
||||||
|
if errorString.contains("timeout") || errorString.contains("cancelled") {
|
||||||
|
logger.warning("⚠️ Permission check timed out, not caching result")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
screenRecordingPermissionCache = (granted: false, timestamp: Date())
|
screenRecordingPermissionCache = (granted: false, timestamp: Date())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -605,10 +605,17 @@ final class WebRTCManager: NSObject {
|
||||||
let action = json["action"] as? String
|
let action = json["action"] as? String
|
||||||
else {
|
else {
|
||||||
logger.error("Failed to decode control message")
|
logger.error("Failed to decode control message")
|
||||||
|
logger.error(" 📋 Raw data length: \(data.count) bytes")
|
||||||
|
if let str = String(data: data, encoding: .utf8) {
|
||||||
|
logger.error(" 📋 Raw data preview: \(str.prefix(200))...")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("📥 Received control message with action: \(action)")
|
logger.info("📥 Received control message with action: \(action)")
|
||||||
|
logger.info(" 📋 Message ID: \(json["id"] as? String ?? "NO ID")")
|
||||||
|
logger.info(" 📋 Message type: \(json["type"] as? String ?? "NO TYPE")")
|
||||||
|
logger.info(" 📋 Message category: \(json["category"] as? String ?? "NO CATEGORY")")
|
||||||
|
|
||||||
// Log detailed info for api-request messages
|
// Log detailed info for api-request messages
|
||||||
if action == "api-request" {
|
if action == "api-request" {
|
||||||
|
|
@ -893,25 +900,37 @@ final class WebRTCManager: NSObject {
|
||||||
private func handleApiRequest(_ json: [String: Any]) async {
|
private func handleApiRequest(_ json: [String: Any]) async {
|
||||||
logger.info("🔍 Starting handleApiRequest...")
|
logger.info("🔍 Starting handleApiRequest...")
|
||||||
logger.info(" 📋 JSON data: \(json)")
|
logger.info(" 📋 JSON data: \(json)")
|
||||||
|
logger.info(" 🔍 Message ID from json: \(json["id"] as? String ?? "NO ID")")
|
||||||
|
|
||||||
guard let requestId = json["requestId"] as? String,
|
// Extract payload which contains the actual API request details
|
||||||
let method = json["method"] as? String,
|
guard let payload = json["payload"] as? [String: Any] else {
|
||||||
let endpoint = json["endpoint"] as? String
|
logger.error("❌ Missing payload in API request")
|
||||||
|
logger.error(" 📋 Full json keys: \(json.keys.joined(separator: ", "))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(" 📋 Payload data: \(payload)")
|
||||||
|
|
||||||
|
guard let requestId = payload["requestId"] as? String,
|
||||||
|
let method = payload["method"] as? String,
|
||||||
|
let endpoint = payload["endpoint"] as? String
|
||||||
else {
|
else {
|
||||||
logger.error("Invalid API request format")
|
logger.error("Invalid API request format")
|
||||||
logger
|
logger
|
||||||
.error(
|
.error(
|
||||||
" 📋 Missing fields - requestId: \(json["requestId"] != nil), method: \(json["method"] != nil), endpoint: \(json["endpoint"] != nil)"
|
" 📋 Missing fields - requestId: \(payload["requestId"] != nil), method: \(payload["method"] != nil), endpoint: \(payload["endpoint"] != nil)"
|
||||||
)
|
)
|
||||||
|
logger.error(" 📋 Full payload keys: \(payload.keys.joined(separator: ", "))")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("📨 Received API request: \(method) \(endpoint)")
|
logger.info("📨 Received API request: \(method) \(endpoint)")
|
||||||
logger.info(" 📋 Request ID: \(requestId)")
|
logger.info(" 📋 Request ID: \(requestId)")
|
||||||
|
logger.info(" 📋 Message ID: \(json["id"] as? String ?? "NO ID")")
|
||||||
logger.info(" 📋 Full request data: \(json)")
|
logger.info(" 📋 Full request data: \(json)")
|
||||||
|
|
||||||
// Extract session ID from request
|
// Extract session ID from payload (where the browser puts it)
|
||||||
let sessionId = json["sessionId"] as? String
|
let sessionId = payload["sessionId"] as? String
|
||||||
logger.info(" 📋 Request session ID: \(sessionId ?? "nil")")
|
logger.info(" 📋 Request session ID: \(sessionId ?? "nil")")
|
||||||
logger.info(" 📋 Current active session: \(self.activeSessionId ?? "nil")")
|
logger.info(" 📋 Current active session: \(self.activeSessionId ?? "nil")")
|
||||||
|
|
||||||
|
|
@ -974,17 +993,17 @@ final class WebRTCManager: NSObject {
|
||||||
// Process API request on background queue to avoid blocking main thread
|
// Process API request on background queue to avoid blocking main thread
|
||||||
Task {
|
Task {
|
||||||
logger.info("🔄 Starting Task for API request: \(requestId)")
|
logger.info("🔄 Starting Task for API request: \(requestId)")
|
||||||
logger.info("📋 About to extract params from json")
|
logger.info("📋 About to extract params from payload")
|
||||||
logger.info("📋 json keys: \(json.keys.sorted())")
|
logger.info("📋 payload keys: \(payload.keys.sorted())")
|
||||||
logger.info("📋 json[\"params\"] exists: \(json["params"] != nil)")
|
logger.info("📋 payload[\"params\"] exists: \(payload["params"] != nil)")
|
||||||
logger.info("📋 json[\"params\"] type: \(type(of: json["params"]))")
|
logger.info("📋 payload[\"params\"] type: \(type(of: payload["params"]))")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
logger.info("🔄 About to call processApiRequest")
|
logger.info("🔄 About to call processApiRequest")
|
||||||
let result = try await processApiRequest(
|
let result = try await processApiRequest(
|
||||||
method: method,
|
method: method,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
params: json["params"],
|
params: payload["params"],
|
||||||
sessionId: sessionId
|
sessionId: sessionId
|
||||||
)
|
)
|
||||||
logger.info("📤 Sending API response for request \(requestId)")
|
logger.info("📤 Sending API response for request \(requestId)")
|
||||||
|
|
@ -1043,13 +1062,27 @@ final class WebRTCManager: NSObject {
|
||||||
|
|
||||||
// Check screen recording permission first for endpoints that need it
|
// Check screen recording permission first for endpoints that need it
|
||||||
if endpoint == "/processes" || endpoint == "/displays" {
|
if endpoint == "/processes" || endpoint == "/displays" {
|
||||||
|
logger.info("🔍 Checking screen recording permission for endpoint: \(endpoint)")
|
||||||
|
|
||||||
let hasPermission = await MainActor.run {
|
let hasPermission = await MainActor.run {
|
||||||
SystemPermissionManager.shared.hasPermission(.screenRecording)
|
SystemPermissionManager.shared.hasPermission(.screenRecording)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("📋 Screen recording permission check result: \(hasPermission)")
|
||||||
|
|
||||||
if !hasPermission {
|
if !hasPermission {
|
||||||
logger.warning("⚠️ Screen recording permission not granted for \(endpoint)")
|
logger.error("❌ Screen recording permission not granted for \(endpoint)")
|
||||||
throw ScreencapError.permissionDenied
|
// Force a re-check in case of cached false negative
|
||||||
|
logger.info("🔄 Forcing permission re-check...")
|
||||||
|
await SystemPermissionManager.shared.checkAllPermissions()
|
||||||
|
let hasPermissionRetry = await MainActor.run {
|
||||||
|
SystemPermissionManager.shared.hasPermission(.screenRecording)
|
||||||
|
}
|
||||||
|
logger.info("📋 Re-check result: \(hasPermissionRetry)")
|
||||||
|
|
||||||
|
if !hasPermissionRetry {
|
||||||
|
throw ScreencapError.permissionDenied
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ const _logger = createLogger('screencap-sidebar');
|
||||||
export class ScreencapSidebar extends LitElement {
|
export class ScreencapSidebar extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgb(var(--color-bg));
|
background: rgb(var(--color-bg));
|
||||||
border-right: 1px solid rgb(var(--color-border));
|
border-right: 1px solid rgb(var(--color-border));
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,19 @@ const logger = createLogger('screencap-view');
|
||||||
export class ScreencapView extends LitElement {
|
export class ScreencapView extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
position: fixed;
|
||||||
height: 100vh;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
background: rgb(var(--color-bg));
|
background: rgb(var(--color-bg));
|
||||||
color: rgb(var(--color-text));
|
color: rgb(var(--color-text));
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
/* Honor safe areas on mobile devices */
|
/* Honor safe areas on mobile devices */
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
|
|
@ -118,14 +125,18 @@ export class ScreencapView extends LitElement {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0; /* Critical for flexbox children to shrink */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), margin-left 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), margin-left 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
|
|
@ -139,6 +150,7 @@ export class ScreencapView extends LitElement {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0; /* Allow content to shrink below its minimum content size */
|
min-width: 0; /* Allow content to shrink below its minimum content size */
|
||||||
|
min-height: 0; /* Allow content to shrink below its minimum content size */
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-area {
|
.capture-area {
|
||||||
|
|
@ -1090,7 +1102,8 @@ export class ScreencapView extends LitElement {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="header">
|
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
|
||||||
|
<div class="header">
|
||||||
<button
|
<button
|
||||||
class="back-btn"
|
class="back-btn"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
|
|
@ -1223,6 +1236,7 @@ export class ScreencapView extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface ControlMessage {
|
||||||
action: string;
|
action: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
userId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ export class ScreencapWebSocketClient {
|
||||||
>();
|
>();
|
||||||
private isConnected = false;
|
private isConnected = false;
|
||||||
private connectionPromise: Promise<void> | null = null;
|
private connectionPromise: Promise<void> | null = null;
|
||||||
public sessionId: string | null = null;
|
public sessionId: string;
|
||||||
|
|
||||||
// Event handlers for WebRTC signaling
|
// Event handlers for WebRTC signaling
|
||||||
public onOffer?: (data: RTCSessionDescriptionInit) => void;
|
public onOffer?: (data: RTCSessionDescriptionInit) => void;
|
||||||
|
|
@ -31,7 +32,11 @@ export class ScreencapWebSocketClient {
|
||||||
public onReady?: () => void;
|
public onReady?: () => void;
|
||||||
|
|
||||||
constructor(private wsUrl: string) {
|
constructor(private wsUrl: string) {
|
||||||
logger.log(`📡 ScreencapWebSocketClient created with URL: ${wsUrl}`);
|
// Generate session ID immediately for all requests
|
||||||
|
this.sessionId = crypto.randomUUID();
|
||||||
|
logger.log(
|
||||||
|
`📡 ScreencapWebSocketClient created with URL: ${wsUrl}, sessionId: ${this.sessionId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connect(): Promise<void> {
|
private async connect(): Promise<void> {
|
||||||
|
|
@ -258,8 +263,9 @@ export class ScreencapWebSocketClient {
|
||||||
endpoint,
|
endpoint,
|
||||||
params,
|
params,
|
||||||
requestId, // Include original requestId in payload for mac-side compatibility
|
requestId, // Include original requestId in payload for mac-side compatibility
|
||||||
|
sessionId: this.sessionId, // Include sessionId in payload as expected by ScreenCaptureApiRequest
|
||||||
},
|
},
|
||||||
sessionId: this.sessionId || undefined,
|
sessionId: this.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -294,7 +300,7 @@ export class ScreencapWebSocketClient {
|
||||||
category: 'screencap',
|
category: 'screencap',
|
||||||
action,
|
action,
|
||||||
payload: data,
|
payload: data,
|
||||||
sessionId: this.sessionId || undefined,
|
sessionId: this.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log(`📤 Sending signal:`, message);
|
logger.log(`📤 Sending signal:`, message);
|
||||||
|
|
@ -311,28 +317,23 @@ export class ScreencapWebSocketClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async startCapture(params: { type: string; index: number; webrtc?: boolean; use8k?: boolean }) {
|
async startCapture(params: { type: string; index: number; webrtc?: boolean; use8k?: boolean }) {
|
||||||
// Generate a session ID for this capture session if not present
|
// Session ID is already generated in constructor
|
||||||
if (!this.sessionId) {
|
logger.log(`Starting capture with session ID: ${this.sessionId}`);
|
||||||
this.sessionId = crypto.randomUUID();
|
|
||||||
logger.log(`Generated session ID: ${this.sessionId}`);
|
|
||||||
}
|
|
||||||
return this.request('POST', '/capture', params);
|
return this.request('POST', '/capture', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureWindow(params: { cgWindowID: number; webrtc?: boolean; use8k?: boolean }) {
|
async captureWindow(params: { cgWindowID: number; webrtc?: boolean; use8k?: boolean }) {
|
||||||
// Generate a session ID for this capture session if not present
|
// Session ID is already generated in constructor
|
||||||
if (!this.sessionId) {
|
logger.log(`Capturing window with session ID: ${this.sessionId}`);
|
||||||
this.sessionId = crypto.randomUUID();
|
|
||||||
logger.log(`Generated session ID: ${this.sessionId}`);
|
|
||||||
}
|
|
||||||
return this.request('POST', '/capture-window', params);
|
return this.request('POST', '/capture-window', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopCapture() {
|
async stopCapture() {
|
||||||
try {
|
try {
|
||||||
const result = await this.request('POST', '/stop');
|
const result = await this.request('POST', '/stop');
|
||||||
// Clear session ID only after successful stop
|
// Generate new session ID after successful stop
|
||||||
this.sessionId = null;
|
this.sessionId = crypto.randomUUID();
|
||||||
|
logger.log(`Generated new session ID after stop: ${this.sessionId}`);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If stop fails, don't clear the session ID
|
// If stop fails, don't clear the session ID
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ describe('screencap routes', () => {
|
||||||
const mockReq = {} as Request;
|
const mockReq = {} as Request;
|
||||||
const mockRes = {
|
const mockRes = {
|
||||||
send: vi.fn(),
|
send: vi.fn(),
|
||||||
|
sendFile: vi.fn(),
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
const mockNext = vi.fn();
|
const mockNext = vi.fn();
|
||||||
|
|
||||||
|
|
@ -185,8 +186,9 @@ describe('screencap routes', () => {
|
||||||
// Call the handler
|
// Call the handler
|
||||||
pageHandler(mockReq, mockRes, mockNext);
|
pageHandler(mockReq, mockRes, mockNext);
|
||||||
|
|
||||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('<!DOCTYPE html>'));
|
expect(mockRes.sendFile).toHaveBeenCalledWith(
|
||||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('<screencap-view>'));
|
expect.stringContaining('public/screencap.html')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import * as path from 'node:path';
|
||||||
import { type NextFunction, type Request, type Response, Router } from 'express';
|
import { type NextFunction, type Request, type Response, Router } from 'express';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
|
@ -28,48 +29,9 @@ export function createScreencapRoutes(): Router {
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serve screencap frontend page
|
// Serve screencap frontend page (serve the static file instead of inline HTML)
|
||||||
router.get('/screencap', requireMacOS, (_req, res) => {
|
router.get('/screencap', requireMacOS, (_req, res) => {
|
||||||
res.send(`
|
res.sendFile(path.join(process.cwd(), 'public', 'screencap.html'));
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Screen Capture - VibeTunnel</title>
|
|
||||||
<link rel="stylesheet" href="/bundle/styles.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--dark-bg: #0a0a0a;
|
|
||||||
--dark-bg-elevated: #171717;
|
|
||||||
--dark-surface-hover: #262626;
|
|
||||||
--dark-border: #404040;
|
|
||||||
--dark-text: #fafafa;
|
|
||||||
--dark-text-muted: #a3a3a3;
|
|
||||||
--accent-primary: #3b82f6;
|
|
||||||
--accent-secondary: #60a5fa;
|
|
||||||
--status-success: #22c55e;
|
|
||||||
--status-warning: #f59e0b;
|
|
||||||
--status-error: #ef4444;
|
|
||||||
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
background: var(--dark-bg);
|
|
||||||
color: var(--dark-text);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<screencap-view></screencap-view>
|
|
||||||
<script type="module" src="/bundle/screencap.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|
|
||||||
|
|
@ -985,8 +985,8 @@ export async function createApp(): Promise<AppInstance> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('✅ Passing connection to controlUnixHandler');
|
logger.log('✅ Passing connection to controlUnixHandler with userId:', userId);
|
||||||
controlUnixHandler.handleBrowserConnection(ws);
|
controlUnixHandler.handleBrowserConnection(ws, userId);
|
||||||
} else if (pathname === '/ws/config') {
|
} else if (pathname === '/ws/config') {
|
||||||
logger.log('⚙️ Handling config WebSocket connection');
|
logger.log('⚙️ Handling config WebSocket connection');
|
||||||
// Add client to the set
|
// Add client to the set
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface ControlMessage {
|
||||||
action: string;
|
action: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
userId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,20 +133,33 @@ class SystemHandler implements MessageHandler {
|
||||||
|
|
||||||
class ScreenCaptureHandler implements MessageHandler {
|
class ScreenCaptureHandler implements MessageHandler {
|
||||||
private browserSocket: WebSocket | null = null;
|
private browserSocket: WebSocket | null = null;
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
constructor(private controlUnixHandler: ControlUnixHandler) {}
|
constructor(private controlUnixHandler: ControlUnixHandler) {}
|
||||||
|
|
||||||
setBrowserSocket(ws: WebSocket | null) {
|
setBrowserSocket(ws: WebSocket | null, userId?: string) {
|
||||||
this.browserSocket = ws;
|
this.browserSocket = ws;
|
||||||
|
this.userId = userId || null;
|
||||||
|
logger.log(`🔐 ScreenCaptureHandler userId set to: ${this.userId || 'unknown'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
isBrowserConnected(): boolean {
|
isBrowserConnected(): boolean {
|
||||||
return this.browserSocket !== null && this.browserSocket.readyState === WS.OPEN;
|
return this.browserSocket !== null && this.browserSocket.readyState === WS.OPEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserId(): string | null {
|
||||||
|
return this.userId;
|
||||||
|
}
|
||||||
|
|
||||||
async handleMessage(message: ControlMessage): Promise<ControlMessage | null> {
|
async handleMessage(message: ControlMessage): Promise<ControlMessage | null> {
|
||||||
logger.log(`Screen capture handler: ${message.action}`);
|
logger.log(`Screen capture handler: ${message.action}`);
|
||||||
|
|
||||||
|
// If message has a sessionId and we have a userId, associate them
|
||||||
|
if (message.sessionId && this.userId) {
|
||||||
|
logger.log(`🔐 Associating sessionId ${message.sessionId} with userId ${this.userId}`);
|
||||||
|
// The Mac app should handle this association
|
||||||
|
}
|
||||||
|
|
||||||
switch (message.action) {
|
switch (message.action) {
|
||||||
case 'mac-ready':
|
case 'mac-ready':
|
||||||
// Mac app connected and ready
|
// Mac app connected and ready
|
||||||
|
|
@ -487,17 +500,18 @@ export class ControlUnixHandler {
|
||||||
logger.log('✅ system:ready event sent');
|
logger.log('✅ system:ready event sent');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBrowserConnection(ws: WebSocket) {
|
handleBrowserConnection(ws: WebSocket, userId?: string) {
|
||||||
logger.log('🌐 New browser WebSocket connection for control messages');
|
logger.log('🌐 New browser WebSocket connection for control messages');
|
||||||
|
logger.log(`👤 User ID: ${userId || 'unknown'}`);
|
||||||
logger.log(
|
logger.log(
|
||||||
`🔌 Mac socket status on browser connect: ${this.macSocket ? 'CONNECTED' : 'NOT CONNECTED'}`
|
`🔌 Mac socket status on browser connect: ${this.macSocket ? 'CONNECTED' : 'NOT CONNECTED'}`
|
||||||
);
|
);
|
||||||
logger.log(`🖥️ Screen capture handler exists: ${!!this.screenCaptureHandler}`);
|
logger.log(`🖥️ Screen capture handler exists: ${!!this.screenCaptureHandler}`);
|
||||||
|
|
||||||
// Set browser socket in screen capture handler
|
// Set browser socket in screen capture handler with user ID
|
||||||
this.screenCaptureHandler.setBrowserSocket(ws);
|
this.screenCaptureHandler.setBrowserSocket(ws, userId);
|
||||||
this.handlers.set('screencap', this.screenCaptureHandler);
|
this.handlers.set('screencap', this.screenCaptureHandler);
|
||||||
logger.log('✅ Browser socket set in screen capture handler');
|
logger.log('✅ Browser socket set in screen capture handler with userId:', userId);
|
||||||
|
|
||||||
// If the Mac app is already connected, we can trigger the ready sequence
|
// If the Mac app is already connected, we can trigger the ready sequence
|
||||||
if (this.macSocket) {
|
if (this.macSocket) {
|
||||||
|
|
@ -526,11 +540,24 @@ export class ControlUnixHandler {
|
||||||
// Handle browser -> Mac messages
|
// Handle browser -> Mac messages
|
||||||
if (message.category === 'screencap') {
|
if (message.category === 'screencap') {
|
||||||
logger.log(`🖥️ Processing screencap message: ${message.action}`);
|
logger.log(`🖥️ Processing screencap message: ${message.action}`);
|
||||||
|
logger.log(`📋 Message ID: ${message.id}`);
|
||||||
|
logger.log(`📋 Message type: ${message.type}`);
|
||||||
|
logger.log(`📋 Full message:`, JSON.stringify(message));
|
||||||
|
|
||||||
|
// Add authentication context to the message
|
||||||
|
const authenticatedMessage = {
|
||||||
|
...message,
|
||||||
|
userId: this.screenCaptureHandler.getUserId() || 'unknown',
|
||||||
|
};
|
||||||
|
logger.log(`🔐 Adding userId ${authenticatedMessage.userId} to message`);
|
||||||
|
|
||||||
// Forward screen capture messages to Mac
|
// Forward screen capture messages to Mac
|
||||||
if (this.macSocket) {
|
if (this.macSocket) {
|
||||||
logger.log(`📤 Forwarding ${message.action} to Mac app via Unix socket`);
|
logger.log(
|
||||||
this.sendToMac(message);
|
`📤 Forwarding ${message.action} to Mac app via Unix socket with auth context`
|
||||||
|
);
|
||||||
|
logger.log(`🔌 Mac socket state: ${this.macSocket.destroyed ? 'DESTROYED' : 'ACTIVE'}`);
|
||||||
|
this.sendToMac(authenticatedMessage);
|
||||||
} else {
|
} else {
|
||||||
logger.warn('❌ No Mac connected to handle screen capture request');
|
logger.warn('❌ No Mac connected to handle screen capture request');
|
||||||
logger.warn('💡 The Mac app needs to be running and connected via Unix socket');
|
logger.warn('💡 The Mac app needs to be running and connected via Unix socket');
|
||||||
|
|
@ -599,13 +626,21 @@ export class ControlUnixHandler {
|
||||||
|
|
||||||
// Skip processing for response messages that aren't pending requests
|
// Skip processing for response messages that aren't pending requests
|
||||||
// This prevents response loops where error responses get processed again
|
// This prevents response loops where error responses get processed again
|
||||||
if (message.type === 'response') {
|
// EXCEPT for screencap messages which need to be forwarded to the browser
|
||||||
|
if (message.type === 'response' && message.category !== 'screencap') {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Ignoring response message that has no pending request: ${message.id}, action: ${message.action}`
|
`Ignoring response message that has no pending request: ${message.id}, action: ${message.action}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log screencap responses that will be forwarded
|
||||||
|
if (message.type === 'response' && message.category === 'screencap') {
|
||||||
|
logger.log(
|
||||||
|
`📡 Forwarding screencap response to handler: ${message.id}, action: ${message.action}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handler = this.handlers.get(message.category);
|
const handler = this.handlers.get(message.category);
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
logger.warn(`No handler for category: ${message.category}`);
|
logger.warn(`No handler for category: ${message.category}`);
|
||||||
|
|
@ -684,6 +719,7 @@ export class ControlUnixHandler {
|
||||||
logger.log(
|
logger.log(
|
||||||
`📤 Sending to Mac: ${message.category}:${message.action}, header: 4 bytes, payload: ${jsonData.length} bytes, total: ${fullData.length} bytes`
|
`📤 Sending to Mac: ${message.category}:${message.action}, header: 4 bytes, payload: ${jsonData.length} bytes, total: ${fullData.length} bytes`
|
||||||
);
|
);
|
||||||
|
logger.log(`📋 Message ID being sent: ${message.id}`);
|
||||||
logger.debug(`📝 Message content: ${jsonStr.substring(0, 200)}...`);
|
logger.debug(`📝 Message content: ${jsonStr.substring(0, 200)}...`);
|
||||||
|
|
||||||
// Log the actual bytes for the first few messages
|
// Log the actual bytes for the first few messages
|
||||||
|
|
|
||||||
|
|
@ -182,4 +182,117 @@ describe('Control Unix Handler', () => {
|
||||||
expect(mockCallback).not.toHaveBeenCalled();
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Screencap Response Forwarding', () => {
|
||||||
|
it('should forward screencap response messages even without pending requests', async () => {
|
||||||
|
// Mock WebSocket for browser connection
|
||||||
|
const mockBrowserSocket = {
|
||||||
|
readyState: 1, // OPEN
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the screen capture handler
|
||||||
|
const mockScreenCaptureHandler = {
|
||||||
|
setBrowserSocket: vi.fn(),
|
||||||
|
handleMessage: vi.fn().mockResolvedValue(null),
|
||||||
|
getUserId: vi.fn().mockReturnValue('test-user-id'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up the handler
|
||||||
|
(controlUnixHandler as any).screenCaptureHandler = mockScreenCaptureHandler;
|
||||||
|
(controlUnixHandler as any).handlers.set('screencap', mockScreenCaptureHandler);
|
||||||
|
mockScreenCaptureHandler.browserSocket = mockBrowserSocket;
|
||||||
|
|
||||||
|
// Create a screencap API response message (simulating response from Mac app)
|
||||||
|
const screencapResponse = {
|
||||||
|
id: 'response-123',
|
||||||
|
type: 'response' as const,
|
||||||
|
category: 'screencap' as const,
|
||||||
|
action: 'api-response',
|
||||||
|
payload: {
|
||||||
|
method: 'GET',
|
||||||
|
endpoint: '/processes',
|
||||||
|
data: [
|
||||||
|
{ processName: 'Terminal', pid: 1234, windows: [] },
|
||||||
|
{ processName: 'Safari', pid: 5678, windows: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call handleMacMessage directly
|
||||||
|
await (controlUnixHandler as any).handleMacMessage(screencapResponse);
|
||||||
|
|
||||||
|
// Verify the handler was called with the message
|
||||||
|
expect(mockScreenCaptureHandler.handleMessage).toHaveBeenCalledWith(screencapResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore non-screencap response messages without pending requests', async () => {
|
||||||
|
// Mock a handler for system messages
|
||||||
|
const mockSystemHandler = {
|
||||||
|
handleMessage: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
(controlUnixHandler as any).handlers.set('system', mockSystemHandler);
|
||||||
|
|
||||||
|
// Create a system response message without a pending request
|
||||||
|
const systemResponse = {
|
||||||
|
id: 'response-456',
|
||||||
|
type: 'response' as const,
|
||||||
|
category: 'system' as const,
|
||||||
|
action: 'some-action',
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call handleMacMessage directly
|
||||||
|
await (controlUnixHandler as any).handleMacMessage(systemResponse);
|
||||||
|
|
||||||
|
// Verify the handler was NOT called (message should be ignored)
|
||||||
|
expect(mockSystemHandler.handleMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process screencap request messages normally', async () => {
|
||||||
|
// Mock the screen capture handler
|
||||||
|
const mockScreenCaptureHandler = {
|
||||||
|
handleMessage: vi.fn().mockResolvedValue({
|
||||||
|
id: 'request-789',
|
||||||
|
type: 'response',
|
||||||
|
category: 'screencap',
|
||||||
|
action: 'api-request',
|
||||||
|
payload: { success: true },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
(controlUnixHandler as any).handlers.set('screencap', mockScreenCaptureHandler);
|
||||||
|
|
||||||
|
// Create a screencap request message
|
||||||
|
const screencapRequest = {
|
||||||
|
id: 'request-789',
|
||||||
|
type: 'request' as const,
|
||||||
|
category: 'screencap' as const,
|
||||||
|
action: 'api-request',
|
||||||
|
payload: {
|
||||||
|
method: 'GET',
|
||||||
|
endpoint: '/displays',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock sendToMac to capture the response
|
||||||
|
const sendToMacSpy = vi
|
||||||
|
.spyOn(controlUnixHandler as any, 'sendToMac')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Call handleMacMessage
|
||||||
|
await (controlUnixHandler as any).handleMacMessage(screencapRequest);
|
||||||
|
|
||||||
|
// Verify the handler was called
|
||||||
|
expect(mockScreenCaptureHandler.handleMessage).toHaveBeenCalledWith(screencapRequest);
|
||||||
|
|
||||||
|
// Verify response was sent back to Mac
|
||||||
|
expect(sendToMacSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'response',
|
||||||
|
category: 'screencap',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue