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
@@ -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