From 55611facc4778bd7120f3d91d3717f9711653642 Mon Sep 17 00:00:00 2001 From: haorwen Date: Sun, 21 Jun 2026 13:11:22 +0800 Subject: [PATCH] feat: add cloudflare auto-tunnel onboarding flow and bump version to v0.9.81 --- package.json | 2 +- public/locales/de/welcome.json | 12 +- public/locales/en/welcome.json | 12 +- public/locales/es/welcome.json | 12 +- public/locales/fr/welcome.json | 12 +- public/locales/pt/welcome.json | 12 +- public/locales/ru/welcome.json | 12 +- public/locales/tr/welcome.json | 12 +- public/locales/zh/welcome.json | 12 +- src/app/services/base.query.ts | 2 + src/app/services/server.ts | 6 + src/routes/onboarding/index.tsx | 13 +- src/routes/onboarding/steps.ts | 70 ++++--- src/routes/onboarding/steps/done-page.tsx | 24 ++- .../onboarding/steps/get-public-domain.tsx | 184 +++++++++++++++++ src/routes/onboarding/steps/welcome-page.tsx | 185 ++++++++++++++++-- src/types/server.ts | 5 + 17 files changed, 532 insertions(+), 55 deletions(-) create mode 100644 src/routes/onboarding/steps/get-public-domain.tsx diff --git a/package.json b/package.json index 2bd8153e..3263fc12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.80", + "version": "0.9.81", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/locales/de/welcome.json b/public/locales/de/welcome.json index 2fa1da67..1e79c522 100644 --- a/public/locales/de/welcome.json +++ b/public/locales/de/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Admin-Konto", "who_sign_up": "Registrierungsberechtigung", "invites": "Einladungen", - "update_domain_tip": "Falsche Domain? Hier aktualisieren:" + "update_domain_tip": "Falsche Domain? Hier aktualisieren:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/en/welcome.json b/public/locales/en/welcome.json index f4b49cc0..1709e415 100644 --- a/public/locales/en/welcome.json +++ b/public/locales/en/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Admin Account", "who_sign_up": "Who Can Sign Up", "invites": "Invites", - "update_domain_tip": "Incorrect link domain?Please update it here:" + "update_domain_tip": "Incorrect link domain?Please update it here:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/es/welcome.json b/public/locales/es/welcome.json index e2c1769b..29c0359a 100644 --- a/public/locales/es/welcome.json +++ b/public/locales/es/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Cuenta de Administrador", "who_sign_up": "Quién Puede Registrarse", "invites": "Invitaciones", - "update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:" + "update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/fr/welcome.json b/public/locales/fr/welcome.json index 9c5a05b8..8e5c1d23 100644 --- a/public/locales/fr/welcome.json +++ b/public/locales/fr/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Compte Administrateur", "who_sign_up": "Qui Peut S'inscrire", "invites": "Invitations", - "update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :" + "update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/pt/welcome.json b/public/locales/pt/welcome.json index 07143f06..2e128006 100644 --- a/public/locales/pt/welcome.json +++ b/public/locales/pt/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Conta de administrador", "who_sign_up": "Quem pode se inscrever", "invites": "Convites", - "update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:" + "update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/ru/welcome.json b/public/locales/ru/welcome.json index 0433bea3..9df763ba 100644 --- a/public/locales/ru/welcome.json +++ b/public/locales/ru/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Учетная запись администратора", "who_sign_up": "Кто может зарегистрироваться", "invites": "Приглашения", - "update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:" + "update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/tr/welcome.json b/public/locales/tr/welcome.json index 1564b143..a5992b75 100644 --- a/public/locales/tr/welcome.json +++ b/public/locales/tr/welcome.json @@ -36,6 +36,16 @@ "admin_account": "Yönetici Hesabı", "who_sign_up": "Kimler kaydolabilir", "invites": "Davetler", - "update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:" + "update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:", + "tunnel_initializing": "Initializing Tunnel service...", + "tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.", + "tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...", + "tunnel_domain_assigned": "A temporary public domain has been assigned to you", + "tunnel_opt_in_title": "Get an automatically assigned public domain?", + "tunnel_opt_in_yes": "Yes", + "tunnel_opt_in_skip": "Skip", + "tunnel_progress": "Starting tunnel...", + "tunnel_error": "Tunnel error", + "tunnel_get_domain": "Get Public Domain" } } diff --git a/public/locales/zh/welcome.json b/public/locales/zh/welcome.json index 76222140..caa3426f 100644 --- a/public/locales/zh/welcome.json +++ b/public/locales/zh/welcome.json @@ -36,6 +36,16 @@ "admin_account": "管理员账号", "who_sign_up": "注册权限", "invites": "邀请", - "update_domain_tip": "链接域名有问题?可在此更新设置:" + "update_domain_tip": "链接域名有问题?可在此更新设置:", + "tunnel_initializing": "正在初始化 Tunnel 服务...", + "tunnel_dns_warning": "正在等待 DNS 传播,如果访问失败请尝试清除 DNS 缓存后再试", + "tunnel_redirect_countdown": "{{seconds}} 秒后重定向到公网地址...", + "tunnel_domain_assigned": "已为您分配了一个临时的公网域名", + "tunnel_opt_in_title": "获取自动分配的公网域名?", + "tunnel_opt_in_yes": "是", + "tunnel_opt_in_skip": "跳过", + "tunnel_progress": "正在启动 Tunnel...", + "tunnel_error": "Tunnel 出错", + "tunnel_get_domain": "获取公网域名" } } diff --git a/src/app/services/base.query.ts b/src/app/services/base.query.ts index 88cbc6e6..65c2023b 100644 --- a/src/app/services/base.query.ts +++ b/src/app/services/base.query.ts @@ -31,12 +31,14 @@ const whiteList = [ "preCheckFileFromUrl", "passkeyLoginStart", "passkeyLoginFinish", + "getAutoTunnelInfo", ]; const whiteList401 = [ "getAgoraVoicingList", "getAgoraChannels", "getGoogleAuthConfig", "getGithubAuthConfig", + "getAutoTunnelInfo", ]; const errorWhiteList = [ "preCheckFileFromUrl", diff --git a/src/app/services/server.ts b/src/app/services/server.ts index 5aacf795..f264da9e 100644 --- a/src/app/services/server.ts +++ b/src/app/services/server.ts @@ -7,6 +7,7 @@ import { AgoraConfig, AgoraTokenResponse, AgoraVoicingListResponse, + AutoTunnelInfo, CloudflaredStatus, CreateAdminDTO, FirebaseConfig, @@ -493,6 +494,9 @@ export const serverApi = createApi({ responseHandler: "text", }), }), + getAutoTunnelInfo: builder.query({ + query: () => ({ url: `/admin/cloudflared/auto_info` }), + }), }), }); @@ -556,4 +560,6 @@ export const { useDeleteGroupAnnouncementMutation, useLazyGetCloudflaredStatusQuery, useStopCloudflaredMutation, + useGetAutoTunnelInfoQuery, + useLazyGetAutoTunnelInfoQuery, } = serverApi; diff --git a/src/routes/onboarding/index.tsx b/src/routes/onboarding/index.tsx index 28d7ac3d..08f9a88e 100644 --- a/src/routes/onboarding/index.tsx +++ b/src/routes/onboarding/index.tsx @@ -3,17 +3,20 @@ import { useTranslation } from "react-i18next"; import { useWizard, Wizard } from "react-use-wizard"; import clsx from "clsx"; -import steps from "./steps"; +import { buildSteps } from "./steps"; import AdminAccount from "./steps/admin-account"; import DonePage from "./steps/done-page"; +import GetPublicDomain from "./steps/get-public-domain"; import InviteLink from "./steps/invite-link"; import ServerName from "./steps/server-name"; import WelcomePage from "./steps/welcome-page"; import WhoCanSignUp from "./steps/who-can-sign-up"; import SelectLanguage from "../../components/Language"; +import { useGetAutoTunnelInfoQuery } from "@/app/services/server"; -const Navigator = () => { +const Navigator = ({ showTunnelStep }: { showTunnelStep: boolean }) => { const { activeStep, goToStep } = useWizard(); + const steps = buildSteps(showTunnelStep); const canJumpTo = steps[activeStep]?.canJumpTo || []; console.log("active step", activeStep); @@ -51,17 +54,21 @@ const Navigator = () => { export default function OnboardingPage() { const { t } = useTranslation("welcome"); const [serverName, setServerName] = useState(""); + const { data: autoInfo } = useGetAutoTunnelInfoQuery(); + const showTunnelStep = autoInfo ? !autoInfo.auto_cftunnel : false; + return ( <> {t("onboarding.title") || ""}
- }> + }> {/* lazy call invite link API */} + {showTunnelStep && }
diff --git a/src/routes/onboarding/steps.ts b/src/routes/onboarding/steps.ts index 6b84f9b9..82f54b43 100644 --- a/src/routes/onboarding/steps.ts +++ b/src/routes/onboarding/steps.ts @@ -1,4 +1,3 @@ -// import { useState } from "react"; import { t } from "i18next"; // `name` for in-code usage, `label` for display @@ -8,33 +7,48 @@ export interface Step { canJumpTo?: string[]; } -const steps: Step[] = [ - { - name: "welcomePage", - label: t("welcome:onboarding.welcome_page") - }, - { - name: "serverName", - label: t("welcome:onboarding.set_name") - }, - { - name: "adminAccount", - label: t("welcome:onboarding.admin_account") - }, - { - name: "whoCanSignUp", - label: t("welcome:onboarding.who_sign_up") - }, - { - name: "inviteLink", - label: t("welcome:onboarding.invites"), - canJumpTo: ["whoCanSignUp"] - }, - { +export function buildSteps(showTunnelStep: boolean): Step[] { + const base: Step[] = [ + { + name: "welcomePage", + label: t("welcome:onboarding.welcome_page") + }, + { + name: "serverName", + label: t("welcome:onboarding.set_name") + }, + { + name: "adminAccount", + label: t("welcome:onboarding.admin_account") + }, + { + name: "whoCanSignUp", + label: t("welcome:onboarding.who_sign_up") + }, + { + name: "inviteLink", + label: t("welcome:onboarding.invites"), + canJumpTo: ["whoCanSignUp"] + }, + ]; + + if (showTunnelStep) { + base.push({ + name: "getPublicDomain", + label: t("welcome:onboarding.tunnel_get_domain"), + canJumpTo: ["whoCanSignUp", "inviteLink"] + }); + } + + base.push({ name: "donePage", label: t("welcome:onboarding.done"), - canJumpTo: ["whoCanSignUp", "inviteLink"] - } -]; + canJumpTo: showTunnelStep + ? ["whoCanSignUp", "inviteLink", "getPublicDomain"] + : ["whoCanSignUp", "inviteLink"] + }); -export default steps; + return base; +} + +export default buildSteps(false); diff --git a/src/routes/onboarding/steps/done-page.tsx b/src/routes/onboarding/steps/done-page.tsx index 77046674..5981aa7e 100644 --- a/src/routes/onboarding/steps/done-page.tsx +++ b/src/routes/onboarding/steps/done-page.tsx @@ -3,20 +3,40 @@ import { useNavigate } from "react-router-dom"; import StyledButton from "@/components/styled/Button"; import PlayIcon from "@/assets/icons/play.svg?url"; +import { useGetAutoTunnelInfoQuery } from "@/app/services/server"; export default function DonePage({ serverName }: { serverName: string }) { const { t } = useTranslation("welcome", { keyPrefix: "onboarding" }); const navigate = useNavigate(); + const { data: autoInfo } = useGetAutoTunnelInfoQuery(); + + const tunnelUrl = + autoInfo?.tunnel_status.status === "running" ? autoInfo.tunnel_status.url : null; return (
{t("done_welcome", { serverName })} - {t("done_title")} - + {t("done_title")} + + {tunnelUrl && ( +
+

+ {t("tunnel_domain_assigned")} +

+ + {tunnelUrl} + +
+ )} navigate("/")} diff --git a/src/routes/onboarding/steps/get-public-domain.tsx b/src/routes/onboarding/steps/get-public-domain.tsx new file mode 100644 index 00000000..714c1bf9 --- /dev/null +++ b/src/routes/onboarding/steps/get-public-domain.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useWizard } from "react-use-wizard"; + +import StyledButton from "@/components/styled/Button"; +import { BASE_ORIGIN, tokenHeader } from "@/app/config"; +import { getLocalAuthData } from "@/utils"; + +type Phase = "prompt" | "starting" | "done" | "error"; + +const COUNTDOWN_SECONDS = 10; + +interface SseEvent { + event: string; + message: string; + url: string | null; + progress: number | null; +} + +export default function GetPublicDomain() { + const { t } = useTranslation("welcome", { keyPrefix: "onboarding" }); + const { nextStep } = useWizard(); + + const [phase, setPhase] = useState("prompt"); + const [logs, setLogs] = useState([]); + const [tunnelUrl, setTunnelUrl] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS); + const abortRef = useRef(null); + const countRef = useRef | null>(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + if (countRef.current) clearInterval(countRef.current); + }; + }, []); + + const addLog = (msg: string) => + setLogs((prev) => [...prev.slice(-29), msg]); + + function startCountdown(url: string) { + setTunnelUrl(url); + setPhase("done"); + let remaining = COUNTDOWN_SECONDS; + setCountdown(remaining); + countRef.current = setInterval(() => { + remaining -= 1; + setCountdown(remaining); + if (remaining <= 0) { + clearInterval(countRef.current!); + window.location.href = url; + } + }, 1000); + } + + const handleYes = async () => { + setPhase("starting"); + setLogs([]); + setErrorMsg(null); + + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + const { token } = getLocalAuthData(); + const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/start`, { + method: "POST", + headers: { [tokenHeader]: token }, + signal: ctrl.signal, + }); + + if (!resp.ok || !resp.body) { + setPhase("error"); + setErrorMsg(`HTTP ${resp.status}`); + return; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + + const parts = buf.split("\n\n"); + buf = parts.pop() ?? ""; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith("data:")) continue; + try { + const evt: SseEvent = JSON.parse(line.slice("data:".length).trim()); + addLog(evt.message); + if (evt.event === "url" || evt.event === "already_running") { + reader.cancel(); + startCountdown(evt.url!); + return; + } + if (evt.event === "error" || evt.event === "stopped") { + reader.cancel(); + setPhase("error"); + setErrorMsg(evt.message); + return; + } + } catch { + // ignore malformed lines + } + } + } + } catch (err: any) { + if (err?.name !== "AbortError") { + setPhase("error"); + setErrorMsg(String(err)); + } + } + }; + + if (phase === "prompt") { + return ( +
+ {t("tunnel_opt_in_title")} +
+ + {t("tunnel_opt_in_yes")} + + + {t("tunnel_opt_in_skip")} + +
+
+ ); + } + + if (phase === "starting") { + return ( +
+
+ {t("tunnel_progress")} + {logs.length > 0 && ( +
+ {logs.map((log, i) => ( +
{log}
+ ))} +
+ )} +
+ ); + } + + if (phase === "done" && tunnelUrl) { + return ( +
+
+ + {tunnelUrl} + + + {t("tunnel_redirect_countdown", { seconds: countdown })} + +
+ ); + } + + if (phase === "error") { + return ( +
+ {t("tunnel_error")} + {errorMsg && ( +

+ {errorMsg} +

+ )} + + {t("tunnel_opt_in_skip")} + +
+ ); + } + + return null; +} diff --git a/src/routes/onboarding/steps/welcome-page.tsx b/src/routes/onboarding/steps/welcome-page.tsx index 80bfc3ff..88af88e5 100644 --- a/src/routes/onboarding/steps/welcome-page.tsx +++ b/src/routes/onboarding/steps/welcome-page.tsx @@ -1,23 +1,182 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useWizard } from "react-use-wizard"; import StyledButton from "@/components/styled/Button"; import PlayIcon from "@/assets/icons/play.svg?url"; +import { BASE_ORIGIN } from "@/app/config"; +import { useGetAutoTunnelInfoQuery } from "@/app/services/server"; + +type Phase = "loading" | "normal" | "initializing" | "dns_wait" | "redirecting"; + +const COUNTDOWN_SECONDS = 10; + +interface SseEvent { + event: string; + message: string; + url: string | null; + progress: number | null; +} export default function WelcomePage() { const { t } = useTranslation("welcome", { keyPrefix: "onboarding" }); const { nextStep } = useWizard(); - return ( -
- {t("welcome")} - {t("welcome_desc")} - - play icon - {t("start")} - -
- ); + const { data: autoInfo, isLoading, isError } = useGetAutoTunnelInfoQuery(); + + const [phase, setPhase] = useState("loading"); + const [logs, setLogs] = useState([]); + const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS); + const [tunnelUrl, setTunnelUrl] = useState(null); + const abortRef = useRef(null); + const countRef = useRef | null>(null); + + // Determine phase once auto_info loads + useEffect(() => { + if (isLoading) return; + if (isError || !autoInfo?.auto_cftunnel) { + setPhase("normal"); + return; + } + // AUTO_CFTUNNEL=true: check if already running + if (autoInfo.tunnel_status.status === "running" && autoInfo.tunnel_status.url) { + setTunnelUrl(autoInfo.tunnel_status.url); + startDnsCountdown(autoInfo.tunnel_status.url); + } else { + setPhase("initializing"); + } + }, [isLoading, autoInfo]); + + // Subscribe to SSE progress stream while initializing + useEffect(() => { + if (phase !== "initializing") return; + + const ctrl = new AbortController(); + abortRef.current = ctrl; + + (async () => { + try { + const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/auto_stream`, { signal: ctrl.signal }); + if (!resp.ok || !resp.body) return; + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + + const parts = buf.split("\n\n"); + buf = parts.pop() ?? ""; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith("data:")) continue; + try { + const evt: SseEvent = JSON.parse(line.slice("data:".length).trim()); + if (evt.message) { + setLogs((prev) => [...prev.slice(-29), evt.message]); + } + if (evt.event === "url" || evt.event === "already_running") { + reader.cancel(); + startDnsCountdown(evt.url!); + return; + } + } catch { + // ignore malformed lines + } + } + } + } catch { + // AbortError on cleanup, or transient network error — stay in initializing + } + })(); + + return () => { + abortRef.current?.abort(); + }; + }, [phase]); + + function startDnsCountdown(url: string) { + setPhase("dns_wait"); + setCountdown(COUNTDOWN_SECONDS); + let remaining = COUNTDOWN_SECONDS; + countRef.current = setInterval(() => { + remaining -= 1; + setCountdown(remaining); + if (remaining <= 0) { + clearInterval(countRef.current!); + setPhase("redirecting"); + window.location.href = url; + } + }, 1000); + } + + useEffect(() => { + return () => { + if (countRef.current) clearInterval(countRef.current); + }; + }, []); + + if (phase === "loading" || isLoading) { + return ( +
+
+
+ ); + } + + if (phase === "normal") { + return ( +
+ {t("welcome")} + {t("welcome_desc")} + + play icon + {t("start")} + +
+ ); + } + + if (phase === "initializing") { + return ( +
+
+ {t("tunnel_initializing")} + {logs.length > 0 && ( +
+ {logs.map((log, i) => ( +
{log}
+ ))} +
+ )} +
+ ); + } + + if (phase === "dns_wait" || phase === "redirecting") { + return ( +
+
+ {tunnelUrl && ( + + {tunnelUrl} + + )} +

+ {t("tunnel_dns_warning")} +

+ + {t("tunnel_redirect_countdown", { seconds: countdown })} + +
+ ); + } + + return null; } diff --git a/src/types/server.ts b/src/types/server.ts index 7fee90da..431c9509 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -150,3 +150,8 @@ export interface CloudflaredStatus { url: string | null; error: string | null; } + +export interface AutoTunnelInfo { + auto_cftunnel: boolean; + tunnel_status: CloudflaredStatus; +}