feat(cloud): Add custom cloud API URL configuration support (#181)

* feat(cloud): Add custom cloud API URL configuration support

- Implement RPC methods to set, get, and reset cloud URL
- Update cloud registration to remove hardcoded cloud API URL
- Modify UI to allow configuring custom cloud API URL in developer settings
- Remove environment-specific cloud configuration files
- Simplify cloud URL configuration in UI config

* fix(ui): Update cloud app URL to production environment in device mode

* refactor(ui): Remove SIGNAL_API env & Rename to DEVICE_API to make clear distinction between  CLOUD_API and DEVICE_API.

* feat(ui): Only show Cloud API URL Change on device mode

* fix(cloud): Don't override the CloudURL on deregistration from the cloud.
This commit is contained in:
Adam Shiervani 2025-02-25 16:10:46 +01:00 committed by GitHub
parent de5403eada
commit 7304e6b672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 201 additions and 113 deletions

View File

@ -96,7 +96,6 @@ func handleCloudRegister(c *gin.Context) {
} }
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI
provider, err := oidc.NewProvider(c, "https://accounts.google.com") provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil { if err != nil {
@ -298,8 +297,8 @@ func rpcDeregisterDevice() error {
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it. // (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) { if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
config.CloudToken = "" config.CloudToken = ""
config.CloudURL = ""
config.GoogleIdentity = "" config.GoogleIdentity = ""
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save configuration after deregistering: %w", err) return fmt.Errorf("failed to save configuration after deregistering: %w", err)
} }

View File

@ -730,6 +730,33 @@ func rpcSetSerialSettings(settings SerialSettings) error {
return nil return nil
} }
func rpcSetCloudUrl(url string) error {
if url == "" {
// Reset to default by removing from config
config.CloudURL = defaultConfig.CloudURL
} else {
config.CloudURL = url
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", 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
}
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
@ -786,4 +813,7 @@ var rpcHandlers = map[string]RPCHandler{
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}},
"getCloudUrl": {Func: rpcGetCloudUrl},
"resetCloudUrl": {Func: rpcResetCloudUrl},
} }

View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=http://localhost:3000

4
ui/.env.cloud-production Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://api.jetkvm.com

4
ui/.env.cloud-staging Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@ -1,6 +0,0 @@
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000
VITE_JETKVM_HEAD=

View File

@ -1,6 +1,2 @@
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint # Used in settings page to know where to link to when user wants to adopt a device to the cloud
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=<script src="/device/ui-config.js"></script>

View File

@ -1,6 +0,0 @@
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=

View File

