diff --git a/server.py b/server.py
index 878b5eeb1..51a24007c 100644
--- a/server.py
+++ b/server.py
@@ -711,6 +711,18 @@ class PromptServer():
return web.Response(status=200)
+ @routes.get("/mobile")
+ async def get_mobile(request):
+ mobile_path = os.path.join(os.path.dirname(__file__), "web_mobile", "index.html")
+ if os.path.exists(mobile_path):
+ response = web.FileResponse(mobile_path)
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+ else:
+ return web.Response(status=404, text="Mobile interface not found")
+
async def setup(self):
timeout = aiohttp.ClientTimeout(total=None) # no timeout
self.client_session = aiohttp.ClientSession(timeout=timeout)
@@ -752,6 +764,13 @@ class PromptServer():
web.static('/docs', embedded_docs_path)
])
+ # Serve mobile interface static files
+ mobile_static_path = os.path.join(os.path.dirname(__file__), "web_mobile")
+ if os.path.exists(mobile_static_path):
+ self.app.add_routes([
+ web.static('/mobile_static', mobile_static_path)
+ ])
+
self.app.add_routes([
web.static('/', self.web_root),
])
diff --git a/tests-unit/mobile_interface_test.py b/tests-unit/mobile_interface_test.py
new file mode 100644
index 000000000..758c3f25f
--- /dev/null
+++ b/tests-unit/mobile_interface_test.py
@@ -0,0 +1,171 @@
+"""
+Unit tests for the mobile interface functionality.
+"""
+import pytest
+import os
+
+
+class TestMobileInterfaceFiles:
+ """Test cases for mobile interface file structure and content."""
+
+ def test_mobile_html_structure(self):
+ """Test that mobile HTML has required structure."""
+ mobile_html = os.path.join(
+ os.path.dirname(__file__), "..", "web_mobile", "index.html"
+ )
+
+ if not os.path.exists(mobile_html):
+ pytest.skip("Mobile HTML file not found")
+
+ with open(mobile_html, 'r') as f:
+ content = f.read()
+
+ # Check essential HTML structure
+ assert "" in content
+ assert "" in content
+ assert "
" in content
+ assert "ComfyUI Mobile" in content
+
+ # Check required elements
+ assert 'id="app"' in content
+ assert 'id="workflowPipeline"' in content
+ assert 'id="queuePrompt"' in content
+ assert 'id="nodeDetailModal"' in content
+ assert 'class="action-bar"' in content
+
+ # Check CSS and JS includes
+ assert "/mobile_static/styles.css" in content
+ assert "/mobile_static/utils.js" in content
+ assert "/mobile_static/app.js" in content
+ assert "/mobile_static/mobile-interface.js" in content
+
+ def test_mobile_js_files_exist(self):
+ """Test that all required JavaScript files exist."""
+ mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
+
+ if not os.path.exists(mobile_dir):
+ pytest.skip("Mobile directory not found")
+
+ required_js_files = [
+ "utils.js",
+ "graph-linearization.js",
+ "api-client.js",
+ "mobile-interface.js",
+ "app.js"
+ ]
+
+ for js_file in required_js_files:
+ file_path = os.path.join(mobile_dir, js_file)
+ assert os.path.exists(file_path), f"Missing required JS file: {js_file}"
+
+ # Check file is not empty
+ with open(file_path, 'r') as f:
+ content = f.read().strip()
+ assert len(content) > 0, f"JS file is empty: {js_file}"
+
+ def test_mobile_css_file_exists(self):
+ """Test that the CSS file exists and has content."""
+ css_file = os.path.join(
+ os.path.dirname(__file__), "..", "web_mobile", "styles.css"
+ )
+
+ if not os.path.exists(css_file):
+ pytest.skip("CSS file not found")
+
+ with open(css_file, 'r') as f:
+ content = f.read().strip()
+
+ assert len(content) > 0, "CSS file is empty"
+
+ # Check for essential CSS classes
+ assert ".header" in content
+ assert ".workflow-pipeline" in content
+ assert ".node-card" in content
+ assert ".action-bar" in content
+ assert ".modal" in content
+
+ # Check for mobile-specific CSS
+ assert "@media" in content, "No responsive CSS found"
+ assert "touch" in content.lower(), "No touch-specific CSS found"
+
+
+class TestMobileInterfaceJavaScript:
+ """Test cases for mobile interface JavaScript functionality."""
+
+ def test_javascript_classes_defined(self):
+ """Test that required JavaScript classes are defined."""
+ js_files = {
+ "utils.js": ["Utils"],
+ "graph-linearization.js": ["GraphLinearization"],
+ "api-client.js": ["ComfyUIAPIClient"],
+ "mobile-interface.js": ["MobileInterface"],
+ "app.js": ["MobileApp"]
+ }
+
+ mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
+
+ for js_file, expected_classes in js_files.items():
+ file_path = os.path.join(mobile_dir, js_file)
+
+ if not os.path.exists(file_path):
+ pytest.skip(f"JS file not found: {js_file}")
+
+ with open(file_path, 'r') as f:
+ content = f.read()
+
+ for class_name in expected_classes:
+ assert f"class {class_name}" in content, \
+ f"Class {class_name} not found in {js_file}"
+
+ def test_javascript_exports(self):
+ """Test that JavaScript files export classes to window."""
+ js_files = {
+ "utils.js": "Utils",
+ "graph-linearization.js": "GraphLinearization",
+ "api-client.js": "ComfyUIAPIClient",
+ "mobile-interface.js": "MobileInterface"
+ }
+
+ mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
+
+ for js_file, class_name in js_files.items():
+ file_path = os.path.join(mobile_dir, js_file)
+
+ if not os.path.exists(file_path):
+ pytest.skip(f"JS file not found: {js_file}")
+
+ with open(file_path, 'r') as f:
+ content = f.read()
+
+ assert f"window.{class_name}" in content, \
+ f"Class {class_name} not exported to window in {js_file}"
+
+ def test_mobile_interface_initialization(self):
+ """Test that mobile interface has proper initialization."""
+ app_js = os.path.join(
+ os.path.dirname(__file__), "..", "web_mobile", "app.js"
+ )
+
+ if not os.path.exists(app_js):
+ pytest.skip("app.js not found")
+
+ with open(app_js, 'r') as f:
+ content = f.read()
+
+ # Check for DOM ready event listener
+ assert "DOMContentLoaded" in content
+
+ # Check for MobileApp initialization
+ assert "new MobileApp()" in content
+
+ # Check for error handling
+ assert "try" in content and "catch" in content
+
+ # Check for proper cleanup
+ assert "beforeunload" in content
+ assert "cleanup" in content
+
+
+if __name__ == "__main__":
+ pytest.main([__file__])
diff --git a/web_mobile/api-client.js b/web_mobile/api-client.js
new file mode 100644
index 000000000..0e6653bb6
--- /dev/null
+++ b/web_mobile/api-client.js
@@ -0,0 +1,472 @@
+/**
+ * ComfyUI Mobile Interface - API Client
+ * Handles communication with ComfyUI backend
+ */
+
+class ComfyUIAPIClient {
+ constructor() {
+ this.baseURL = window.location.origin;
+ this.websocket = null;
+ this.clientId = this.generateClientId();
+ this.isConnected = false;
+ this.eventListeners = new Map();
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = 5;
+ this.reconnectDelay = 1000;
+
+ // Initialize WebSocket connection
+ this.initializeWebSocket();
+ }
+
+ /**
+ * Generate unique client ID
+ * @returns {string} Client ID
+ */
+ generateClientId() {
+ return 'mobile_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+ }
+
+ /**
+ * Initialize WebSocket connection
+ */
+ initializeWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsURL = `${protocol}//${window.location.host}/ws?clientId=${this.clientId}`;
+
+ try {
+ this.websocket = new WebSocket(wsURL);
+ this.setupWebSocketHandlers();
+ } catch (error) {
+ console.error('WebSocket initialization error:', error);
+ this.emit('connection_error', error);
+ }
+ }
+
+ /**
+ * Setup WebSocket event handlers
+ */
+ setupWebSocketHandlers() {
+ this.websocket.onopen = () => {
+ console.log('WebSocket connected');
+ this.isConnected = true;
+ this.reconnectAttempts = 0;
+ this.emit('connected');
+ };
+
+ this.websocket.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ this.handleWebSocketMessage(data);
+ } catch (error) {
+ console.error('WebSocket message parse error:', error);
+ }
+ };
+
+ this.websocket.onclose = (event) => {
+ console.log('WebSocket disconnected:', event.code, event.reason);
+ this.isConnected = false;
+ this.emit('disconnected');
+
+ // Attempt to reconnect if not a clean close
+ if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
+ setTimeout(() => this.reconnect(), this.reconnectDelay);
+ }
+ };
+
+ this.websocket.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ this.emit('connection_error', error);
+ };
+ }
+
+ /**
+ * Handle incoming WebSocket messages
+ * @param {Object} data - Message data
+ */
+ handleWebSocketMessage(data) {
+ const { type, data: messageData } = data;
+
+ switch (type) {
+ case 'status':
+ this.emit('status_update', messageData);
+ break;
+ case 'progress':
+ this.emit('progress_update', messageData);
+ break;
+ case 'executing':
+ this.emit('node_executing', messageData);
+ break;
+ case 'executed':
+ this.emit('node_executed', messageData);
+ break;
+ case 'execution_start':
+ this.emit('execution_start', messageData);
+ break;
+ case 'execution_success':
+ this.emit('execution_success', messageData);
+ break;
+ case 'execution_error':
+ this.emit('execution_error', messageData);
+ break;
+ case 'execution_cached':
+ this.emit('execution_cached', messageData);
+ break;
+ default:
+ console.log('Unknown WebSocket message type:', type, messageData);
+ }
+ }
+
+ /**
+ * Reconnect to WebSocket
+ */
+ reconnect() {
+ this.reconnectAttempts++;
+ console.log(`Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
+
+ this.initializeWebSocket();
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Exponential backoff
+ }
+
+ /**
+ * Add event listener
+ * @param {string} event - Event name
+ * @param {Function} callback - Callback function
+ */
+ on(event, callback) {
+ if (!this.eventListeners.has(event)) {
+ this.eventListeners.set(event, []);
+ }
+ this.eventListeners.get(event).push(callback);
+ }
+
+ /**
+ * Remove event listener
+ * @param {string} event - Event name
+ * @param {Function} callback - Callback function
+ */
+ off(event, callback) {
+ if (!this.eventListeners.has(event)) return;
+
+ const callbacks = this.eventListeners.get(event);
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+
+ /**
+ * Emit event to listeners
+ * @param {string} event - Event name
+ * @param {*} data - Event data
+ */
+ emit(event, data) {
+ if (!this.eventListeners.has(event)) return;
+
+ this.eventListeners.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (error) {
+ console.error('Event callback error:', error);
+ }
+ });
+ }
+
+ /**
+ * Make HTTP API request
+ * @param {string} endpoint - API endpoint
+ * @param {Object} options - Request options
+ * @returns {Promise} Response promise
+ */
+ async request(endpoint, options = {}) {
+ const url = `${this.baseURL}${endpoint}`;
+ const defaultOptions = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ const requestOptions = { ...defaultOptions, ...options };
+
+ try {
+ const response = await fetch(url, requestOptions);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const contentType = response.headers.get('content-type');
+ if (contentType && contentType.includes('application/json')) {
+ return await response.json();
+ } else {
+ return await response.text();
+ }
+ } catch (error) {
+ console.error('API request error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get system information
+ * @returns {Promise