feat: redesign remote tunnel settings and named tunnel workflow (#546)
* feat: add Cloudflare Tunnel settings for desktop app Add a 'Remote Tunnel' section in Settings (desktop-only) that lets users start/stop a Cloudflare quick tunnel on demand, with auto-generated password protection and a QR code for easy mobile access. - Server: 4 new API endpoints (check/status/start/stop) reusing the existing cloudflare-tunnel module - UI: TunnelSettings component with full state machine (checking → idle/not-available → starting → active → stopping) - QR code rendered via the qrcode package for in-app display - Hidden from VS Code extension (desktop/web only) * fix: use ?token= instead of ?p= in tunnel password URLs REST API endpoints were building passwordUrl with ?p=<token> but SessionAuthGate reads the ?token= query param, causing QR code auto-login to fail — the password was never extracted from the URL. Standardize all three tunnel URL construction sites to use ?token= so scanning the QR code correctly pre-fills and submits the password. * feat: secure remote tunnel access with one-time connect links * feat: redesign remote tunnel settings and access flow * fix: cleaned up unused desktop close code path * feat: overhaul named tunnel setup and persistence flow * chore: align codemirror language dependency resolution --------- Co-authored-by: Brian-Hwang <brian.hwang@cornelisnetworks.com>
This commit is contained in:
committed by
GitHub
co-authored by
Brian-Hwang
parent
a505378d79
commit
d5d0d35083
@@ -68,6 +68,7 @@
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
@@ -86,6 +87,7 @@
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
|
||||
@@ -106,25 +106,6 @@ interface ErrorScreenProps {
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
const getTokenFromUrl = (): string | null => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('token');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokenFromUrl = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const skipAuth = vscodeRuntime;
|
||||
@@ -134,9 +115,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
||||
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasTriedUrlTokenRef = React.useRef(false);
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
if (skipAuth) {
|
||||
@@ -158,28 +139,30 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
const responseText = await response.text();
|
||||
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Frontend Auth] Session is authenticated');
|
||||
setState('authenticated');
|
||||
setErrorMessage('');
|
||||
setRetryAfter(undefined);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
let data: { debug?: { hasRefreshToken: boolean; message: string } } = {};
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
data = {};
|
||||
if (response.ok) {
|
||||
console.log('[Frontend Auth] Session is authenticated');
|
||||
setState('authenticated');
|
||||
setIsTunnelLocked(false);
|
||||
setErrorMessage('');
|
||||
setRetryAfter(undefined);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
let data: { tunnelLocked?: boolean; debug?: { hasRefreshToken: boolean; message: string } } = {};
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
console.warn('[Frontend Auth] Session is locked (401)', data);
|
||||
if (data.debug) {
|
||||
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||
if (data.debug) {
|
||||
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||
}
|
||||
setIsTunnelLocked(data.tunnelLocked === true);
|
||||
setState('locked');
|
||||
setRetryAfter(undefined);
|
||||
return;
|
||||
}
|
||||
setState('locked');
|
||||
setRetryAfter(undefined);
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
let data: { retryAfter?: number } = {};
|
||||
try {
|
||||
@@ -188,14 +171,17 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
data = {};
|
||||
}
|
||||
setRetryAfter(data.retryAfter);
|
||||
setIsTunnelLocked(false);
|
||||
setState('rate-limited');
|
||||
return;
|
||||
}
|
||||
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to check session status:', error);
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
}
|
||||
}, [skipAuth]);
|
||||
|
||||
@@ -219,49 +205,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Auto-login with URL token parameter
|
||||
React.useEffect(() => {
|
||||
if (skipAuth || state !== 'locked' || hasTriedUrlTokenRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlToken = getTokenFromUrl();
|
||||
if (!urlToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriedUrlTokenRef.current = true;
|
||||
clearTokenFromUrl();
|
||||
|
||||
// Auto-submit the password from URL
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
submitPassword(urlToken)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setPassword('');
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
setErrorMessage('URL token invalid. Please enter password manually.');
|
||||
setState('locked');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('Unexpected response from server.');
|
||||
setState('error');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to submit URL token:', error);
|
||||
setErrorMessage('Network error. Check connection and retry.');
|
||||
setState('error');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}, [skipAuth, state, isSubmitting]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) {
|
||||
return;
|
||||
@@ -278,6 +221,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isTunnelLocked) {
|
||||
return;
|
||||
}
|
||||
if (!password || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
@@ -296,6 +242,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -303,6 +250,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
if (response.status === 401) {
|
||||
console.warn('[Frontend Auth] Login failed: Invalid password');
|
||||
setErrorMessage('Incorrect password. Try again.');
|
||||
setIsTunnelLocked(false);
|
||||
setState('locked');
|
||||
return;
|
||||
}
|
||||
@@ -311,16 +259,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
console.warn('[Frontend Auth] Login failed: Rate limited');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
setRetryAfter(data.retryAfter);
|
||||
setIsTunnelLocked(false);
|
||||
setState('rate-limited');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
||||
setErrorMessage('Unexpected response from server.');
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} catch (error) {
|
||||
console.warn('Failed to submit UI password:', error);
|
||||
setErrorMessage('Network error. Check connection and retry.');
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -345,55 +296,59 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Unlock OpenChamber
|
||||
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
|
||||
</h1>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
This session is password-protected.
|
||||
{isTunnelLocked
|
||||
? 'Open this tunnel using the one-time connect link from the desktop app.'
|
||||
: 'This session is password-protected.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
||||
<Input
|
||||
id="openchamber-ui-password"
|
||||
ref={passwordInputRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
if (errorMessage) {
|
||||
setErrorMessage('');
|
||||
}
|
||||
}}
|
||||
className="pl-10"
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{!isTunnelLocked && (
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
||||
<Input
|
||||
id="openchamber-ui-password"
|
||||
ref={passwordInputRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
if (errorMessage) {
|
||||
setErrorMessage('');
|
||||
}
|
||||
}}
|
||||
className="pl-10"
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!password || isSubmitting}
|
||||
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiLockUnlockLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!password || isSubmitting}
|
||||
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiLockUnlockLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{errorMessage && (
|
||||
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showHostSwitcher && (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GitSettings } from './GitSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import { GitHubSettings } from './GitHubSettings';
|
||||
import { VoiceSettings } from './VoiceSettings';
|
||||
import { TunnelSettings } from './TunnelSettings';
|
||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -75,6 +76,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
return <NotificationSectionContent />;
|
||||
case 'voice':
|
||||
return <VoiceSectionContent />;
|
||||
case 'tunnel':
|
||||
return <TunnelSectionContent />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -157,3 +160,10 @@ const VoiceSectionContent: React.FC = () => {
|
||||
}
|
||||
return <VoiceSettings />;
|
||||
};
|
||||
|
||||
const TunnelSectionContent: React.FC = () => {
|
||||
if (isVSCodeRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return <TunnelSettings />;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,4 +6,5 @@ export type OpenChamberSection =
|
||||
| 'git'
|
||||
| 'github'
|
||||
| 'notifications'
|
||||
| 'voice';
|
||||
| 'voice'
|
||||
| 'tunnel';
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
RiCloudLine,
|
||||
RiFoldersLine,
|
||||
RiGitBranchLine,
|
||||
|
||||
RiGlobalLine,
|
||||
RiMicLine,
|
||||
RiNotification3Line,
|
||||
RiPaletteLine,
|
||||
@@ -97,6 +97,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'skills.installed',
|
||||
'skills.catalog',
|
||||
'voice',
|
||||
'tunnel',
|
||||
];
|
||||
|
||||
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
|
||||
@@ -150,6 +151,8 @@ function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ class
|
||||
return RiBarChart2Line;
|
||||
case 'voice':
|
||||
return RiMicLine;
|
||||
case 'tunnel':
|
||||
return RiGlobalLine;
|
||||
case 'home':
|
||||
return null;
|
||||
default:
|
||||
@@ -367,6 +370,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
sessions: 'sessions',
|
||||
notifications: 'notifications',
|
||||
voice: 'voice',
|
||||
tunnel: 'tunnel',
|
||||
}), []);
|
||||
|
||||
const renderUnavailable = React.useCallback(() => {
|
||||
@@ -437,7 +441,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
case 'shortcuts':
|
||||
case 'sessions':
|
||||
case 'notifications':
|
||||
case 'voice': {
|
||||
case 'voice':
|
||||
case 'tunnel': {
|
||||
const section = openChamberSectionBySlug[slug] ?? 'visual';
|
||||
return <OpenChamberPage section={section} />;
|
||||
}
|
||||
@@ -511,7 +516,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
)}
|
||||
>
|
||||
<span className="typography-ui-label font-normal truncate">{page.title}</span>
|
||||
{page.slug === 'voice' && (
|
||||
{(page.slug === 'voice' || page.slug === 'tunnel') && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
beta
|
||||
</span>
|
||||
|
||||
@@ -29,6 +29,12 @@ export type SkillCatalogConfig = {
|
||||
gitIdentityId?: string;
|
||||
};
|
||||
|
||||
export type NamedTunnelPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
export type DesktopSettings = {
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
@@ -84,6 +90,15 @@ export type DesktopSettings = {
|
||||
}>; // Per-provider custom model groups configuration
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
tunnelMode?: 'quick' | 'named';
|
||||
tunnelBootstrapTtlMs?: number | null;
|
||||
tunnelSessionTtlMs?: number;
|
||||
namedTunnelHostname?: string;
|
||||
namedTunnelToken?: string | null;
|
||||
hasNamedTunnelToken?: boolean;
|
||||
namedTunnelPresets?: NamedTunnelPreset[];
|
||||
namedTunnelSelectedPresetId?: string;
|
||||
namedTunnelPresetTokens?: Record<string, string>;
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
defaultVariant?: string;
|
||||
defaultAgent?: string;
|
||||
|
||||
@@ -208,6 +208,51 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
||||
return result.length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const sanitizeNamedTunnelPresets = (value: unknown): DesktopSettings['namedTunnelPresets'] | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: NonNullable<DesktopSettings['namedTunnelPresets']> = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenHostnames = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
||||
const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : '';
|
||||
|
||||
if (!id || !name || !hostname) continue;
|
||||
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
||||
seenIds.add(id);
|
||||
seenHostnames.add(hostname);
|
||||
|
||||
result.push({ id, name, hostname });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeNamedTunnelPresetTokens = (value: unknown): DesktopSettings['namedTunnelPresetTokens'] | undefined => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, tokenValue] of Object.entries(candidate)) {
|
||||
const id = key.trim();
|
||||
const token = typeof tokenValue === 'string' ? tokenValue.trim() : '';
|
||||
if (!id || !token) continue;
|
||||
result[id] = token;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
@@ -444,6 +489,40 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
||||
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
||||
}
|
||||
if (typeof candidate.tunnelMode === 'string') {
|
||||
const mode = candidate.tunnelMode.trim().toLowerCase();
|
||||
if (mode === 'quick' || mode === 'named') {
|
||||
result.tunnelMode = mode;
|
||||
}
|
||||
}
|
||||
if (candidate.tunnelBootstrapTtlMs === null) {
|
||||
result.tunnelBootstrapTtlMs = null;
|
||||
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
||||
result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs;
|
||||
}
|
||||
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
||||
result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs;
|
||||
}
|
||||
if (typeof candidate.namedTunnelHostname === 'string') {
|
||||
result.namedTunnelHostname = candidate.namedTunnelHostname.trim();
|
||||
}
|
||||
if (candidate.namedTunnelToken === null) {
|
||||
result.namedTunnelToken = null;
|
||||
} else if (typeof candidate.namedTunnelToken === 'string') {
|
||||
result.namedTunnelToken = candidate.namedTunnelToken.trim();
|
||||
}
|
||||
const namedTunnelPresets = sanitizeNamedTunnelPresets(candidate.namedTunnelPresets);
|
||||
if (namedTunnelPresets) {
|
||||
result.namedTunnelPresets = namedTunnelPresets;
|
||||
}
|
||||
if (typeof candidate.namedTunnelSelectedPresetId === 'string') {
|
||||
const trimmed = candidate.namedTunnelSelectedPresetId.trim();
|
||||
result.namedTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
const namedTunnelPresetTokens = sanitizeNamedTunnelPresetTokens(candidate.namedTunnelPresetTokens);
|
||||
if (namedTunnelPresetTokens) {
|
||||
result.namedTunnelPresetTokens = namedTunnelPresetTokens;
|
||||
}
|
||||
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
|
||||
result.defaultModel = candidate.defaultModel;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export type SettingsPageSlug =
|
||||
| 'shortcuts'
|
||||
| 'sessions'
|
||||
| 'notifications'
|
||||
| 'voice';
|
||||
| 'voice'
|
||||
| 'tunnel';
|
||||
|
||||
export type SettingsPageGroup =
|
||||
| 'appearance'
|
||||
@@ -168,6 +169,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
|
||||
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
|
||||
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
{ slug: 'tunnel', title: 'Remote Tunnel', group: 'advanced', kind: 'single', keywords: ['tunnel', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
] as const;
|
||||
|
||||
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
|
||||
Reference in New Issue
Block a user