mirror of https://github.com/jetkvm/kvm.git
				
				
				
			feat(cloud): Add support for custom cloud app URL configuration
- Extend CloudState and Config to include CloudAppURL - Update RPC methods to handle both API and app URLs - Modify cloud adoption and settings routes to support custom app URLs - Remove hardcoded cloud app URL environment file - Simplify cloud URL configuration in UI
This commit is contained in:
		
							parent
							
								
									482c64ad02
								
							
						
					
					
						commit
						69a25ce1e9
					
				
							
								
								
									
										2
									
								
								cloud.go
								
								
								
								
							
							
						
						
									
										2
									
								
								cloud.go
								
								
								
								
							|  | @ -264,12 +264,14 @@ func RunWebsocketClient() { | |||
| type CloudState struct { | ||||
| 	Connected bool   `json:"connected"` | ||||
| 	URL       string `json:"url,omitempty"` | ||||
| 	AppURL    string `json:"appUrl,omitempty"` | ||||
| } | ||||
| 
 | ||||
| func rpcGetCloudState() CloudState { | ||||
| 	return CloudState{ | ||||
| 		Connected: config.CloudToken != "" && config.CloudURL != "", | ||||
| 		URL:       config.CloudURL, | ||||
| 		AppURL:    config.CloudAppURL, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ type UsbConfig struct { | |||
| 
 | ||||
| type Config struct { | ||||
| 	CloudURL             string            `json:"cloud_url"` | ||||
| 	CloudAppURL          string            `json:"cloud_app_url"` | ||||
| 	CloudToken           string            `json:"cloud_token"` | ||||
| 	GoogleIdentity       string            `json:"google_identity"` | ||||
| 	JigglerEnabled       bool              `json:"jiggler_enabled"` | ||||
|  | @ -43,6 +44,7 @@ const configPath = "/userdata/kvm_config.json" | |||
| 
 | ||||
| var defaultConfig = &Config{ | ||||
| 	CloudURL:             "https://api.jetkvm.com", | ||||
| 	CloudAppURL:          "https://app.jetkvm.com", | ||||
| 	AutoUpdateEnabled:    true, // Set a default value
 | ||||
| 	ActiveExtension:      "", | ||||
| 	DisplayMaxBrightness: 64, | ||||
|  |  | |||
							
								
								
									
										35
									
								
								jsonrpc.go
								
								
								
								
							
							
						
						
									
										35
									
								
								jsonrpc.go
								
								
								
								
							|  | @ -753,35 +753,15 @@ func rpcSetSerialSettings(settings SerialSettings) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetCloudUrl(url string) error { | ||||
| 	if url == "" { | ||||
| 		// Reset to default by removing from config
 | ||||
| 		config.CloudURL = defaultConfig.CloudURL | ||||
| 	} else { | ||||
| 		config.CloudURL = url | ||||
| 	} | ||||
| func rpcSetCloudUrl(apiUrl string, appUrl string) (bool, error) { | ||||
| 	config.CloudURL = apiUrl | ||||
| 	config.CloudAppURL = appUrl | ||||
| 
 | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 		return false, err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcGetCloudUrl() (string, error) { | ||||
| 	return config.CloudURL, nil | ||||
| } | ||||
| 
 | ||||
| func rpcResetCloudUrl() error { | ||||
| 	// Reset to default by removing from config
 | ||||
| 	config.CloudURL = defaultConfig.CloudURL | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to reset cloud URL: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcGetDefaultCloudUrl() (string, error) { | ||||
| 	return defaultConfig.CloudURL, nil | ||||
| 	return true, nil | ||||
| } | ||||
| 
 | ||||
| var rpcHandlers = map[string]RPCHandler{ | ||||
|  | @ -842,8 +822,5 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"setATXPowerAction":      {Func: rpcSetATXPowerAction, Params: []string{"action"}}, | ||||
| 	"getSerialSettings":      {Func: rpcGetSerialSettings}, | ||||
| 	"setSerialSettings":      {Func: rpcSetSerialSettings, Params: []string{"settings"}}, | ||||
| 	"setCloudUrl":            {Func: rpcSetCloudUrl, Params: []string{"url"}}, | ||||
| 	"getCloudUrl":            {Func: rpcGetCloudUrl}, | ||||
| 	"resetCloudUrl":          {Func: rpcResetCloudUrl}, | ||||
| 	"getDefaultCloudUrl":     {Func: rpcGetDefaultCloudUrl}, | ||||
| 	"setCloudUrl":            {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, | ||||
| } | ||||
|  |  | |||
|  | @ -1,2 +0,0 @@ | |||
| # Used in settings page to know where to link to when user wants to adopt a device to the cloud | ||||
| VITE_CLOUD_APP=http://app.jetkvm.com | ||||
|  | @ -177,12 +177,11 @@ if (isOnDevice) { | |||
|         }, | ||||
|       ], | ||||
|     }, | ||||
| 
 | ||||
|     { | ||||
|       path: "/adopt", | ||||
|       element: <AdoptRoute />, | ||||
|       loader: AdoptRoute.loader, | ||||
|       errorElement: <ErrorBoundary />, | ||||
|       loader: AdoptRoute.loader, | ||||
|     }, | ||||
|   ]); | ||||
| } else { | ||||
|  |  | |||
|  | @ -1,6 +1,12 @@ | |||
| import { LoaderFunctionArgs, redirect } from "react-router-dom"; | ||||
| import api from "../api"; | ||||
| import { CLOUD_APP, DEVICE_API } from "@/ui.config"; | ||||
| import { DEVICE_API } from "@/ui.config"; | ||||
| 
 | ||||
| export interface CloudState { | ||||
|   connected: boolean; | ||||
|   url: string; | ||||
|   appUrl: string; | ||||
| } | ||||
| 
 | ||||
| const loader = async ({ request }: LoaderFunctionArgs) => { | ||||
|   const url = new URL(request.url); | ||||
|  | @ -11,14 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => { | |||
|   const oidcGoogle = searchParams.get("oidcGoogle"); | ||||
|   const clientId = searchParams.get("clientId"); | ||||
| 
 | ||||
|   const res = await api.POST(`${DEVICE_API}/cloud/register`, { | ||||
|     token: tempToken, | ||||
|     oidcGoogle, | ||||
|     clientId, | ||||
|   }); | ||||
|   const [cloudStateResponse, registerResponse] = await Promise.all([ | ||||
|     api.GET(`${DEVICE_API}/cloud/state`), | ||||
|     api.POST(`${DEVICE_API}/cloud/register`, { | ||||
|       token: tempToken, | ||||
|       oidcGoogle, | ||||
|       clientId, | ||||
|     }), | ||||
|   ]); | ||||
| 
 | ||||
|   if (!res.ok) throw new Error("Failed to register device"); | ||||
|   return redirect(CLOUD_APP + `/devices/${deviceId}/setup`); | ||||
|   if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state"); | ||||
|   const cloudState = (await cloudStateResponse.json()) as CloudState; | ||||
| 
 | ||||
|   if (!registerResponse.ok) throw new Error("Failed to register device"); | ||||
| 
 | ||||
|   return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`); | ||||
| }; | ||||
| 
 | ||||
| export default function AdoptRoute() { | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; | |||
| import { SettingsItem } from "./devices.$id.settings"; | ||||
| import { useLoaderData, useNavigate } from "react-router-dom"; | ||||
| import { Button, LinkButton } from "../components/Button"; | ||||
| import { CLOUD_APP, DEVICE_API } from "../ui.config"; | ||||
| import { DEVICE_API } from "../ui.config"; | ||||
| import api from "../api"; | ||||
| import { LocalDevice } from "./devices.$id"; | ||||
| import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; | ||||
|  | @ -15,6 +15,7 @@ import { InputFieldWithLabel } from "../components/InputField"; | |||
| import { SelectMenuBasic } from "../components/SelectMenuBasic"; | ||||
| import { SettingsSectionHeader } from "../components/SettingsSectionHeader"; | ||||
| import { isOnDevice } from "../main"; | ||||
| import { CloudState } from "./adopt"; | ||||
| 
 | ||||
| export const loader = async () => { | ||||
|   if (isOnDevice) { | ||||
|  | @ -26,6 +27,22 @@ export const loader = async () => { | |||
|   return null; | ||||
| }; | ||||
| 
 | ||||
| // Define hardcoded cloud providers with both API and app URLs
 | ||||
| const CLOUD_PROVIDERS = [ | ||||
|   { | ||||
|     value: "jetkvm", | ||||
|     apiUrl: "https://api.jetkvm.com", | ||||
|     appUrl: "https://app.jetkvm.com", | ||||
|     label: "JetKVM Cloud", | ||||
|   }, | ||||
|   { | ||||
|     value: "custom", | ||||
|     apiUrl: "", | ||||
|     appUrl: "", | ||||
|     label: "Custom", | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| export default function SettingsAccessIndexRoute() { | ||||
|   const loaderData = useLoaderData() as LocalDevice | null; | ||||
| 
 | ||||
|  | @ -36,38 +53,29 @@ export default function SettingsAccessIndexRoute() { | |||
| 
 | ||||
|   const [isAdopted, setAdopted] = useState(false); | ||||
|   const [deviceId, setDeviceId] = useState<string | null>(null); | ||||
|   const [cloudUrl, setCloudUrl] = useState(""); | ||||
|   const [cloudProviders, setCloudProviders] = useState< | ||||
|     { value: string; label: string }[] | null | ||||
|   >([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]); | ||||
|   const [cloudApiUrl, setCloudApiUrl] = useState("https://api.jetkvm.com"); | ||||
|   const [cloudAppUrl, setCloudAppUrl] = useState("https://app.jetkvm.com"); | ||||
| 
 | ||||
|   // The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers
 | ||||
|   const [selectedUrlOption, setSelectedUrlOption] = useState<string>( | ||||
|     "https://api.jetkvm.com", | ||||
|   ); | ||||
| 
 | ||||
|   const [defaultCloudUrl, setDefaultCloudUrl] = useState<string>(""); | ||||
| 
 | ||||
|   const syncCloudUrl = useCallback(() => { | ||||
|     send("getCloudUrl", {}, resp => { | ||||
|       if ("error" in resp) return; | ||||
|       const url = resp.result as string; | ||||
|       setCloudUrl(url); | ||||
|       // Check if the URL matches any predefined option
 | ||||
|       if (cloudProviders?.some(provider => provider.value === url)) { | ||||
|         setSelectedUrlOption(url); | ||||
|       } else { | ||||
|         setSelectedUrlOption("custom"); | ||||
|         // setCustomCloudUrl(url);
 | ||||
|       } | ||||
|     }); | ||||
|   }, [cloudProviders, send]); | ||||
|   // Use a simple string identifier for the selected provider
 | ||||
|   const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm"); | ||||
| 
 | ||||
|   const getCloudState = useCallback(() => { | ||||
|     send("getCloudState", {}, resp => { | ||||
|       if ("error" in resp) return console.error(resp.error); | ||||
|       const cloudState = resp.result as { connected: boolean }; | ||||
|       const cloudState = resp.result as CloudState; | ||||
| 
 | ||||
|       setAdopted(cloudState.connected); | ||||
|       setCloudApiUrl(cloudState.url); | ||||
| 
 | ||||
|       if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl); | ||||
| 
 | ||||
|       // Find if the API URL matches any of our predefined providers
 | ||||
|       const matchingProvider = CLOUD_PROVIDERS.find(p => p.apiUrl === cloudState.url); | ||||
|       if (matchingProvider && matchingProvider.value !== "custom") { | ||||
|         setSelectedProvider(matchingProvider.value); | ||||
|       } else { | ||||
|         setSelectedProvider("custom"); | ||||
|       } | ||||
|     }); | ||||
|   }, [send]); | ||||
| 
 | ||||
|  | @ -87,43 +95,44 @@ export default function SettingsAccessIndexRoute() { | |||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const onCloudAdoptClick = useCallback( | ||||
|     (url: string) => { | ||||
|       if (!deviceId) { | ||||
|         notifications.error("No device ID available"); | ||||
|   const onCloudAdoptClick = useCallback(() => { | ||||
|     if (!deviceId) { | ||||
|       notifications.error("No device ID available"); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => { | ||||
|       if ("error" in resp) { | ||||
|         notifications.error( | ||||
|           `Failed to update cloud URL: ${resp.error.data || "Unknown error"}`, | ||||
|         ); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       send("setCloudUrl", { url }, resp => { | ||||
|         if ("error" in resp) { | ||||
|           notifications.error( | ||||
|             `Failed to update cloud URL: ${resp.error.data || "Unknown error"}`, | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|         syncCloudUrl(); | ||||
|         notifications.success("Cloud URL updated successfully"); | ||||
|       getCloudState(); | ||||
| 
 | ||||
|         const returnTo = new URL(window.location.href); | ||||
|         returnTo.pathname = "/adopt"; | ||||
|         returnTo.search = ""; | ||||
|         returnTo.hash = ""; | ||||
|         window.location.href = | ||||
|           CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`; | ||||
|       }); | ||||
|     }, | ||||
|     [deviceId, syncCloudUrl, send], | ||||
|   ); | ||||
|       const returnTo = new URL(window.location.href); | ||||
|       returnTo.pathname = "/adopt"; | ||||
|       returnTo.search = ""; | ||||
|       returnTo.hash = ""; | ||||
|       window.location.href = | ||||
|         cloudAppUrl + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`; | ||||
|     }); | ||||
|   }, [deviceId, getCloudState, send, cloudApiUrl, cloudAppUrl]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!defaultCloudUrl) return; | ||||
|     setSelectedUrlOption(defaultCloudUrl); | ||||
|     setCloudProviders([ | ||||
|       { value: defaultCloudUrl, label: "JetKVM Cloud" }, | ||||
|       { value: "custom", label: "Custom" }, | ||||
|     ]); | ||||
|   }, [defaultCloudUrl]); | ||||
|   // Handle provider selection change
 | ||||
|   const handleProviderChange = (value: string) => { | ||||
|     setSelectedProvider(value); | ||||
| 
 | ||||
|     // If selecting a predefined provider, update both URLs
 | ||||
|     const provider = CLOUD_PROVIDERS.find(p => p.value === value); | ||||
|     if (provider && value !== "custom") { | ||||
|       setCloudApiUrl(provider.apiUrl); | ||||
|       setCloudAppUrl(provider.appUrl); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   // Fetch device ID and cloud state on component mount
 | ||||
|   useEffect(() => { | ||||
|     getCloudState(); | ||||
| 
 | ||||
|  | @ -133,18 +142,6 @@ export default function SettingsAccessIndexRoute() { | |||
|     }); | ||||
|   }, [send, getCloudState]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     send("getDefaultCloudUrl", {}, resp => { | ||||
|       if ("error" in resp) return console.error(resp.error); | ||||
|       setDefaultCloudUrl(resp.result as string); | ||||
|     }); | ||||
|   }, [cloudProviders, syncCloudUrl, send]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!cloudProviders?.length) return; | ||||
|     syncCloudUrl(); | ||||
|   }, [cloudProviders, syncCloudUrl]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="space-y-4"> | ||||
|       <SettingsPageHeader | ||||
|  | @ -219,34 +216,39 @@ export default function SettingsAccessIndexRoute() { | |||
|               > | ||||
|                 <SelectMenuBasic | ||||
|                   size="SM" | ||||
|                   value={selectedUrlOption} | ||||
|                   onChange={e => { | ||||
|                     const value = e.target.value; | ||||
|                     setSelectedUrlOption(value); | ||||
|                   }} | ||||
|                   options={cloudProviders ?? []} | ||||
|                   value={selectedProvider} | ||||
|                   onChange={e => handleProviderChange(e.target.value)} | ||||
|                   options={CLOUD_PROVIDERS.map(p => ({ value: p.value, label: p.label }))} | ||||
|                 /> | ||||
|               </SettingsItem> | ||||
| 
 | ||||
|               {selectedUrlOption === "custom" && ( | ||||
|                 <div className="mt-4 flex items-end gap-x-2 space-y-4"> | ||||
|                   <InputFieldWithLabel | ||||
|                     size="SM" | ||||
|                     label="Custom Cloud URL" | ||||
|                     value={cloudUrl} | ||||
|                     onChange={e => setCloudUrl(e.target.value)} | ||||
|                     placeholder="https://api.example.com" | ||||
|                   /> | ||||
|               {selectedProvider === "custom" && ( | ||||
|                 <div className="mt-4 space-y-4"> | ||||
|                   <div className="flex items-end gap-x-2"> | ||||
|                     <InputFieldWithLabel | ||||
|                       size="SM" | ||||
|                       label="Cloud API URL" | ||||
|                       value={cloudApiUrl} | ||||
|                       onChange={e => setCloudApiUrl(e.target.value)} | ||||
|                       placeholder="https://api.example.com" | ||||
|                     /> | ||||
|                   </div> | ||||
|                   <div className="flex items-end gap-x-2"> | ||||
|                     <InputFieldWithLabel | ||||
|                       size="SM" | ||||
|                       label="Cloud App URL" | ||||
|                       value={cloudAppUrl} | ||||
|                       onChange={e => setCloudAppUrl(e.target.value)} | ||||
|                       placeholder="https://app.example.com" | ||||
|                     /> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               )} | ||||
|             </> | ||||
|           )} | ||||
| 
 | ||||
|           {/* | ||||
|                 We do the harcoding here to avoid flickering when the default Cloud URL being fetched. | ||||
|                 I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now. | ||||
|               */} | ||||
|           {selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && ( | ||||
|           {/* Show security info for JetKVM Cloud */} | ||||
|           {selectedProvider === "jetkvm" && ( | ||||
|             <GridCard> | ||||
|               <div className="flex items-start gap-x-4 p-4"> | ||||
|                 <ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" /> | ||||
|  | @ -295,7 +297,7 @@ export default function SettingsAccessIndexRoute() { | |||
|           {!isAdopted ? ( | ||||
|             <div className="flex items-end gap-x-2"> | ||||
|               <Button | ||||
|                 onClick={() => onCloudAdoptClick(cloudUrl)} | ||||
|                 onClick={onCloudAdoptClick} | ||||
|                 size="SM" | ||||
|                 theme="primary" | ||||
|                 text="Adopt KVM to Cloud" | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| export const CLOUD_API = import.meta.env.VITE_CLOUD_API; | ||||
| export const CLOUD_APP = import.meta.env.VITE_CLOUD_APP; | ||||
| 
 | ||||
| // In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
 | ||||
| export const DEVICE_API = ""; | ||||
|  |  | |||
							
								
								
									
										11
									
								
								web.go
								
								
								
								
							
							
						
						
									
										11
									
								
								web.go
								
								
								
								
							|  | @ -100,6 +100,7 @@ func setupRouter() *gin.Engine { | |||
| 	{ | ||||
| 		protected.POST("/webrtc/session", handleWebRTCSession) | ||||
| 		protected.POST("/cloud/register", handleCloudRegister) | ||||
| 		protected.GET("/cloud/state", handleCloudState) | ||||
| 		protected.GET("/device", handleDevice) | ||||
| 		protected.POST("/auth/logout", handleLogout) | ||||
| 
 | ||||
|  | @ -359,6 +360,16 @@ func handleDeviceStatus(c *gin.Context) { | |||
| 	c.JSON(http.StatusOK, response) | ||||
| } | ||||
| 
 | ||||
| func handleCloudState(c *gin.Context) { | ||||
| 	response := CloudState{ | ||||
| 		Connected: config.CloudToken != "", | ||||
| 		URL:       config.CloudURL, | ||||
| 		AppURL:    config.CloudAppURL, | ||||
| 	} | ||||
| 
 | ||||
| 	c.JSON(http.StatusOK, response) | ||||
| } | ||||
| 
 | ||||
| func handleDeviceUIConfig(c *gin.Context) { | ||||
| 	config, _ := json.Marshal(gin.H{ | ||||
| 		"CLOUD_API":      config.CloudURL, | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue