From c83b85a1a2e6ce572913948dcef269bd853a8725 Mon Sep 17 00:00:00 2001 From: peezy Date: Fri, 8 Aug 2025 00:55:17 -0300 Subject: [PATCH 1/3] feat: AppServerClient system --- .env.example | 5 +- .gitignore | 3 +- docs/App-server.md | 0 package-lock.json | 4 +- src/client/components/DevToolsPane.js | 720 ++++++++++++++++++++++++++ src/client/components/ScriptEditor.js | 58 +++ src/client/components/Sidebar.js | 83 ++- src/core/createClientWorld.js | 2 + src/core/systems/AppServerClient.js | 666 ++++++++++++++++++++++++ src/core/systems/ClientLoader.js | 25 +- 10 files changed, 1553 insertions(+), 13 deletions(-) create mode 100644 docs/App-server.md create mode 100644 src/client/components/DevToolsPane.js create mode 100644 src/core/systems/AppServerClient.js diff --git a/.env.example b/.env.example index 873ec677..937816b6 100644 --- a/.env.example +++ b/.env.example @@ -43,4 +43,7 @@ CLEAN=true # LiveKit (voice chat) LIVEKIT_WS_URL= LIVEKIT_API_KEY= -LIVEKIT_API_SECRET= \ No newline at end of file +LIVEKIT_API_SECRET= + +# Hooks to connect to a local app dev server +PUBLIC_DEV_SERVER=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 12b93f6a..594a25c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ localstorage.json .notes/ node_modules/ build/ -/world* \ No newline at end of file +/world* +data/ diff --git a/docs/App-server.md b/docs/App-server.md new file mode 100644 index 00000000..e69de29b diff --git a/package-lock.json b/package-lock.json index fe87f532..6b6709ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "hyperfy", + "name": "@drama.haus/hyperfy", "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hyperfy", + "name": "@drama.haus/hyperfy", "version": "0.15.0", "license": "GPL-3.0-only", "dependencies": { diff --git a/src/client/components/DevToolsPane.js b/src/client/components/DevToolsPane.js new file mode 100644 index 00000000..600eb4af --- /dev/null +++ b/src/client/components/DevToolsPane.js @@ -0,0 +1,720 @@ +import { css } from '@firebolt-dev/css' +import { useEffect, useRef, useState } from 'react' +import { + CheckCircleIcon, + ExternalLinkIcon, + LinkIcon, + PlusIcon, + RefreshCwIcon, + SettingsIcon, + TrashIcon, + UploadIcon, + WifiIcon, + WifiOffIcon, + XCircleIcon, + XIcon, + SearchIcon, +} from 'lucide-react' +import { + FieldBtn, + FieldText, + FieldToggle, +} from './Fields' +import { cls } from './cls' + +export function DevToolsPane({ world, hidden }) { + return ( + + ) +} + +function DevToolsStatus({ world }) { + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [isConnecting, setIsConnecting] = useState(false) + + useEffect(() => { + checkConnection() + + // Listen for connection status changes + const updateStatus = () => { + setConnectionStatus(world.appServerClient.connected ? 'connected' : 'disconnected') + } + + world.appServerClient.on?.('connectionChanged', updateStatus) + return () => world.appServerClient.off?.('connectionChanged', updateStatus) + }, []) + + const checkConnection = async () => { + try { + setIsConnecting(true) + const devServerUrl = world.appServerClient.url || 'http://localhost:8080' + + const response = await fetch(`${devServerUrl}/health`) + if (response.ok) { + setConnectionStatus('connected') + } else { + setConnectionStatus('error') + } + } catch (error) { + setConnectionStatus('disconnected') + } finally { + setIsConnecting(false) + } + } + + const getStatusIcon = () => { + if (isConnecting) return + + switch (connectionStatus) { + case 'connected': + return + case 'disconnected': + return + case 'error': + return + default: + return + } + } + + return ( +
+ {getStatusIcon()} +
+ ) +} + +// Import Pane component +function Pane({ width = '20rem', hidden, children }) { + return ( +
+
{children}
+
+ ) +} + +function Group({ label }) { + return ( + <> +
+ {label && ( +
+ {label} +
+ )} + + ) +} + +function DevToolsContent({ world }) { + const [serverUrl, setServerUrl] = useState('http://localhost:8080') + const [customPort, setCustomPort] = useState('8080') + const [linkedApps, setLinkedApps] = useState([]) + const [allApps, setAllApps] = useState([]) + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [isConnecting, setIsConnecting] = useState(false) + const [isLoadingApps, setIsLoadingApps] = useState(false) + const [lastError, setLastError] = useState(null) + const [lastSuccess, setLastSuccess] = useState(null) + const [activeTab, setActiveTab] = useState('linked') + const [actionLoading, setActionLoading] = useState({}) + + useEffect(() => { + // Initialize server URL from AppServerClient if available + if (world.appServerClient.url) { + setServerUrl(world.appServerClient.url) + setCustomPort(world.appServerClient.url.split(':').pop()) + } + + // Check initial connection status + checkConnection() + + // Listen for app linked/unlinked events + const onAppLinked = ({ appName, linkInfo }) => { + loadApps() + showSuccess(`${appName} linked successfully`) + } + const onAppUnlinked = ({ appName }) => { + loadApps() + showSuccess(`${appName} unlinked successfully`) + } + + world.on('app_linked', onAppLinked) + world.on('app_unlinked', onAppUnlinked) + + return () => { + world.off('app_linked', onAppLinked) + world.off('app_unlinked', onAppUnlinked) + } + }, []) + + // Utility functions for showing feedback + const showSuccess = (message) => { + setLastSuccess(message) + setLastError(null) + setTimeout(() => setLastSuccess(null), 3000) + } + + const showError = (message) => { + setLastError(message) + setLastSuccess(null) + } + + const setActionLoadingState = (action, isLoading) => { + setActionLoading(prev => ({ ...prev, [action]: isLoading })) + } + + const checkConnection = async () => { + try { + setIsConnecting(true) + setLastError(null) + setLastSuccess(null) + + const response = await fetch(`${serverUrl}/health`) + if (response.ok) { + setConnectionStatus('connected') + showSuccess('Connected to development server') + await loadApps() + } else { + setConnectionStatus('error') + showError('Server responded with error') + } + } catch (error) { + setConnectionStatus('disconnected') + showError(`Connection failed: ${error.message}`) + } finally { + setIsConnecting(false) + } + } + + const disconnect = () => { + setConnectionStatus('disconnected') + setLinkedApps([]) + setAllApps([]) + showSuccess('Disconnected from development server') + } + + const loadApps = async () => { + try { + setIsLoadingApps(true) + + // Load all apps + const allAppsResponse = await fetch(`${serverUrl}/api/apps`) + if (allAppsResponse.ok) { + const { apps } = await allAppsResponse.json() + setAllApps(apps || []) + } + + // Load linked apps for current world + const worldUrl = world.network?.apiUrl.split("/api")[0] + const linkedAppsResponse = await fetch(`${serverUrl}/api/linked-apps?worldUrl=${encodeURIComponent(worldUrl)}`) + if (linkedAppsResponse.ok) { + const { apps } = await linkedAppsResponse.json() + setLinkedApps(apps || []) + } + } catch (error) { + console.warn('Failed to load apps:', error) + showError('Failed to load apps list') + } finally { + setIsLoadingApps(false) + } + } + + const connectWithCustomPort = async () => { + const newUrl = `http://localhost:${customPort}` + setServerUrl(newUrl) + + // Update AppServerClient connection + world.appServerClient.setServerUrl(newUrl) + + await checkConnection() + } + + const unlinkApp = async (appName) => { + try { + setActionLoadingState(`unlink-${appName}`, true) + + const response = await fetch(`${serverUrl}/api/apps/${appName}/unlink`, { + method: 'POST' + }) + + if (response.ok) { + showSuccess(`${appName} unlinked successfully`) + await loadApps() + } else { + throw new Error('Failed to unlink app') + } + } catch (error) { + console.error(`❌ Failed to unlink ${appName}:`, error) + showError(`Failed to unlink ${appName}: ${error.message}`) + } finally { + setActionLoadingState(`unlink-${appName}`, false) + } + } + + const pushApp = async (appName) => { + try { + setActionLoadingState(`push-${appName}`, true) + + const response = await fetch(`${serverUrl}/api/apps/${appName}/deploy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + position: [0, 0, 0] + }) + }) + + if (response.ok) { + showSuccess(`${appName} deployed successfully`) + } else { + throw new Error('Failed to deploy app') + } + } catch (error) { + console.error(`❌ Failed to deploy ${appName}:`, error) + showError(`Failed to deploy ${appName}: ${error.message}`) + } finally { + setActionLoadingState(`push-${appName}`, false) + } + } + + return ( +
+ + + {} : setCustomPort} + /> + + {connectionStatus !== 'connected' && ( + + )} + + {connectionStatus === 'connected' && ( + + )} + + + + {/* Connection Status Display */} +
+ {connectionStatus === 'connected' && } + {connectionStatus === 'disconnected' && } + {connectionStatus === 'error' && } + {connectionStatus === 'connected' && `Connected to ${serverUrl}`} + {connectionStatus === 'disconnected' && 'Not connected to development server'} + {connectionStatus === 'error' && 'Connection error'} +
+ + {/* Success Message */} + {lastSuccess && ( +
+ + {lastSuccess} +
+ )} + + {/* Error Message */} + {lastError && ( +
+ + {lastError} +
+ )} + + + + {/* Tab Navigation */} +
+
setActiveTab('linked')} + css={css` + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &.active { + color: white; + border-bottom-color: #10b981; + } + `} + > + Linked ({linkedApps.length}) +
+
setActiveTab('all')} + css={css` + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &.active { + color: white; + border-bottom-color: #10b981; + } + `} + > + All ({allApps.length}) +
+
+ + {/* Tab Content */} + {activeTab === 'linked' && ( + <> + {linkedApps.length === 0 ? ( +
+ No apps linked yet.
+ Use the link button in app inspectors to connect apps. +
+ ) : ( +
+ {linkedApps.map((app) => ( +
+
+
{app.name}
+
+ {app.assets.length} assets • {app.script ? 'Has script' : 'No script'} +
+
+
+
!actionLoading[`push-${app.name}`] && pushApp(app.name)} + title="Deploy local changes to world" + style={{ opacity: actionLoading[`push-${app.name}`] ? 0.5 : 1 }} + > + {actionLoading[`push-${app.name}`] ? ( + + ) : ( + + )} +
+
{ + if (!actionLoading[`unlink-${app.name}`] && confirm(`Unlink ${app.name}? This will remove the connection but keep the local files.`)) { + unlinkApp(app.name) + } + }} + title="Unlink app from development server" + style={{ opacity: actionLoading[`unlink-${app.name}`] ? 0.5 : 1 }} + > + {actionLoading[`unlink-${app.name}`] ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} + + )} + + {activeTab === 'all' && ( + <> + {allApps.length === 0 ? ( +
+ No apps available on development server.
+ Create new apps or download them from the world. +
+ ) : ( +
+ {allApps.map((app) => ( +
+
+
{app.name}
+
+ {app.assets.length} assets • {app.script ? 'Has script' : 'No script'} +
+
+
+
!actionLoading[`push-${app.name}`] && pushApp(app.name)} + title="Deploy app to world" + style={{ opacity: actionLoading[`push-${app.name}`] ? 0.5 : 1 }} + > + {actionLoading[`push-${app.name}`] ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} + + )} + + + + +
+ ) +} diff --git a/src/client/components/ScriptEditor.js b/src/client/components/ScriptEditor.js index 92e523b3..a3439dcf 100644 --- a/src/client/components/ScriptEditor.js +++ b/src/client/components/ScriptEditor.js @@ -118,6 +118,64 @@ export function ScriptEditor({ app, onHandle }) { dead = true } }, []) + + // Listen for blueprint modifications to update editor content + useEffect(() => { + if (!editor) return + + const onBlueprintModify = bp => { + // Only update if this is the same blueprint as our current app + console.log('debug: blueprint modified', {bp, app}) + if (bp.id !== app.blueprint.id) return + console.log('isSelectedBlueprint') + // Load the new script content + const loadNewScript = async () => { + try { + let newCode = '// …' + if (bp.script) { + // Load the script using the world loader + let script = app.world.loader.get('script', bp.script) + if (!script) { + script = await app.world.loader.load('script', bp.script) + } + if (script?.code) { + newCode = script.code + } + } + + // Check if editor content has unsaved changes + // const currentCode = editor.getValue() + // const hasUnsavedChanges = currentCode !== (app.script?.code ?? '// …') + + // if (hasUnsavedChanges) { + // // Ask user if they want to discard changes + // const shouldDiscard = confirm('The script has been updated externally. Discard your local changes and load the new version?') + // if (!shouldDiscard) return + // } + + // Update the editor with new content + editor.setValue(newCode) + codeRef.current = newCode + + // Clear cached state since we're loading new content + if (cached.key === key) { + cached.value = newCode + cached.viewState = null + } + } catch (error) { + console.error('Failed to load updated script:', error) + } + } + + loadNewScript() + } + + app.world.blueprints.on('modify', onBlueprintModify) + + return () => { + app.world.blueprints.off('modify', onBlueprintModify) + } + }, [editor, app, key]) return (
+ {env.PUBLIC_DEV_SERVER === 'true' && + world.ui.togglePane('devtools')} + > + + + } )} {ui.app && ( @@ -246,6 +258,7 @@ export function Sidebar({ world, ui }) { {ui.pane === 'world' &&