Backend changes (C#): - Add CopyRecipe method to MachineBridge - Generates new GUID for copied recipe - Returns new recipe with current timestamp - Add DeleteRecipe method to MachineBridge - Prevents deletion of currently selected recipe - Returns success/failure status - Add COPY_RECIPE and DELETE_RECIPE handlers in WebSocketServer Frontend changes (React/TypeScript): - Add CopyRecipe and DeleteRecipe to Window interface types - Add copyRecipe and deleteRecipe methods to communication layer - Implement handleCopy in RecipePage - Prompts user for new name - Adds copied recipe to list - Selects newly copied recipe - Implement handleDelete in RecipePage - Shows confirmation dialog - Removes recipe from list - Selects next available recipe - Connect Copy and Delete buttons with handlers - Disable buttons when no recipe is selected 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
|
|
// Check if running in WebView2
|
|
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
|
|
|
type MessageCallback = (data: any) => void;
|
|
|
|
class CommunicationLayer {
|
|
private listeners: MessageCallback[] = [];
|
|
private ws: WebSocket | null = null;
|
|
private isConnected = false;
|
|
|
|
constructor() {
|
|
if (isWebView) {
|
|
console.log("[COMM] Running in WebView2 Mode");
|
|
this.isConnected = true; // WebView2 is always connected
|
|
window.chrome!.webview!.addEventListener('message', (event: any) => {
|
|
this.notifyListeners(event.data);
|
|
});
|
|
} else {
|
|
console.log("[COMM] Running in Browser Mode (WebSocket)");
|
|
this.connectWebSocket();
|
|
}
|
|
}
|
|
|
|
private connectWebSocket() {
|
|
this.ws = new WebSocket('ws://localhost:8081');
|
|
|
|
this.ws.onopen = () => {
|
|
console.log("[COMM] WebSocket Connected");
|
|
this.isConnected = true;
|
|
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.notifyListeners(data);
|
|
} catch (e) {
|
|
console.error("[COMM] JSON Parse Error", e);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log("[COMM] WebSocket Closed. Reconnecting...");
|
|
this.isConnected = false;
|
|
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
|
|
setTimeout(() => this.connectWebSocket(), 2000);
|
|
};
|
|
|
|
this.ws.onerror = (err) => {
|
|
console.error("[COMM] WebSocket Error", err);
|
|
};
|
|
}
|
|
|
|
private notifyListeners(data: any) {
|
|
this.listeners.forEach(cb => cb(data));
|
|
}
|
|
|
|
public subscribe(callback: MessageCallback) {
|
|
this.listeners.push(callback);
|
|
return () => {
|
|
this.listeners = this.listeners.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
public getConnectionState(): boolean {
|
|
return this.isConnected;
|
|
}
|
|
|
|
// --- API Methods ---
|
|
|
|
public async getConfig(): Promise<string> {
|
|
if (isWebView) {
|
|
return await window.chrome!.webview!.hostObjects.machine.GetConfig();
|
|
} else {
|
|
// WebSocket Request/Response Pattern
|
|
return new Promise((resolve, reject) => {
|
|
// 1. Wait for Connection (max 2s)
|
|
if (!this.isConnected) {
|
|
// Simple wait logic (could be improved)
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject("WebSocket connection timeout");
|
|
}, 2000);
|
|
}
|
|
|
|
// 2. Send Request with Timeout (max 10s)
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject("Config fetch timeout");
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'CONFIG_DATA') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(JSON.stringify(data.data));
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'GET_CONFIG' }));
|
|
});
|
|
}
|
|
}
|
|
|
|
public async getIOList(): Promise<string> {
|
|
if (isWebView) {
|
|
return await window.chrome!.webview!.hostObjects.machine.GetIOList();
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.isConnected) {
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject("WebSocket connection timeout");
|
|
}, 2000);
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject("IO fetch timeout");
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'IO_LIST_DATA') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(JSON.stringify(data.data));
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'GET_IO_LIST' }));
|
|
});
|
|
}
|
|
}
|
|
|
|
public async getRecipeList(): Promise<string> {
|
|
if (isWebView) {
|
|
return await window.chrome!.webview!.hostObjects.machine.GetRecipeList();
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.isConnected) {
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject("WebSocket connection timeout");
|
|
}, 2000);
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject("Recipe fetch timeout");
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'RECIPE_LIST_DATA') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(JSON.stringify(data.data));
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'GET_RECIPE_LIST' }));
|
|
});
|
|
}
|
|
}
|
|
|
|
public async saveConfig(configJson: string): Promise<void> {
|
|
if (isWebView) {
|
|
await window.chrome!.webview!.hostObjects.machine.SaveConfig(configJson);
|
|
} else {
|
|
this.ws?.send(JSON.stringify({ type: 'SAVE_CONFIG', data: JSON.parse(configJson) }));
|
|
}
|
|
}
|
|
|
|
public async sendControl(command: string) {
|
|
if (isWebView) {
|
|
await window.chrome!.webview!.hostObjects.machine.SystemControl(command);
|
|
} else {
|
|
this.ws?.send(JSON.stringify({ type: 'CONTROL', command }));
|
|
}
|
|
}
|
|
|
|
public async moveAxis(axis: string, value: number) {
|
|
if (isWebView) {
|
|
await window.chrome!.webview!.hostObjects.machine.MoveAxis(axis, value);
|
|
} else {
|
|
this.ws?.send(JSON.stringify({ type: 'MOVE', axis, value }));
|
|
}
|
|
}
|
|
|
|
public async setIO(id: number, state: boolean) {
|
|
if (isWebView) {
|
|
await window.chrome!.webview!.hostObjects.machine.SetIO(id, false, state);
|
|
} else {
|
|
this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state }));
|
|
}
|
|
}
|
|
|
|
public async selectRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
|
|
if (isWebView) {
|
|
const resultJson = await window.chrome!.webview!.hostObjects.machine.SelectRecipe(recipeId);
|
|
return JSON.parse(resultJson);
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.isConnected) {
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
|
}, 2000);
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject({ success: false, message: "Recipe selection timeout" });
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'RECIPE_SELECTED') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(data.data);
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'SELECT_RECIPE', recipeId }));
|
|
});
|
|
}
|
|
}
|
|
|
|
public async copyRecipe(recipeId: string, newName: string): Promise<{ success: boolean; message: string; newRecipe?: any }> {
|
|
if (isWebView) {
|
|
const resultJson = await window.chrome!.webview!.hostObjects.machine.CopyRecipe(recipeId, newName);
|
|
return JSON.parse(resultJson);
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.isConnected) {
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
|
}, 2000);
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject({ success: false, message: "Recipe copy timeout" });
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'RECIPE_COPIED') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(data.data);
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'COPY_RECIPE', recipeId, newName }));
|
|
});
|
|
}
|
|
}
|
|
|
|
public async deleteRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
|
|
if (isWebView) {
|
|
const resultJson = await window.chrome!.webview!.hostObjects.machine.DeleteRecipe(recipeId);
|
|
return JSON.parse(resultJson);
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.isConnected) {
|
|
setTimeout(() => {
|
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
|
}, 2000);
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
reject({ success: false, message: "Recipe delete timeout" });
|
|
}, 10000);
|
|
|
|
const handler = (data: any) => {
|
|
if (data.type === 'RECIPE_DELETED') {
|
|
clearTimeout(timeoutId);
|
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
|
resolve(data.data);
|
|
}
|
|
};
|
|
this.listeners.push(handler);
|
|
this.ws?.send(JSON.stringify({ type: 'DELETE_RECIPE', recipeId }));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export const comms = new CommunicationLayer();
|