diff --git a/cloud.go b/cloud.go index 5cf00c2..628837b 100644 --- a/cloud.go +++ b/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, } } diff --git a/config.go b/config.go index 38a2b5b..f12ab45 100644 --- a/config.go +++ b/config.go @@ -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, diff --git a/jsonrpc.go b/jsonrpc.go index 9d10cb4..86db0ad 100644 --- a/jsonrpc.go +++ b/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"}}, } diff --git a/ui/.env.device b/ui/.env.device deleted file mode 100644 index c8d7c4f..0000000 --- a/ui/.env.device +++ /dev/null @@ -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 diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 0754a48..09a7172 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -177,12 +177,11 @@ if (isOnDevice) { }, ], }, - { path: "/adopt", element: , - loader: AdoptRoute.loader, errorElement: , + loader: AdoptRoute.loader, }, ]); } else { diff --git a/ui/src/routes/adopt.tsx b/ui/src/routes/adopt.tsx index 2aacf44..8aa7555 100644 --- a/ui/src/routes/adopt.tsx +++ b/ui/src/routes/adopt.tsx @@ -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() { diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 4beae1c..5074273 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -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(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( - "https://api.jetkvm.com", - ); - - const [defaultCloudUrl, setDefaultCloudUrl] = useState(""); - - 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("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 ( { - 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 }))} /> - {selectedUrlOption === "custom" && ( - - setCloudUrl(e.target.value)} - placeholder="https://api.example.com" - /> + {selectedProvider === "custom" && ( + + + setCloudApiUrl(e.target.value)} + placeholder="https://api.example.com" + /> + + + setCloudAppUrl(e.target.value)} + placeholder="https://app.example.com" + /> + )} > )} - {/* - 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" && ( @@ -295,7 +297,7 @@ export default function SettingsAccessIndexRoute() { {!isAdopted ? ( onCloudAdoptClick(cloudUrl)} + onClick={onCloudAdoptClick} size="SM" theme="primary" text="Adopt KVM to Cloud" diff --git a/ui/src/ui.config.ts b/ui/src/ui.config.ts index d82a6da..b76dd7c 100644 --- a/ui/src/ui.config.ts +++ b/ui/src/ui.config.ts @@ -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 = ""; diff --git a/web.go b/web.go index 63d8f6a..dea3e17 100644 --- a/web.go +++ b/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,