@ -1,4 +0,0 @@
VITE_SIGNAL_API=https://staging-api.jetkvm.com
VITE_CLOUD_APP=https://staging-app.jetkvm.com
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@ -28,7 +28,6 @@
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
%VITE_JETKVM_HEAD%
<script> <script>
// Initial theme setup // Initial theme setup
document.documentElement.classList.toggle( document.documentElement.classList.toggle(

View File

@ -8,11 +8,11 @@
}, },
"scripts": { "scripts": {
"dev": "./dev_device.sh", "dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=development", "dev:cloud": "vite dev --mode=cloud-development",
"build": "npm run build:prod", "build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir", "build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=staging", "build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=production", "build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -14,7 +14,7 @@ import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api"; import api from "../api";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button"; import { Button, LinkButton } from "./Button";
import { CLOUD_API, SIGNAL_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -38,7 +38,7 @@ export default function DashboardNavbar({
const navigate = useNavigate(); const navigate = useNavigate();
const onLogout = useCallback(async () => { const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice const logoutUrl = isOnDevice
? `${SIGNAL_API}/auth/logout` ? `${DEVICE_API}/auth/logout`
: `${CLOUD_API}/logout`; : `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl); const res = await api.POST(logoutUrl);
if (!res.ok) return; if (!res.ok) return;

View File

@ -35,7 +35,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications"; import notifications from "../notifications";
import Fieldset from "./Fieldset"; import Fieldset from "./Fieldset";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
export default function MountMediaModal({ export default function MountMediaModal({
open, open,
@ -1120,7 +1120,7 @@ function UploadFileView({
alreadyUploadedBytes: number, alreadyUploadedBytes: number,
dataChannel: string, dataChannel: string,
) { ) {
const uploadUrl = `${SIGNAL_API}/storage/upload?uploadId=${dataChannel}`; const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true); xhr.open("POST", uploadUrl, true);

View File

@ -26,7 +26,8 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id"; import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom"; import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid"; import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { CLOUD_APP, SIGNAL_API } from "@/ui.config"; import { CLOUD_APP, DEVICE_API } from "@/ui.config";
import { InputFieldWithLabel } from "../InputField";
export function SettingsItem({ export function SettingsItem({
title, title,
@ -277,6 +278,51 @@ export default function SettingsSidebar() {
} }
}; };
const [cloudUrl, setCloudUrl] = useState("");
useEffect(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
setCloudUrl(resp.result as string);
});
}, [send]);
const getCloudUrl = useCallback(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
setCloudUrl(resp.result as string);
});
}, [send]);
const handleCloudUrlChange = useCallback(
(url: string) => {
send("setCloudUrl", { url }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudUrl();
notifications.success("Cloud URL updated successfully");
});
},
[send, getCloudUrl],
);
const handleResetCloudUrl = useCallback(() => {
send("resetCloudUrl", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudUrl();
notifications.success("Cloud URL reset to default successfully");
});
}, [send, getCloudUrl]);
useEffect(() => { useEffect(() => {
getCloudState(); getCloudState();
@ -363,12 +409,19 @@ export default function SettingsSidebar() {
if ("error" in resp) return; if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean); setUsbEmulationEnabled(resp.result as boolean);
}); });
}, [getCloudState, send, setBacklightSettings, setDeveloperMode, setHideCursor, setJiggler]); }, [
getCloudState,
send,
setBacklightSettings,
setDeveloperMode,
setHideCursor,
setJiggler,
]);
const getDevice = useCallback(async () => { const getDevice = useCallback(async () => {
try { try {
const status = await api const status = await api
.GET(`${SIGNAL_API}/device`) .GET(`${DEVICE_API}/device`)
.then(res => res.json() as Promise<LocalDevice>); .then(res => res.json() as Promise<LocalDevice>);
setLocalDevice(status); setLocalDevice(status);
} catch (error) { } catch (error) {
@ -920,6 +973,7 @@ export default function SettingsSidebar() {
</SettingsItem> </SettingsItem>
{settings.developerMode && ( {settings.developerMode && (
<div>
<div className="space-y-4"> <div className="space-y-4">
<TextAreaWithLabel <TextAreaWithLabel
label="SSH Public Key" label="SSH Public Key"
@ -940,6 +994,38 @@ export default function SettingsSidebar() {
/> />
</div> </div>
</div> </div>
{isOnDevice && (
<div className="mt-4 space-y-4">
<SettingsItem
title="Cloud API URL"
description="Connect to a custom JetKVM Cloud API"
/>
<InputFieldWithLabel
size="SM"
label="Cloud URL"
value={cloudUrl}
onChange={e => setCloudUrl(e.target.value)}
placeholder="https://api.jetkvm.com"
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Save Cloud URL"
onClick={() => handleCloudUrlChange(cloudUrl)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={handleResetCloudUrl}
/>
</div>
</div>
)}
</div>
)} )}
<SettingsItem <SettingsItem
title="Troubleshooting Mode" title="Troubleshooting Mode"

View File

@ -1,6 +1,6 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api"; import api from "../api";
import { CLOUD_API, CLOUD_APP, SIGNAL_API } from "@/ui.config"; import { CLOUD_APP, DEVICE_API } from "@/ui.config";
const loader = async ({ request }: LoaderFunctionArgs) => { const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
@ -11,15 +11,11 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const oidcGoogle = searchParams.get("oidcGoogle"); const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId"); const clientId = searchParams.get("clientId");
const res = await api.POST( const res = await api.POST(`${DEVICE_API}/cloud/register`, {
`${SIGNAL_API}/cloud/register`,
{
token: tempToken, token: tempToken,
cloudApi: CLOUD_API,
oidcGoogle, oidcGoogle,
clientId, clientId,
}, });
);
if (!res.ok) throw new Error("Failed to register device"); if (!res.ok) throw new Error("Failed to register device");
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`); return redirect(CLOUD_APP + `/devices/${deviceId}/setup`);

View File

@ -36,7 +36,7 @@ import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react"; import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal"; import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, SIGNAL_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
@ -57,12 +57,12 @@ export interface LocalDevice {
const deviceLoader = async () => { const deviceLoader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome"); if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${SIGNAL_API}/device`); const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local"); if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) { if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice; const device = (await deviceRes.json()) as LocalDevice;
@ -78,9 +78,7 @@ const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> =>
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`); const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json(); const iceConfig = await iceResp.json();
const deviceResp = await api.GET( const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`);
`${CLOUD_API}/devices/${params.id}`,
);
if (!deviceResp.ok) { if (!deviceResp.ok) {
if (deviceResp.status === 404) { if (deviceResp.status === 404) {
@ -143,7 +141,11 @@ export default function KvmIdRoute() {
try { try {
const sd = btoa(JSON.stringify(pc.localDescription)); const sd = btoa(JSON.stringify(pc.localDescription));
const res = await api.POST(`${SIGNAL_API}/webrtc/session`, {
const sessionUrl = isOnDevice
? `${DEVICE_API}/webrtc/session`
: `${CLOUD_API}/webrtc/session`;
const res = await api.POST(sessionUrl, {
sd, sd,
// When on device, we don't need to specify the device id, as it's already known // When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }), ...(isOnDevice ? {} : { id: params.id }),

View File

@ -12,16 +12,16 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import ExtLink from "../components/ExtLink"; import ExtLink from "../components/ExtLink";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome"); if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${SIGNAL_API}/device`); const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.ok) return redirect("/"); if (deviceRes.ok) return redirect("/");
return null; return null;
}; };
@ -32,7 +32,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
try { try {
const response = await api.POST( const response = await api.POST(
`${SIGNAL_API}/auth/login-local`, `${DEVICE_API}/auth/login-local`,
{ {
password, password,
}, },

View File

@ -9,11 +9,11 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local"); if (res.isSetup) return redirect("/login-local");
@ -31,7 +31,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
if (localAuthMode === "noPassword") { if (localAuthMode === "noPassword") {
try { try {
await api.POST(`${SIGNAL_API}/device/setup`, { await api.POST(`${DEVICE_API}/device/setup`, {
localAuthMode, localAuthMode,
}); });
return redirect("/"); return redirect("/");

View File

@ -10,11 +10,11 @@ import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local"); if (res.isSetup) return redirect("/login-local");
@ -31,7 +31,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
} }
try { try {
const response = await api.POST(`${SIGNAL_API}/device/setup`, { const response = await api.POST(`${DEVICE_API}/device/setup`, {
localAuthMode: "password", localAuthMode: "password",
password, password,
}); });

View File

@ -9,7 +9,7 @@ import LogoMark from "@/assets/logo-mark.png";
import { cx } from "cva"; import { cx } from "cva";
import api from "../api"; import api from "../api";
import { redirect } from "react-router-dom"; import { redirect } from "react-router-dom";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
export interface DeviceStatus { export interface DeviceStatus {
isSetup: boolean; isSetup: boolean;
@ -17,7 +17,7 @@ export interface DeviceStatus {
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local"); if (res.isSetup) return redirect("/login-local");

View File

@ -1,23 +1,5 @@
interface JetKVMConfig { export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
CLOUD_API?: string; export const CLOUD_APP = import.meta.env.VITE_CLOUD_APP;
CLOUD_APP?: string;
DEVICE_VERSION?: string;
}
declare global { // In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
interface Window { JETKVM_CONFIG?: JetKVMConfig; } export const DEVICE_API = "";
}
const getAppURL = (api_url?: string) => {
if (!api_url) {
return;
}
const url = new URL(api_url);
url.host = url.host.replace(/api\./, "app.");
// remove the ending slash
return url.toString().replace(/\/$/, "");
}
export const CLOUD_API = window.JETKVM_CONFIG?.CLOUD_API || import.meta.env.VITE_CLOUD_API;
export const CLOUD_APP = window.JETKVM_CONFIG?.CLOUD_APP || getAppURL(CLOUD_API) || import.meta.env.VITE_CLOUD_APP;
export const SIGNAL_API = import.meta.env.VITE_SIGNAL_API;

View File

@ -9,7 +9,7 @@ declare const process: {
}; };
export default defineConfig(({ mode, command }) => { export default defineConfig(({ mode, command }) => {
const isCloud = mode === "production"; const isCloud = mode.indexOf("cloud") !== -1;
const onDevice = mode === "device"; const onDevice = mode === "device";
const { JETKVM_PROXY_URL } = process.env; const { JETKVM_PROXY_URL } = process.env;
@ -18,15 +18,17 @@ export default defineConfig(({ mode, command }) => {
build: { outDir: isCloud ? "dist" : "../static" }, build: { outDir: isCloud ? "dist" : "../static" },
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
proxy: JETKVM_PROXY_URL ? { proxy: JETKVM_PROXY_URL
'/me': JETKVM_PROXY_URL, ? {
'/device': JETKVM_PROXY_URL, "/me": JETKVM_PROXY_URL,
'/webrtc': JETKVM_PROXY_URL, "/device": JETKVM_PROXY_URL,
'/auth': JETKVM_PROXY_URL, "/webrtc": JETKVM_PROXY_URL,
'/storage': JETKVM_PROXY_URL, "/auth": JETKVM_PROXY_URL,
'/cloud': JETKVM_PROXY_URL, "/storage": JETKVM_PROXY_URL,
} : undefined "/cloud": JETKVM_PROXY_URL,
}
: undefined,
}, },
base: onDevice && command === 'build' ? "/static" : "/", base: onDevice && command === "build" ? "/static" : "/",
}; };
}); });