feat: add Ngrok tunnel provider (#1415)

Adds Ngrok quick tunnel support
Adds desktop tunnel docs across locales
Improves tunnel settings provider and TTL labels
This commit is contained in:
Bohdan Triapitsyn
2026-05-25 18:00:03 +03:00
committed by GitHub
parent 25ed8e6abb
commit 00a7807168
22 changed files with 893 additions and 28 deletions
@@ -149,16 +149,20 @@ const getProviderLabel = (provider: string): string => {
if (provider === 'cloudflare') {
return 'Cloudflare';
}
if (provider === 'ngrok') {
return 'Ngrok';
}
return provider;
};
const ProviderOptionLabel: React.FC<{ provider: string }> = ({ provider }) => {
const label = getProviderLabel(provider);
const isCloudflare = provider === 'cloudflare';
const isNgrok = provider === 'ngrok';
return (
<span className="flex items-center gap-2">
<Icon name="cloud" className={cn('size-4 shrink-0', isCloudflare ? 'text-[var(--status-warning)]' : 'text-muted-foreground')} />
<Icon name="cloud" className={cn('size-4 shrink-0', isCloudflare || isNgrok ? 'text-[var(--status-warning)]' : 'text-muted-foreground')} />
<span>{label}</span>
</span>
);
@@ -182,6 +186,11 @@ const ttlOptionValue = (options: TtlOption[], ttlMs: number | null, fallback: st
return matched?.value || fallback;
};
const ttlOptionLabel = (options: TtlOption[], ttlMs: number | null, fallback: string) => {
const value = ttlOptionValue(options, ttlMs, fallback);
return options.find((entry) => entry.value === value)?.label || value;
};
const formatRemaining = (remainingMs: number): string => {
const safeMs = Math.max(0, remainingMs);
const totalSeconds = Math.floor(safeMs / 1000);
@@ -361,6 +370,23 @@ export const TunnelSettings: React.FC = () => {
}
return null;
}, [localPort]);
const selectedProviderCapability = React.useMemo(() => {
return providerCapabilities.find((capability) => capability.provider === tunnelProvider) ?? null;
}, [providerCapabilities, tunnelProvider]);
const tunnelModeOptions = React.useMemo(() => {
const supportedModes = new Set(
selectedProviderCapability?.modes
?.map((mode) => mode.key)
.filter((mode): mode is TunnelMode => mode === 'quick' || mode === 'managed-remote' || mode === 'managed-local')
);
if (supportedModes.size === 0) {
return TUNNEL_MODE_OPTIONS;
}
return TUNNEL_MODE_OPTIONS.filter((option) => supportedModes.has(option.value));
}, [selectedProviderCapability]);
const installCommand = tunnelProvider === 'ngrok'
? 'brew install ngrok'
: 'brew install cloudflared';
const openExternal = React.useCallback(async (url: string) => {
await openExternalUrl(url);
}, []);
@@ -691,8 +717,12 @@ export const TunnelSettings: React.FC = () => {
const handleProviderChange = React.useCallback(async (provider: string) => {
setManagedRemoteValidationError(null);
setErrorMessage(null);
await saveTunnelSettings({ tunnelProvider: provider });
}, [saveTunnelSettings]);
const capability = providerCapabilities.find((entry) => entry.provider === provider);
const defaultMode = capability?.modes?.some((mode) => mode.key === tunnelMode)
? tunnelMode
: toUiTunnelMode(capability?.modes?.[0]?.key);
await saveTunnelSettings({ tunnelProvider: provider, tunnelMode: defaultMode });
}, [providerCapabilities, saveTunnelSettings, tunnelMode]);
const handleBrowseManagedLocalConfig = React.useCallback(async () => {
const result = await requestFileAccess({
@@ -1146,17 +1176,17 @@ 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">{t('settings.openchamber.tunnel.notAvailable.cloudflaredNotFound')}</p>
<p className="typography-meta font-medium text-foreground">{getProviderLabel(tunnelProvider)} tunnel dependency was not found.</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">
brew install cloudflared
{installCommand}
</code>
</div>
</div>
</section>
)}
{state !== 'not-available' && (
{(
<section className="space-y-4 px-2 pb-2 pt-0">
<div className="space-y-3">
<div className="space-y-1.5">
@@ -1193,7 +1223,7 @@ export const TunnelSettings: React.FC = () => {
<div className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
<div className="flex flex-wrap items-center gap-1">
{TUNNEL_MODE_OPTIONS.map((option) => (
{tunnelModeOptions.map((option) => (
<Tooltip key={option.value}>
<TooltipTrigger asChild>
<Button
@@ -1229,7 +1259,9 @@ export const TunnelSettings: React.FC = () => {
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
>
<SelectTrigger className="max-w-[11rem] min-w-0">
<SelectValue className="truncate" />
<SelectValue className="truncate">
{ttlOptionLabel(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{BOOTSTRAP_TTL_OPTIONS.map((option) => (
@@ -1249,7 +1281,9 @@ export const TunnelSettings: React.FC = () => {
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
>
<SelectTrigger className="max-w-[11rem] min-w-0">
<SelectValue className="truncate" />
<SelectValue className="truncate">
{ttlOptionLabel(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{SESSION_TTL_OPTIONS.map((option) => (