Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -21,6 +21,7 @@ import {
|
||||
type ResponseStylePreset,
|
||||
} from '@/lib/responseStyle';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
|
||||
|
||||
@@ -69,7 +70,7 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
|
||||
};
|
||||
|
||||
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -104,12 +105,12 @@ export const BehaviorPage: React.FC = () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [settingsRes, agentsMdRes] = await Promise.all([
|
||||
fetch('/api/config/settings', {
|
||||
runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: abort.signal,
|
||||
}),
|
||||
fetch('/api/behavior/agents-md', {
|
||||
runtimeFetch('/api/behavior/agents-md', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: abort.signal,
|
||||
@@ -204,7 +205,7 @@ export const BehaviorPage: React.FC = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const content = normalizeAgentsMdContent(prompt);
|
||||
const response = await fetch('/api/behavior/agents-md', {
|
||||
const response = await runtimeFetch('/api/behavior/agents-md', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
|
||||
const value = params.get(key);
|
||||
@@ -42,7 +43,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
if (callbackStateKey) {
|
||||
void fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
void runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('error');
|
||||
setMessage(errorDescription ?? error);
|
||||
@@ -57,7 +58,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
|
||||
let pendingContext = callbackContext;
|
||||
if (!pendingContext && callbackStateKey) {
|
||||
const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
|
||||
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
|
||||
if (payload?.name?.trim()) {
|
||||
@@ -75,13 +76,13 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
|
||||
await completeAuth(pendingContext.name, code, pendingContext.directory);
|
||||
if (callbackStateKey) {
|
||||
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('success');
|
||||
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
|
||||
} catch (authError) {
|
||||
if (callbackStateKey) {
|
||||
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('error');
|
||||
setMessage(normalizeMcpAuthErrorMessage(authError, 'Failed to complete MCP authorization.'));
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from './mcpImport';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
@@ -501,7 +503,7 @@ const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, window.location.origin);
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
url.searchParams.set('server', name.trim());
|
||||
}
|
||||
@@ -516,7 +518,7 @@ const queuePendingMcpAuthContext = async (input: {
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
}): Promise<void> => {
|
||||
const response = await fetch('/api/mcp/auth/pending', {
|
||||
const response = await runtimeFetch('/api/mcp/auth/pending', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -533,7 +535,7 @@ const queuePendingMcpAuthContext = async (input: {
|
||||
};
|
||||
|
||||
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
|
||||
const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
|
||||
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -554,7 +556,7 @@ const clearPendingMcpAuthContext = async (stateKey: string | null | undefined):
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
};
|
||||
|
||||
const normalizeMcpAuthErrorMessage = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const getDisplayModel = (
|
||||
storedModel: string | undefined
|
||||
@@ -76,7 +77,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -131,7 +132,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ defaultModel: newValue }),
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
setDesktopLaunchAtLogin,
|
||||
} from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
export const DesktopNetworkSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -37,7 +39,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -123,7 +125,14 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(window.location.port);
|
||||
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const portSource = runtimeApiBaseUrl || window.location.href;
|
||||
let parsed = 0;
|
||||
try {
|
||||
parsed = Number(new URL(portSource).port);
|
||||
} catch {
|
||||
parsed = Number(window.location.port);
|
||||
}
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}, []);
|
||||
const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
|
||||
@@ -165,7 +174,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
type GitHubUser = {
|
||||
@@ -81,7 +82,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
const payload = runtimeGitHub
|
||||
? await runtimeGitHub.authStart()
|
||||
: await (async () => {
|
||||
const response = await fetch('/api/github/auth/start', {
|
||||
const response = await runtimeFetch('/api/github/auth/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -114,7 +115,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
return runtimeGitHub.authComplete(deviceCode) as Promise<DeviceFlowCompleteResponse>;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/github/auth/complete', {
|
||||
const response = await runtimeFetch('/api/github/auth/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -181,7 +182,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
if (runtimeGitHub) {
|
||||
await runtimeGitHub.authDisconnect();
|
||||
} else {
|
||||
const response = await fetch('/api/github/auth', {
|
||||
const response = await runtimeFetch('/api/github/auth', {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -206,7 +207,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
const payload = runtimeGitHub
|
||||
? await runtimeGitHub.authActivate(accountId)
|
||||
: await (async () => {
|
||||
const response = await fetch('/api/github/auth/activate', {
|
||||
const response = await runtimeFetch('/api/github/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export const GitSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -63,7 +64,7 @@ export const GitSettings: React.FC = () => {
|
||||
|
||||
// 2. Fetch API (Web/server)
|
||||
if (!data) {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -15,8 +15,19 @@ import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import type { OpenChamberSection } from './types';
|
||||
|
||||
const useRuntimeEndpointEpoch = (): number => {
|
||||
const [epoch, setEpoch] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
return subscribeRuntimeEndpointChanged(() => setEpoch((current) => current + 1));
|
||||
}, []);
|
||||
|
||||
return epoch;
|
||||
};
|
||||
|
||||
interface OpenChamberPageProps {
|
||||
/** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */
|
||||
section?: OpenChamberSection;
|
||||
@@ -24,8 +35,10 @@ interface OpenChamberPageProps {
|
||||
|
||||
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
|
||||
const showAbout = isMobile && isWebRuntime();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
void runtimeEndpointEpoch;
|
||||
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
|
||||
|
||||
// If no section specified, show all (mobile/legacy behavior)
|
||||
@@ -135,6 +148,8 @@ const ChatSectionContent: React.FC = () => {
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
const SessionsSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
|
||||
void runtimeEndpointEpoch;
|
||||
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -27,6 +28,7 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
|
||||
import { useI18n, type Locale } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
|
||||
import {
|
||||
setDirectoryShowHidden,
|
||||
useDirectoryShowHidden,
|
||||
@@ -129,6 +131,17 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
|
||||
{
|
||||
value: 'default',
|
||||
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
|
||||
},
|
||||
{
|
||||
value: 'new',
|
||||
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
|
||||
},
|
||||
];
|
||||
|
||||
type PwaInstallNameWindow = Window & {
|
||||
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
|
||||
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
|
||||
@@ -483,9 +496,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const hasThemeSettings = shouldShow('theme') && !isVSCode;
|
||||
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
|
||||
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
|
||||
const hasAppearanceSettings = isVSCode
|
||||
? hasLocalizationSettings
|
||||
: (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
|
||||
: (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
|
||||
const hasBehaviorSettings = shouldShow('mermaidRendering')
|
||||
@@ -509,6 +523,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
|
||||
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
|
||||
const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent();
|
||||
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
|
||||
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
|
||||
const selectedTimeFormatLabel = React.useMemo(() => {
|
||||
@@ -528,6 +543,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
return option ? tUnsafe(option.labelKey) : undefined;
|
||||
}, [mobileKeyboardMode, tUnsafe]);
|
||||
|
||||
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
|
||||
if (value === mobileLayoutPreference) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileLayoutPreference(value);
|
||||
setStoredMobileLayoutPreference(value);
|
||||
window.location.reload();
|
||||
}, [mobileLayoutPreference]);
|
||||
|
||||
const applyPwaInstallName = React.useCallback(async (value: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -578,7 +603,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
const loadPwaInstallName = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
@@ -656,6 +681,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMobileLayoutSetting && (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 py-1.5">
|
||||
<span className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.mobileLayout')}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{MOBILE_LAYOUT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={mobileLayoutPreference === option.value}
|
||||
className="!font-normal"
|
||||
onClick={() => handleMobileLayoutPreferenceChange(option.value)}
|
||||
>
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export const OpenCodeCliSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -22,7 +23,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { toast } from '@/components/ui';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -12,6 +13,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
type TunnelState =
|
||||
| 'checking'
|
||||
@@ -364,7 +366,14 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(window.location.port);
|
||||
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const portSource = runtimeApiBaseUrl || window.location.href;
|
||||
let parsed = 0;
|
||||
try {
|
||||
parsed = Number(new URL(portSource).port);
|
||||
} catch {
|
||||
parsed = Number(window.location.port);
|
||||
}
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -398,10 +407,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
|
||||
try {
|
||||
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/openchamber/tunnel/check', { signal }),
|
||||
fetch('/api/openchamber/tunnel/status', { signal }),
|
||||
fetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
|
||||
fetch('/api/openchamber/tunnel/providers', { signal }),
|
||||
runtimeFetch('/api/openchamber/tunnel/check', { signal }),
|
||||
runtimeFetch('/api/openchamber/tunnel/status', { signal }),
|
||||
runtimeFetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
|
||||
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
|
||||
]);
|
||||
|
||||
const checkData = await checkRes.json();
|
||||
@@ -614,7 +623,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
let cancelled = false;
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const statusRes = await fetch('/api/openchamber/tunnel/status');
|
||||
const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
|
||||
if (!statusRes.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
@@ -818,7 +827,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch('/api/openchamber/tunnel/start', {
|
||||
const res = await runtimeFetch('/api/openchamber/tunnel/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -913,8 +922,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
setState('stopping');
|
||||
|
||||
try {
|
||||
await fetch('/api/openchamber/tunnel/stop', { method: 'POST' });
|
||||
const statusRes = await fetch('/api/openchamber/tunnel/status');
|
||||
await runtimeFetch('/api/openchamber/tunnel/stop', { method: 'POST' });
|
||||
const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
|
||||
if (statusRes.ok) {
|
||||
const statusData = (await statusRes.json()) as TunnelStatusResponse;
|
||||
setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { audioStreamService } from '@/lib/voice/audioStreamService';
|
||||
import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService';
|
||||
import type { WasmModelStatus } from '@/lib/voice/wasmSttService';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { disposePreviewAudio } from './voicePreviewAudio';
|
||||
const LANGUAGE_OPTIONS = [
|
||||
@@ -278,7 +279,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
const checkOpenAIAvailability = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tts/status');
|
||||
const response = await runtimeFetch('/api/tts/status');
|
||||
const data = await response.json();
|
||||
const hasServerKey = data.available;
|
||||
const hasSettingsKey = openaiApiKey.trim().length > 0;
|
||||
@@ -298,7 +299,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/tts/say/status')
|
||||
runtimeFetch('/api/tts/say/status')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setIsSayAvailable(data.available);
|
||||
@@ -327,7 +328,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
setIsPreviewPlaying(true);
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
try {
|
||||
const response = await fetch('/api/tts/say/speak', {
|
||||
const response = await runtimeFetch('/api/tts/say/speak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -381,7 +382,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
setIsOpenAIPreviewPlaying(true);
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
try {
|
||||
const response = await fetch('/api/tts/speak', {
|
||||
const response = await runtimeFetch('/api/tts/speak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -441,7 +442,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
setIsCompatiblePreviewPlaying(true);
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
try {
|
||||
const response = await fetch('/api/tts/speak', {
|
||||
const response = await runtimeFetch('/api/tts/speak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -160,16 +160,8 @@ export const ProjectsPage: React.FC = () => {
|
||||
const hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
|
||||
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
|
||||
const hasRemovableImageIcon = effectiveHasImageIcon;
|
||||
const iconPreviewUrl = !previewImageFailed
|
||||
? (hasPendingUploadImageIcon
|
||||
? pendingUploadIconPreviewUrl
|
||||
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
|
||||
? getProjectIconImageUrl(selectedProject, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null))
|
||||
: null;
|
||||
const showStoredImagePreview = Boolean(selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon);
|
||||
const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
|
||||
|
||||
const handleUploadIcon = React.useCallback((file: File | null) => {
|
||||
if (!selectedProject || !file || isUploadingIcon) {
|
||||
@@ -368,7 +360,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{effectiveHasImageIcon && iconPreviewUrl && (
|
||||
{effectiveHasImageIcon && showImagePreview && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
|
||||
@@ -376,13 +368,25 @@ export const ProjectsPage: React.FC = () => {
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={iconPreviewUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setPreviewImageFailed(true)}
|
||||
/>
|
||||
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
|
||||
<img
|
||||
src={pendingUploadIconPreviewUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setPreviewImageFailed(true)}
|
||||
/>
|
||||
) : selectedProject ? (
|
||||
<ProjectIconImage
|
||||
project={selectedProject}
|
||||
options={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setPreviewImageFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
|
||||
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -18,7 +18,6 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
@@ -66,45 +65,32 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
{projects.map((project) => {
|
||||
const selected = project.id === selectedId;
|
||||
const iconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
|
||||
const imageUrl = brokenIconIds.has(imageFailureKey)
|
||||
? null
|
||||
: getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
});
|
||||
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
const icon = imageUrl
|
||||
? (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => {
|
||||
setBrokenIconIds((prev) => {
|
||||
if (prev.has(imageFailureKey)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
next.add(imageFailureKey);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
: iconName
|
||||
const fallbackIcon = iconName
|
||||
? (
|
||||
<Icon name={iconName} className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
|
||||
)
|
||||
: (
|
||||
<Icon name="folder" className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
|
||||
);
|
||||
const icon = project.iconImage
|
||||
? (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<ProjectIconImage
|
||||
project={project}
|
||||
options={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fallback={fallbackIcon}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
: fallbackIcon;
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
|
||||
@@ -21,6 +21,8 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
@@ -180,18 +182,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
const loadAuthMethods = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/provider/auth', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Auth methods request failed (${response.status})`);
|
||||
const result = await opencodeClient.getSdkClient().provider.auth();
|
||||
if (result.error) {
|
||||
throw new Error(`provider.auth failed: ${String(result.error)}`);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!isMounted) return;
|
||||
setAuthMethodsByProvider(parseAuthPayload(payload));
|
||||
setAuthMethodsByProvider(parseAuthPayload(result.data));
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load provider auth methods:', error);
|
||||
@@ -217,18 +213,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
setAvailableLoading(true);
|
||||
setAvailableError(null);
|
||||
try {
|
||||
const response = await fetch('/api/provider', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provider list request failed (${response.status})`);
|
||||
const result = await opencodeClient.getSdkClient().provider.list();
|
||||
if (result.error) {
|
||||
throw new Error(`provider.list failed: ${String(result.error)}`);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!isMounted) return;
|
||||
setAvailableProviders(parseProvidersPayload(payload));
|
||||
setAvailableProviders(parseProvidersPayload(result.data));
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load available providers:', error);
|
||||
@@ -292,7 +282,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const loadSources = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
|
||||
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
|
||||
// not local auth/source-file provenance used by this settings UI.
|
||||
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -337,16 +329,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'api', key: apiKey }),
|
||||
const result = await opencodeClient.getSdkClient().auth.set({
|
||||
providerID: providerId,
|
||||
auth: { type: 'api', key: apiKey },
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
|
||||
throw new Error(message);
|
||||
if (result.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.apiKeySaved'));
|
||||
@@ -366,20 +354,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: methodIndex }),
|
||||
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
|
||||
providerID: providerId,
|
||||
method: methodIndex,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
|
||||
throw new Error(message);
|
||||
if (result.error) {
|
||||
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
|
||||
}
|
||||
|
||||
const payloadRecord = isRecord(payload) ? payload : {};
|
||||
const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord;
|
||||
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
|
||||
const nestedData = payloadRecord.data;
|
||||
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
|
||||
const urlCandidate =
|
||||
(typeof dataRecord.url === 'string' && dataRecord.url) ||
|
||||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
|
||||
@@ -435,16 +420,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
requestBody.code = code;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
|
||||
providerID: providerId,
|
||||
method: requestBody.method,
|
||||
code: requestBody.code,
|
||||
});
|
||||
|
||||
const responsePayload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
|
||||
throw new Error(message);
|
||||
if (result.error) {
|
||||
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.oauthCompleted'));
|
||||
@@ -485,15 +467,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
|
||||
throw new Error(message);
|
||||
const result = await opencodeClient.getSdkClient().auth.remove({ providerID: providerId });
|
||||
if (result.error) {
|
||||
throw new Error(t('settings.providers.page.toast.providerDisconnectFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.providerDisconnected'));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
@@ -61,7 +62,9 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
const tasks = providers.map(async (provider) => {
|
||||
try {
|
||||
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
|
||||
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
|
||||
// not local auth/source-file provenance used by this settings sidebar.
|
||||
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
@@ -27,6 +28,9 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { RemoteClientRecord } from '@/lib/api/types';
|
||||
import { buildClientConnectionPayload, encodeClientConnectionPayload, parseClientConnectionPayload } from '@/lib/connectionPayload';
|
||||
import {
|
||||
desktopSshLogsClear,
|
||||
desktopSshLogs,
|
||||
@@ -34,6 +38,16 @@ import {
|
||||
type DesktopSshPortForward,
|
||||
type DesktopSshPortForwardType,
|
||||
} from '@/lib/desktopSsh';
|
||||
import {
|
||||
desktopHostsGet,
|
||||
desktopHostsSet,
|
||||
normalizeHostUrl,
|
||||
redactSensitiveUrl,
|
||||
resolveDesktopHostUrl,
|
||||
type DesktopHost,
|
||||
} from '@/lib/desktopHosts';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
|
||||
const randomPort = (): number => {
|
||||
return Math.floor(20000 + Math.random() * 30000);
|
||||
@@ -241,9 +255,12 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
|
||||
|
||||
export const RemoteInstancesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { clientAuth } = useRuntimeAPIs();
|
||||
const showInstanceManagement = isDesktopShell();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
|
||||
const isLoading = useDesktopSshStore((state) => state.isLoading);
|
||||
const isImportsLoading = useDesktopSshStore((state) => state.isImportsLoading);
|
||||
const isSaving = useDesktopSshStore((state) => state.isSaving);
|
||||
const error = useDesktopSshStore((state) => state.error);
|
||||
@@ -277,12 +294,276 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const [isPrimaryActionPending, setIsPrimaryActionPending] = React.useState(false);
|
||||
const [isRetryPending, setIsRetryPending] = React.useState(false);
|
||||
const [clockMs, setClockMs] = React.useState(() => Date.now());
|
||||
const [directHosts, setDirectHosts] = React.useState<DesktopHost[]>([]);
|
||||
const [directDefaultHostId, setDirectDefaultHostId] = React.useState<string | null>('local');
|
||||
const [directLoading, setDirectLoading] = React.useState(false);
|
||||
const [directSaving, setDirectSaving] = React.useState(false);
|
||||
const [directLabel, setDirectLabel] = React.useState('');
|
||||
const [directUrl, setDirectUrl] = React.useState('');
|
||||
const [directToken, setDirectToken] = React.useState('');
|
||||
const [directConnectLink, setDirectConnectLink] = React.useState('');
|
||||
const [directError, setDirectError] = React.useState<string | null>(null);
|
||||
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
|
||||
const [directImportDialogOpen, setDirectImportDialogOpen] = React.useState(false);
|
||||
const [directEditingId, setDirectEditingId] = React.useState<string | null>(null);
|
||||
const [directEditLabel, setDirectEditLabel] = React.useState('');
|
||||
const [directEditUrl, setDirectEditUrl] = React.useState('');
|
||||
const [directEditToken, setDirectEditToken] = React.useState('');
|
||||
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
|
||||
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
|
||||
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
|
||||
const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState<string | null>(null);
|
||||
const [remoteClientError, setRemoteClientError] = React.useState<string | null>(null);
|
||||
const [pairingUrl, setPairingUrl] = React.useState<string | null>(null);
|
||||
const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState<string | null>(null);
|
||||
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
|
||||
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
|
||||
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
|
||||
const [sshNameDraft, setSshNameDraft] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
void loadImports();
|
||||
}, [load, loadImports]);
|
||||
|
||||
const loadDirectHosts = React.useCallback(async () => {
|
||||
setDirectLoading(true);
|
||||
setDirectError(null);
|
||||
try {
|
||||
const config = await desktopHostsGet();
|
||||
setDirectHosts(config.hosts || []);
|
||||
setDirectDefaultHostId(config.defaultHostId || 'local');
|
||||
} catch (err) {
|
||||
setDirectError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDirectLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadDirectHosts();
|
||||
}, [loadDirectHosts]);
|
||||
|
||||
const persistDirectHosts = React.useCallback(async (hosts: DesktopHost[], defaultHostId: string | null = directDefaultHostId) => {
|
||||
setDirectSaving(true);
|
||||
setDirectError(null);
|
||||
try {
|
||||
await desktopHostsSet({ hosts, defaultHostId, initialHostChoiceCompleted: true });
|
||||
setDirectHosts(hosts);
|
||||
setDirectDefaultHostId(defaultHostId);
|
||||
} catch (err) {
|
||||
setDirectError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDirectSaving(false);
|
||||
}
|
||||
}, [directDefaultHostId]);
|
||||
|
||||
const handleAddDirectHost = React.useCallback(async () => {
|
||||
const resolved = resolveDesktopHostUrl(directUrl);
|
||||
if (!resolved) {
|
||||
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
const url = resolved.persistedUrl;
|
||||
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const host: DesktopHost = {
|
||||
id,
|
||||
label: directLabel.trim() || redactSensitiveUrl(url),
|
||||
url,
|
||||
apiUrl: url,
|
||||
...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
|
||||
};
|
||||
await persistDirectHosts([host, ...directHosts], directDefaultHostId);
|
||||
setDirectLabel('');
|
||||
setDirectUrl('');
|
||||
setDirectToken('');
|
||||
setDirectAddDialogOpen(false);
|
||||
if (resolved.redeemUrl) {
|
||||
navigateToUrl(resolved.redeemUrl);
|
||||
}
|
||||
}, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
|
||||
|
||||
const importDirectConnectLink = React.useCallback(async () => {
|
||||
const payload = parseClientConnectionPayload(directConnectLink);
|
||||
if (!payload) {
|
||||
setDirectError(t('settings.remoteInstances.direct.error.invalidConnectLink'));
|
||||
return;
|
||||
}
|
||||
const url = normalizeHostUrl(payload.serverUrl);
|
||||
if (!url) {
|
||||
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
const existing = directHosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === url);
|
||||
if (existing) {
|
||||
const nextHosts = directHosts.map((host) => host.id === existing.id
|
||||
? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: payload.token }
|
||||
: host);
|
||||
await persistDirectHosts(nextHosts, directDefaultHostId);
|
||||
} else {
|
||||
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
await persistDirectHosts([{ id, label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: payload.token }, ...directHosts], directDefaultHostId);
|
||||
}
|
||||
setDirectConnectLink('');
|
||||
setDirectError(null);
|
||||
setDirectImportDialogOpen(false);
|
||||
}, [directConnectLink, directDefaultHostId, directHosts, persistDirectHosts, t]);
|
||||
|
||||
const handleRemoveDirectHost = React.useCallback(async (id: string) => {
|
||||
const nextHosts = directHosts.filter((host) => host.id !== id);
|
||||
const nextDefault = directDefaultHostId === id ? 'local' : directDefaultHostId;
|
||||
await persistDirectHosts(nextHosts, nextDefault);
|
||||
if (directEditingId === id) {
|
||||
setDirectEditingId(null);
|
||||
}
|
||||
}, [directDefaultHostId, directEditingId, directHosts, persistDirectHosts]);
|
||||
|
||||
const beginEditDirectHost = React.useCallback((host: DesktopHost) => {
|
||||
setDirectEditingId(host.id);
|
||||
setDirectEditLabel(host.label);
|
||||
setDirectEditUrl(host.apiUrl || host.url);
|
||||
setDirectEditToken(host.clientToken || '');
|
||||
setDirectError(null);
|
||||
}, []);
|
||||
|
||||
const saveDirectHostEdit = React.useCallback(async () => {
|
||||
if (!directEditingId) return;
|
||||
const resolved = resolveDesktopHostUrl(directEditUrl);
|
||||
if (!resolved) {
|
||||
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
const url = resolved.persistedUrl;
|
||||
const nextHosts = directHosts.map((host) => host.id === directEditingId
|
||||
? {
|
||||
...host,
|
||||
label: directEditLabel.trim() || redactSensitiveUrl(url),
|
||||
url,
|
||||
apiUrl: url,
|
||||
clientToken: directEditToken.trim() || undefined,
|
||||
}
|
||||
: host);
|
||||
await persistDirectHosts(nextHosts, directDefaultHostId);
|
||||
setDirectEditingId(null);
|
||||
if (resolved.redeemUrl) {
|
||||
navigateToUrl(resolved.redeemUrl);
|
||||
}
|
||||
}, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
|
||||
|
||||
const createSshInstanceFromDialog = React.useCallback(async () => {
|
||||
const command = sshCommandDraft.trim();
|
||||
if (!command) {
|
||||
toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
|
||||
return;
|
||||
}
|
||||
const id = `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
setSshAddDialogOpen(false);
|
||||
setSshCommandDraft('ssh user@example.com');
|
||||
setSshNameDraft('');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
|
||||
} catch (error) {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
|
||||
|
||||
const setDefaultDirectHost = React.useCallback(async (id: string) => {
|
||||
await persistDirectHosts(directHosts, id);
|
||||
}, [directHosts, persistDirectHosts]);
|
||||
|
||||
const loadRemoteClients = React.useCallback(async () => {
|
||||
if (!clientAuth) return;
|
||||
setRemoteClientsLoading(true);
|
||||
setRemoteClientError(null);
|
||||
try {
|
||||
setRemoteClients(await clientAuth.listClients());
|
||||
} catch (err) {
|
||||
setRemoteClientError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRemoteClientsLoading(false);
|
||||
}
|
||||
}, [clientAuth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadRemoteClients();
|
||||
}, [loadRemoteClients]);
|
||||
|
||||
const createRemoteClient = React.useCallback(async () => {
|
||||
if (!clientAuth) return;
|
||||
setRemoteClientError(null);
|
||||
try {
|
||||
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || undefined });
|
||||
setCreatedRemoteClientToken(result.token);
|
||||
setRemoteClientLabel('');
|
||||
await loadRemoteClients();
|
||||
} catch (err) {
|
||||
setRemoteClientError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [clientAuth, loadRemoteClients, remoteClientLabel]);
|
||||
|
||||
const createPairingLink = React.useCallback(async () => {
|
||||
if (!clientAuth) return;
|
||||
setRemoteClientError(null);
|
||||
try {
|
||||
const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
|
||||
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
|
||||
const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
|
||||
const encoded = encodeClientConnectionPayload(payload);
|
||||
setCreatedRemoteClientToken(result.token);
|
||||
setPairingUrl(encoded);
|
||||
setPairingQrDataUrl(await QRCode.toDataURL(encoded, { width: 192, margin: 1 }));
|
||||
setRemoteClientLabel('');
|
||||
await loadRemoteClients();
|
||||
} catch (err) {
|
||||
setRemoteClientError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [clientAuth, loadRemoteClients, remoteClientLabel]);
|
||||
|
||||
const revokeRemoteClient = React.useCallback(async (client: RemoteClientRecord) => {
|
||||
if (!clientAuth) return;
|
||||
const isLocalDesktopClient = client.clientKind === 'desktop-local';
|
||||
setRemoteClientError(null);
|
||||
try {
|
||||
await clientAuth.revokeClient(client.id);
|
||||
if (isLocalDesktopClient && isDesktopShell()) {
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
hosts: config.hosts,
|
||||
defaultHostId: config.defaultHostId,
|
||||
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
|
||||
localClientToken: null,
|
||||
});
|
||||
setRemoteClients((clients) => clients.map((entry) => entry.id === client.id
|
||||
? { ...entry, revokedAt: new Date().toISOString() }
|
||||
: entry));
|
||||
switchRuntimeEndpoint({ apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: null, runtimeKey: 'local' });
|
||||
return;
|
||||
}
|
||||
await loadRemoteClients();
|
||||
} catch (err) {
|
||||
setRemoteClientError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [clientAuth, loadRemoteClients]);
|
||||
|
||||
const purgeRevokedRemoteClients = React.useCallback(async () => {
|
||||
if (!clientAuth) return;
|
||||
setRemoteClientError(null);
|
||||
try {
|
||||
await clientAuth.purgeRevokedClients();
|
||||
await loadRemoteClients();
|
||||
} catch (err) {
|
||||
setRemoteClientError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [clientAuth, loadRemoteClients]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(selectedInstance);
|
||||
}, [selectedInstance]);
|
||||
@@ -674,17 +955,271 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
if (!draft) {
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
|
||||
{clientAuth ? (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input className="h-8" value={remoteClientLabel} onChange={(event) => setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} />
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void createRemoteClient()}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.create')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void createPairingLink()}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.pair')}
|
||||
</Button>
|
||||
</div>
|
||||
{pairingUrl ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-[var(--interactive-border)] p-2 sm:flex-row">
|
||||
{pairingQrDataUrl ? <img src={pairingQrDataUrl} alt={t('settings.remoteInstances.clientAuth.qrAlt')} className="size-48 self-start" /> : null}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.pairingUrl')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{pairingUrl}</code>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void copyTextToClipboard(pairingUrl)}>
|
||||
<Icon name="file-copy" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{createdRemoteClientToken ? (
|
||||
<div className="space-y-1 rounded-md border border-[var(--interactive-border)] p-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.createdToken')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{createdRemoteClientToken}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{revokedClientCount > 0 ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.clearRevoked')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteClientsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.loading')}</p>
|
||||
) : remoteClients.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.empty')}</p>
|
||||
) : remoteClients.map((client) => {
|
||||
const isLocalDesktopClient = client.clientKind === 'desktop-local';
|
||||
return (
|
||||
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="typography-ui-label text-foreground truncate">{client.label}</p>
|
||||
{isLocalDesktopClient ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.direct.note')}</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(true)} disabled={directSaving}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.direct.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{directLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
|
||||
) : directHosts.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.empty')}</p>
|
||||
) : directHosts.map((host) => (
|
||||
<div key={host.id} className="py-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p>
|
||||
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground font-mono truncate">{redactSensitiveUrl(host.apiUrl || host.url)}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
|
||||
{directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}>
|
||||
<Icon name="pencil" className="h-3.5 w-3.5" />
|
||||
{t('desktopHostSwitcher.actions.edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void handleRemoveDirectHost(host.id)} disabled={directSaving}>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
|
||||
</section>
|
||||
</div> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.direct.actions.add')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void handleAddDirectHost(); }}>
|
||||
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
|
||||
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
|
||||
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={Boolean(directEditingId)} onOpenChange={(open) => { if (!open) setDirectEditingId(null); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('desktopHostSwitcher.actions.edit')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void saveDirectHostEdit(); }}>
|
||||
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
|
||||
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
|
||||
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={directImportDialogOpen} onOpenChange={setDirectImportDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.direct.import.action')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.direct.import.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void importDirectConnectLink(); }}>
|
||||
<Input className="h-8" value={directConnectLink} onChange={(event) => setDirectConnectLink(event.target.value)} placeholder={t('settings.remoteInstances.direct.import.placeholder')} disabled={directSaving} autoFocus />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directConnectLink.trim()}>{t('settings.remoteInstances.direct.import.action')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p>
|
||||
</div>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-1">
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
) : instances.map((instance) => {
|
||||
const instanceStatus = statusesById[instance.id];
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const phase = instanceStatus?.phase;
|
||||
const ready = phase === 'ready';
|
||||
return (
|
||||
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{title}</p>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const op = ready ? disconnect(instance.id) : connect(instance.id);
|
||||
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
|
||||
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
|
||||
<Icon name="pencil" className="h-3.5 w-3.5" />
|
||||
{t('desktopHostSwitcher.actions.edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
|
||||
if (!ok) return;
|
||||
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
|
||||
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
|
||||
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
@@ -694,15 +1229,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{importCandidates.map((candidate) => (
|
||||
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 rounded-md border border-[var(--interactive-border)] px-3 py-2">
|
||||
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground truncate">
|
||||
<div className="typography-ui-label font-medium text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
|
||||
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -711,14 +1246,14 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
{t('settings.remoteInstances.page.actions.create')}
|
||||
{t('settings.common.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
<Dialog
|
||||
open={Boolean(patternHost)}
|
||||
@@ -767,7 +1302,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
|
||||
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<div className="mb-6 px-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
@@ -1466,46 +2002,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
|
||||
) : (
|
||||
<div>
|
||||
{importCandidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.source}:${candidate.host}`}
|
||||
className={`flex items-center justify-between gap-2 px-1 py-2 ${index > 0 ? 'border-t border-[var(--surface-subtle)]' : ''}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ' (pattern)' : ''}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground truncate">{candidate.sshCommand}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
{t('settings.common.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
|
||||
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
@@ -1617,6 +2114,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsPageLayout>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ const makeId = (): string => {
|
||||
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const DIRECT_INSTANCES_ID = '__direct_instances__';
|
||||
|
||||
const randomPort = (): number => {
|
||||
return Math.floor(20000 + Math.random() * 30000);
|
||||
};
|
||||
@@ -76,6 +78,9 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (selectedId === DIRECT_INSTANCES_ID) {
|
||||
return;
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
if (selectedId !== null) {
|
||||
setSelectedId(null);
|
||||
@@ -130,7 +135,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
}, [connect, t, upsertInstance]);
|
||||
|
||||
return (
|
||||
<SettingsSidebarLayout
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
@@ -151,6 +156,16 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsSidebarItem
|
||||
title={t('settings.remoteInstances.direct.sidebarTitle')}
|
||||
metadata={t('settings.remoteInstances.direct.sidebarDescription')}
|
||||
selected={selectedId === DIRECT_INSTANCES_ID || (!selectedId && instances.length === 0)}
|
||||
onSelect={() => {
|
||||
setSelectedId(DIRECT_INSTANCES_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
icon={<Icon name="global" className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
{instances.map((instance) => {
|
||||
const status = statusesById[instance.id];
|
||||
const selected = instance.id === selectedId;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -60,7 +61,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
return (result?.settings || {}) as DesktopSettings;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -50,7 +51,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
return (result?.settings || {}) as DesktopSettings;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user