fix: improve Windows tunnel support

This commit is contained in:
Bohdan Triapitsyn
2026-06-05 15:01:22 +03:00
parent e823497cf1
commit 16fdff530b
24 changed files with 936 additions and 151 deletions
@@ -149,6 +149,84 @@ interface TunnelProviderCapability {
modes?: TunnelProviderModeDescriptor[];
}
interface TunnelCheckResponse {
available?: boolean;
provider?: string | null;
version?: string | null;
dependency?: string | null;
installCommand?: string | null;
platform?: string | null;
}
interface TunnelDependencyInstallInfo {
provider: string;
dependency: string;
installCommand: string;
}
const getProviderDependencyName = (provider: string): string => (provider === 'ngrok' ? 'ngrok' : 'cloudflared');
const getClientInstallPlatform = (): string => {
if (typeof window !== 'undefined' && typeof window.__OPENCHAMBER_PLATFORM__ === 'string') {
const platform = window.__OPENCHAMBER_PLATFORM__;
if (platform === 'win32' || platform === 'darwin' || platform === 'linux') {
return platform;
}
}
const browserPlatform = typeof navigator !== 'undefined'
? `${navigator.platform || ''} ${navigator.userAgent || ''}`.toLowerCase()
: '';
if (browserPlatform.includes('win')) {
return 'win32';
}
if (browserPlatform.includes('mac')) {
return 'darwin';
}
return 'linux';
};
const getFallbackInstallCommand = (provider: string, platform = getClientInstallPlatform()): string => {
if (provider === 'ngrok') {
if (platform === 'win32') {
return 'winget install ngrok -s msstore';
}
if (platform === 'darwin') {
return 'brew install ngrok';
}
return 'https://ngrok.com/download';
}
if (platform === 'win32') {
return 'winget install --id Cloudflare.cloudflared';
}
if (platform === 'darwin') {
return 'brew install cloudflared';
}
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/';
};
const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => {
const responseProvider = typeof checkData?.provider === 'string' && checkData.provider.trim().length > 0
? checkData.provider.trim().toLowerCase()
: provider;
const dependency = typeof checkData?.dependency === 'string' && checkData.dependency.trim().length > 0
? checkData.dependency.trim()
: getProviderDependencyName(responseProvider);
const platform = typeof checkData?.platform === 'string' && checkData.platform.trim().length > 0
? checkData.platform.trim()
: getClientInstallPlatform();
const installCommand = typeof checkData?.installCommand === 'string' && checkData.installCommand.trim().length > 0
? checkData.installCommand.trim()
: getFallbackInstallCommand(responseProvider, platform);
return {
provider: responseProvider,
dependency,
installCommand,
};
};
const getProviderLabel = (provider: string): string => {
if (provider === 'cloudflare') {
return 'Cloudflare';
@@ -281,6 +359,7 @@ export const TunnelSettings: React.FC = () => {
const [isSavingTtl, setIsSavingTtl] = React.useState(false);
const [isSavingMode, setIsSavingMode] = React.useState(false);
const [tunnelProvider, setTunnelProvider] = React.useState<string>('cloudflare');
const [dependencyInstallInfo, setDependencyInstallInfo] = React.useState<TunnelDependencyInstallInfo>(() => createTunnelDependencyInstallInfo('cloudflare'));
const [providerCapabilities, setProviderCapabilities] = React.useState<TunnelProviderCapability[]>([]);
const [tunnelMode, setTunnelMode] = React.useState<TunnelMode>('quick');
const [managedLocalConfigPath, setManagedLocalConfigPath] = React.useState<string | null>(null);
@@ -400,13 +479,47 @@ export const TunnelSettings: React.FC = () => {
() => tunnelModeOptions.some((option) => option.value === 'managed-remote' || option.value === 'managed-local'),
[tunnelModeOptions],
);
const installCommand = tunnelProvider === 'ngrok'
? 'brew install ngrok'
: 'brew install cloudflared';
const displayedDependencyInstallInfo = React.useMemo(() => {
if (dependencyInstallInfo.provider === tunnelProvider) {
return dependencyInstallInfo;
}
return createTunnelDependencyInstallInfo(tunnelProvider);
}, [dependencyInstallInfo, tunnelProvider]);
const openExternal = React.useCallback(async (url: string) => {
await openExternalUrl(url);
}, []);
const applyDependencyCheck = React.useCallback((checkData: TunnelCheckResponse, fallbackProvider: string): boolean => {
setDependencyInstallInfo(createTunnelDependencyInstallInfo(fallbackProvider, checkData));
return checkData.available === true;
}, []);
const refreshTunnelDependencyCheck = React.useCallback(async (provider: string, signal?: AbortSignal): Promise<boolean | null> => {
try {
const checkRes = await runtimeFetch('/api/openchamber/tunnel/check', {
query: { provider },
...(signal ? { signal } : {}),
});
if (!checkRes.ok) {
return null;
}
const checkData = (await checkRes.json()) as TunnelCheckResponse;
if (signal?.aborted) {
return null;
}
const available = applyDependencyCheck(checkData, provider);
setState((current) => {
if (current === 'checking' || current === 'starting' || current === 'active' || current === 'stopping') {
return current;
}
return available ? 'idle' : 'not-available';
});
return available;
} catch {
return null;
}
}, [applyDependencyCheck]);
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
try {
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
@@ -416,7 +529,7 @@ export const TunnelSettings: React.FC = () => {
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
]);
const checkData = await checkRes.json();
const checkData = (await checkRes.json()) as TunnelCheckResponse;
const statusData = (await statusRes.json()) as TunnelStatusResponse;
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const providersData = providersRes.ok ? await providersRes.json() : {};
@@ -440,6 +553,7 @@ export const TunnelSettings: React.FC = () => {
const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string'
? settingsData.managedLocalTunnelConfigPath.trim() || null
: null;
const dependencyAvailable = applyDependencyCheck(checkData, loadedProvider);
const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets);
const loadedHostname = typeof statusData.managedRemoteTunnelHostname === 'string'
@@ -484,14 +598,14 @@ export const TunnelSettings: React.FC = () => {
return;
}
setState(checkData.available ? 'idle' : 'not-available');
setState(dependencyAvailable ? 'idle' : 'not-available');
} catch {
if (!signal.aborted) {
setState('error');
setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed'));
}
}
}, [t]);
}, [applyDependencyCheck, t]);
React.useEffect(() => {
const controller = new AbortController();
@@ -738,7 +852,8 @@ export const TunnelSettings: React.FC = () => {
? tunnelMode
: toUiTunnelMode(capability?.modes?.[0]?.key);
await saveTunnelSettings({ tunnelProvider: provider, tunnelMode: defaultMode });
}, [providerCapabilities, saveTunnelSettings, tunnelMode]);
void refreshTunnelDependencyCheck(provider);
}, [providerCapabilities, refreshTunnelDependencyCheck, saveTunnelSettings, tunnelMode]);
const handleBrowseManagedLocalConfig = React.useCallback(async () => {
const result = await requestFileAccess({
@@ -1192,10 +1307,12 @@ export const TunnelSettings: React.FC = () => {
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<div className="space-y-1">
<p className="typography-meta font-medium text-foreground">{getProviderLabel(tunnelProvider)} tunnel dependency was not found.</p>
<p className="typography-meta font-medium text-foreground">
{t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })}
</p>
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
<code className="typography-code block rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
{installCommand}
{displayedDependencyInstallInfo.installCommand}
</code>
</div>
</div>
@@ -119,6 +119,7 @@ export const settingsDict = {
'settings.openchamber.tunnel.section.redeemedAccessLinks': 'Redeemed access links',
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': 'Saved managed remote tunnels',
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared not found',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} was not found.',
'settings.openchamber.tunnel.notAvailable.installHint': 'Install it to enable remote tunnel access:',
'settings.openchamber.tunnel.field.provider': 'Provider',
'settings.openchamber.tunnel.field.providerPlaceholder': 'Select provider',
@@ -86,6 +86,7 @@ export const settingsDict = {
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Enlaces de acceso canjeados",
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneles remotos gestionados guardados",
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared no encontrado",
"settings.openchamber.tunnel.notAvailable.dependencyNotFound": "No se encontró {dependency}.",
"settings.openchamber.tunnel.notAvailable.installHint": "Instálalo para habilitar el acceso al túnel remoto:",
"settings.openchamber.tunnel.field.provider": "Proveedor",
"settings.openchamber.tunnel.field.providerPlaceholder": "Seleccionar proveedor",
@@ -86,6 +86,7 @@ export const settingsDict = {
'settings.openchamber.tunnel.section.redeemedAccessLinks': '사용된 접속 링크',
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '저장된 Managed Remote 터널',
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared를 찾을 수 없음',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency}를 찾을 수 없습니다.',
'settings.openchamber.tunnel.notAvailable.installHint': '원격 터널 접속을 사용하려면 설치하세요:',
'settings.openchamber.tunnel.field.provider': '프로바이더',
'settings.openchamber.tunnel.field.providerPlaceholder': '프로바이더 선택',
@@ -814,6 +814,7 @@ export const settingsDict = {
'settings.openchamber.tunnel.field.tunnelSessionTtl': 'TTL sesji tunelu',
'settings.openchamber.tunnel.field.tunnelType': 'Typ tunelu',
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'Nie znaleziono cloudflared',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': 'Nie znaleziono {dependency}.',
'settings.openchamber.tunnel.notAvailable.installHint': 'Zainstaluj go, aby włączyć zdalny dostęp przez tunel:',
'settings.openchamber.tunnel.note.cloudflareConnectorTarget': 'Docelowy adres konektora Cloudflare:',
'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Dla docelowego adresu konektora Cloudflare użyj',
@@ -86,6 +86,7 @@ export const settingsDict = {
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Links de acesso resgatados",
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneis remotos gerenciados salvos",
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared não encontrado",
"settings.openchamber.tunnel.notAvailable.dependencyNotFound": "{dependency} não foi encontrado.",
"settings.openchamber.tunnel.notAvailable.installHint": "Instale para habilitar o acesso ao túnel remoto:",
"settings.openchamber.tunnel.field.provider": "Provedor",
"settings.openchamber.tunnel.field.providerPlaceholder": "Selecionar provedor",
@@ -86,6 +86,7 @@ export const settingsDict = {
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Використані посилання доступу",
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Збережені керовані віддалені тунелі",
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared не знайдено",
"settings.openchamber.tunnel.notAvailable.dependencyNotFound": "{dependency} не знайдено.",
"settings.openchamber.tunnel.notAvailable.installHint": "Встановіть його, щоб увімкнути віддалений доступ через тунель:",
"settings.openchamber.tunnel.field.provider": "Провайдер",
"settings.openchamber.tunnel.field.providerPlaceholder": "Виберіть провайдера",
@@ -86,6 +86,7 @@ export const settingsDict = {
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兑换访问链接',
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已保存的 Managed Remote 隧道',
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '未找到 cloudflared',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '未找到 {dependency}。',
'settings.openchamber.tunnel.notAvailable.installHint': '安装它以启用远程隧道访问:',
'settings.openchamber.tunnel.field.provider': '提供商',
'settings.openchamber.tunnel.field.providerPlaceholder': '选择提供商',
@@ -83,6 +83,7 @@
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兌換存取連結',
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已儲存的 managed remote tunnel',
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '找不到 cloudflared',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '找不到 {dependency}。',
'settings.openchamber.tunnel.notAvailable.installHint': '安裝它以啟用 remote tunnel 存取:',
'settings.openchamber.tunnel.field.provider': '供應商',
'settings.openchamber.tunnel.field.providerPlaceholder': '選擇供應商',