mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Refactor global styles and enhance MITM functionality
- Updated global CSS to implement a new brand color palette and improve light/dark theme consistency. - Enhanced the MitmServerCard component to provide clearer user feedback regarding admin privileges. - Filtered LLM combos in the CombosPage to ensure only relevant data is displayed. - Improved APIPageClient layout for better usability and visual consistency. - Added functionality to save and load DNS tool states in the MITM manager. - Updated OAuth configuration URLs for Qwen to reflect the new endpoint structure. - Refined tunnel management logic to improve reliability and user experience.
This commit is contained in:
@@ -23,6 +23,8 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
const serverIsWindows = status?.isWin === true;
|
||||
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
|
||||
const isAdmin = status?.isAdmin !== false;
|
||||
// No privilege: not admin/root AND (Win OR no cached sudo password)
|
||||
const noPrivilege = !isAdmin && (serverIsWindows || (!status?.hasCachedPassword && status?.needsSudoPassword !== false));
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -217,6 +219,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
<button
|
||||
onClick={() => handleAction("start")}
|
||||
disabled={loading || (serverIsWindows && !isAdmin)}
|
||||
title={serverIsWindows && !isAdmin ? "Administrator required" : undefined}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">play_circle</span>
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function CombosPage() {
|
||||
const providersData = await providersRes.json();
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
|
||||
if (combosRes.ok) setCombos(combosData.combos || []);
|
||||
// Only LLM combos here — webSearch/webFetch combos belong to media-providers/web
|
||||
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind));
|
||||
if (providersRes.ok) {
|
||||
setActiveProviders(providersData.connections || []);
|
||||
}
|
||||
|
||||
@@ -675,8 +675,8 @@ export default function APIPageClient({ machineId }) {
|
||||
/>
|
||||
{/* Cloudflare Tunnel */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
|
||||
tunnelEnabled ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" : "bg-sidebar text-text-muted"
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
tunnelEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>Tunnel</span>
|
||||
{tunnelEnabled && !tunnelLoading ? (
|
||||
<>
|
||||
@@ -742,7 +742,6 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
setShowEnableTunnelModal(true);
|
||||
}}
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
@@ -750,8 +749,8 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
{/* Tailscale */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
|
||||
tsEnabled ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" : "bg-sidebar text-text-muted"
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
tsEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>Tailscale</span>
|
||||
{tsEnabled && !tsLoading ? (
|
||||
<>
|
||||
@@ -850,8 +849,8 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold">Token Saver</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 pb-4 border-b border-border">
|
||||
<div className="pr-4">
|
||||
<div className="flex items-center justify-between pt-2 pb-4 border-b border-border gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
Compress tool output{" "}
|
||||
<a
|
||||
@@ -864,7 +863,7 @@ export default function APIPageClient({ machineId }) {
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Auto-compress tool output (git diff/grep/ls/tree/logs) before sending to LLM (60-90% fewer input tokens on common dev commands). Disable if you see issues.
|
||||
git/grep/ls/tree/logs → 60-90% fewer input tokens
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -872,8 +871,8 @@ export default function APIPageClient({ machineId }) {
|
||||
onChange={() => handleRtkEnabled(!rtkEnabled)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="pr-4">
|
||||
<div className="flex items-center justify-between pt-4 gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
Compress LLM output{" "}
|
||||
<a
|
||||
@@ -886,32 +885,34 @@ export default function APIPageClient({ machineId }) {
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Inject a terse-style instruction into the system prompt so the LLM replies shorter (~65% fewer output tokens on average, up to 87%). Code, errors and warnings stay exact.
|
||||
Terse-style system prompt → ~65% fewer output tokens (up to 87%)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={cavemanEnabled}
|
||||
onChange={() => handleCavemanEnabled(!cavemanEnabled)}
|
||||
/>
|
||||
</div>
|
||||
{cavemanEnabled && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{CAVEMAN_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl.id}
|
||||
onClick={() => handleCavemanLevel(lvl.id)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
|
||||
cavemanLevel === lvl.id
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-transparent border-border text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
title={lvl.desc}
|
||||
>
|
||||
{lvl.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{cavemanEnabled && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{CAVEMAN_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl.id}
|
||||
onClick={() => handleCavemanLevel(lvl.id)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
|
||||
cavemanLevel === lvl.id
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-transparent border-border text-text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
title={lvl.desc}
|
||||
>
|
||||
{lvl.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Toggle
|
||||
checked={cavemanEnabled}
|
||||
onChange={() => handleCavemanEnabled(!cavemanEnabled)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
@@ -1089,14 +1090,14 @@ export default function APIPageClient({ machineId }) {
|
||||
onClose={() => setShowEnableTunnelModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="bg-surface-2 border border-border-subtle rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">cloud_upload</span>
|
||||
<span className="material-symbols-outlined text-primary">cloud_upload</span>
|
||||
<div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-1">
|
||||
<p className="text-sm text-text-main font-medium mb-1">
|
||||
Cloudflare Tunnel
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="text-sm text-text-muted">
|
||||
Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1118,11 +1119,7 @@ export default function APIPageClient({ machineId }) {
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleEnableTunnel}
|
||||
fullWidth
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
|
||||
>
|
||||
<Button onClick={handleEnableTunnel} fullWidth>
|
||||
Start Tunnel
|
||||
</Button>
|
||||
<Button onClick={() => setShowEnableTunnelModal(false)} variant="ghost" fullWidth>Cancel</Button>
|
||||
@@ -1139,7 +1136,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDisableTunnel} fullWidth disabled={tunnelLoading} className="bg-red-500! hover:bg-red-600! text-white!">
|
||||
<Button onClick={handleDisableTunnel} fullWidth disabled={tunnelLoading} variant="danger">
|
||||
{tunnelLoading ? "Disabling..." : "Disable"}
|
||||
</Button>
|
||||
<Button onClick={() => setShowDisableTunnelModal(false)} variant="ghost" fullWidth disabled={tunnelLoading}>Cancel</Button>
|
||||
@@ -1167,11 +1164,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">Tailscale is not installed. Install it to enable Funnel.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleInstallTailscale}
|
||||
fullWidth
|
||||
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
|
||||
>
|
||||
<Button onClick={handleInstallTailscale} fullWidth>
|
||||
Install Tailscale
|
||||
</Button>
|
||||
<Button onClick={() => setShowTsModal(false)} variant="ghost" fullWidth>Cancel</Button>
|
||||
@@ -1211,7 +1204,6 @@ export default function APIPageClient({ machineId }) {
|
||||
handleConnectTailscale(tab);
|
||||
}}
|
||||
fullWidth
|
||||
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
@@ -1233,7 +1225,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDisableTailscale} fullWidth disabled={tsLoading} className="bg-red-500! hover:bg-red-600! text-white!">
|
||||
<Button onClick={handleDisableTailscale} fullWidth disabled={tsLoading} variant="danger">
|
||||
{tsLoading ? "Disabling..." : "Disable"}
|
||||
</Button>
|
||||
<Button onClick={() => setShowDisableTsModal(false)} variant="ghost" fullWidth disabled={tsLoading}>Cancel</Button>
|
||||
@@ -1248,9 +1240,8 @@ export default function APIPageClient({ machineId }) {
|
||||
function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${badge === "CF" ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" :
|
||||
badge === "TS" ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" :
|
||||
"bg-sidebar text-text-muted"
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>{label}</span>
|
||||
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
|
||||
@@ -75,11 +75,7 @@ export default function MitmPageClient() {
|
||||
const mitmTools = Object.entries(MITM_TOOLS);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-1 sm:px-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold text-text-main sm:text-2xl">MITM</h1>
|
||||
<p className="text-sm text-text-muted">Route supported IDE traffic through 9Router with local DNS interception.</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* MITM Server Card */}
|
||||
<MitmServerCard
|
||||
apiKeys={apiKeys}
|
||||
|
||||
@@ -762,15 +762,15 @@ export default function ProviderDetailPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{providerInfo.name}</h1>
|
||||
{providerInfo.notice?.apiKeyUrl && !providerInfo.deprecated && (
|
||||
{(providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website) && (
|
||||
<a
|
||||
href={providerInfo.notice.apiKeyUrl}
|
||||
href={providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">open_in_new</span>
|
||||
Get API Key
|
||||
{providerInfo.notice?.apiKeyUrl ? "Get API Key" : "Sign up / Learn more"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -911,20 +911,21 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-primary/10 text-primary shrink-0">
|
||||
<span className="material-symbols-outlined text-[18px]">{isOAuth ? "lock" : "key"}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">No connections yet</p>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
||||
{!isCompatible && (
|
||||
<div className="flex flex-col gap-2 justify-center sm:flex-row">
|
||||
<div className="flex gap-2">
|
||||
{providerId === "iflow" && (
|
||||
<Button icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
|
||||
Cookie Auth
|
||||
<Button size="sm" icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
|
||||
Cookie
|
||||
</Button>
|
||||
)}
|
||||
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||
<Button size="sm" icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||
{providerId === "iflow" ? "OAuth" : "Add Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,13 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import RequestDetailsTab from "./components/RequestDetailsTab";
|
||||
|
||||
const PERIODS = [
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "60d", label: "60D" },
|
||||
];
|
||||
|
||||
export default function UsagePage() {
|
||||
return (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
@@ -18,6 +25,7 @@ function UsageContent() {
|
||||
const router = useRouter();
|
||||
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [period, setPeriod] = useState("7d");
|
||||
|
||||
const tabFromUrl = searchParams.get("tab");
|
||||
const activeTab = tabFromUrl && ["overview", "logs", "details"].includes(tabFromUrl)
|
||||
@@ -30,21 +38,32 @@ function UsageContent() {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("tab", value);
|
||||
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
|
||||
// Brief loading flash so user sees feedback
|
||||
setTimeout(() => setTabLoading(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "details", label: "Details" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
{/* Tabs + period selector on same row */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "details", label: "Details" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
{activeTab === "overview" && (
|
||||
<SegmentedControl
|
||||
options={PERIODS}
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tabLoading ? (
|
||||
<CardSkeleton />
|
||||
@@ -52,7 +71,7 @@ function UsageContent() {
|
||||
<>
|
||||
{activeTab === "overview" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageStats />
|
||||
<UsageStats period={period} setPeriod={setPeriod} hidePeriodSelector />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "logs" && <RequestLogger />}
|
||||
@@ -62,4 +81,3 @@ function UsageContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,22 @@ function requiresSudoPassword(pwd) {
|
||||
}
|
||||
|
||||
function checkIsAdmin() {
|
||||
if (!isWin) return true;
|
||||
try {
|
||||
require("child_process").execSync("net session >nul 2>&1", { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
if (isWin) {
|
||||
try {
|
||||
require("child_process").execSync("net session >nul 2>&1", { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return typeof process.getuid === "function" && process.getuid() === 0;
|
||||
}
|
||||
|
||||
function checkPrivilege(pwd) {
|
||||
if (checkIsAdmin()) return true;
|
||||
if (isWin) return false;
|
||||
if (!isSudoPasswordRequired()) return true;
|
||||
return !!pwd;
|
||||
}
|
||||
|
||||
// GET - Full MITM status (server + per-tool DNS)
|
||||
@@ -94,6 +103,13 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkPrivilege(pwd)) {
|
||||
return NextResponse.json(
|
||||
{ error: isWin ? "Administrator required — restart 9Router as Administrator" : "Root or sudo password required to start MITM" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (mitmRouterBaseUrl !== undefined && mitmRouterBaseUrl !== null) {
|
||||
try {
|
||||
const normalized = normalizeMitmRouterBaseUrlInput(mitmRouterBaseUrl);
|
||||
@@ -149,6 +165,12 @@ export async function PATCH(request) {
|
||||
if (requiresSudoPassword(pwd)) {
|
||||
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
|
||||
}
|
||||
if (!checkPrivilege(pwd)) {
|
||||
return NextResponse.json(
|
||||
{ error: isWin ? "Administrator required — restart 9Router as Administrator" : "Root or sudo password required to modify DNS" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "enable") {
|
||||
await enableToolDNS(tool, pwd);
|
||||
|
||||
@@ -138,9 +138,6 @@ export async function GET() {
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
permission: [],
|
||||
root: combo.name,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,9 +151,6 @@ export async function GET() {
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: alias,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -175,9 +169,6 @@ export async function GET() {
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerAlias,
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -267,9 +258,6 @@ export async function GET() {
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1 - Return models list (OpenAI compatible)
|
||||
*/
|
||||
export async function GET() {
|
||||
const models = [
|
||||
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
|
||||
{ id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" },
|
||||
{ id: "gpt-4o", object: "model", owned_by: "openai" },
|
||||
{ id: "gemini-2.5-pro", object: "model", owned_by: "google" }
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
object: "list",
|
||||
data: models
|
||||
}), {
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
|
||||
});
|
||||
}
|
||||
|
||||
export { GET, OPTIONS } from "./models/route";
|
||||
|
||||
@@ -3,80 +3,183 @@
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* macOS-inspired Color Palette with Terracotta Primary */
|
||||
/* ============================================================
|
||||
9Router palette — adopted from 9remote_private/web
|
||||
Brand orange (dark) / soft coral (light), neutral warm bases
|
||||
============================================================ */
|
||||
:root {
|
||||
/* Primary - Warm Coral/Terracotta */
|
||||
--color-primary: #D97757;
|
||||
--color-primary-hover: #C56243;
|
||||
|
||||
/* Light theme */
|
||||
--color-bg: #FBF9F6;
|
||||
--color-bg-alt: #F5F1ED;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-sidebar: rgba(246, 246, 246, 0.8);
|
||||
--color-border: rgba(0, 0, 0, 0.1);
|
||||
--color-text-main: #383733;
|
||||
--color-text-muted: #75736E;
|
||||
|
||||
/* Shadows - subtle macOS style */
|
||||
--shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 12px rgba(0, 0, 0, 0.015);
|
||||
--shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.12);
|
||||
/* Brand scale (light) - centered on #E56A4A */
|
||||
--color-brand-50: #fdf1ed;
|
||||
--color-brand-100: #fadccf;
|
||||
--color-brand-200: #f4b59c;
|
||||
--color-brand-300: #ee8d6a;
|
||||
--color-brand-400: #ea7855;
|
||||
--color-brand-500: #E56A4A;
|
||||
--color-brand-600: #cc5236;
|
||||
--color-brand-700: #a64027;
|
||||
--color-brand-800: #7a2f1d;
|
||||
--color-brand-900: #4d1e12;
|
||||
|
||||
/* Primary (legacy alias for backward compat with existing components) */
|
||||
--color-primary: var(--color-brand-500);
|
||||
--color-primary-hover: var(--color-brand-600);
|
||||
|
||||
/* Surfaces & backgrounds (light) */
|
||||
--color-bg: #FDFAF6;
|
||||
--color-bg-alt: #F7F3EE;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f4f4f5;
|
||||
--color-surface-3: #e7e7e9;
|
||||
--color-sidebar: rgba(244, 241, 236, 0.85);
|
||||
|
||||
/* Borders */
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-subtle: #f1f1f3;
|
||||
|
||||
/* Text */
|
||||
--color-text: #0a0a0a;
|
||||
--color-text-main: #0a0a0a;
|
||||
--color-text-muted: #6B7280;
|
||||
--color-text-subtle: #9CA3AF;
|
||||
|
||||
/* Status */
|
||||
--color-danger: #cf222e;
|
||||
--color-success: #10B981;
|
||||
--color-warning: #F59E0B;
|
||||
--color-info: #3B82F6;
|
||||
|
||||
/* Radius */
|
||||
--radius-brand: 10px;
|
||||
--radius-brand-lg: 14px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.04);
|
||||
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.18);
|
||||
--shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06);
|
||||
--shadow-elev:
|
||||
inset 0 1px 0 0 rgba(255,255,255,0.8),
|
||||
0 1px 2px rgba(15,23,42,0.04),
|
||||
0 12px 36px -8px rgba(15,23,42,0.10);
|
||||
--shadow-focus: 0 0 0 3px rgba(229,106,74,0.18);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme */
|
||||
--color-bg: #191918;
|
||||
/* Brand scale (dark) - centered on #E56A4A, same as light for consistency */
|
||||
--color-brand-50: #fdf1ed;
|
||||
--color-brand-100: #fadccf;
|
||||
--color-brand-200: #f4b59c;
|
||||
--color-brand-300: #ee8d6a;
|
||||
--color-brand-400: #ea7855;
|
||||
--color-brand-500: #E56A4A;
|
||||
--color-brand-600: #cc5236;
|
||||
--color-brand-700: #a64027;
|
||||
--color-brand-800: #7a2f1d;
|
||||
--color-brand-900: #4d1e12;
|
||||
|
||||
--color-primary: #E56A4A;
|
||||
--color-primary-hover: #cc5236;
|
||||
|
||||
/* Surfaces (dark - Claude-like neutral warm) */
|
||||
--color-bg: #1a1a1a;
|
||||
--color-bg-alt: #1F1F1E;
|
||||
--color-surface: #242423;
|
||||
--color-sidebar: rgba(30, 30, 30, 0.8);
|
||||
--color-border: rgba(255, 255, 255, 0.1);
|
||||
--color-text-main: #ECEBE8;
|
||||
--color-text-muted: #9E9D99;
|
||||
|
||||
/* Dark shadows - subtle macOS style */
|
||||
--shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.15);
|
||||
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.3);
|
||||
--color-surface: #262626;
|
||||
--color-surface-2: #303030;
|
||||
--color-surface-3: #3a3a3a;
|
||||
--color-sidebar: rgba(30, 30, 30, 0.85);
|
||||
|
||||
--color-border: #333333;
|
||||
--color-border-subtle: #2a2a2a;
|
||||
|
||||
--color-text: #ededed;
|
||||
--color-text-main: #ededed;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-text-subtle: #6b7280;
|
||||
|
||||
--color-danger: #ef4444;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #fbbf24;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.3);
|
||||
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.25);
|
||||
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.45);
|
||||
--shadow-elev:
|
||||
inset 0 1px 0 0 rgba(255,255,255,0.06),
|
||||
0 1px 2px rgba(0,0,0,0.4),
|
||||
0 16px 48px -8px rgba(0,0,0,0.55);
|
||||
--shadow-focus: 0 0 0 3px rgba(229, 106, 74, 0.18);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Primary */
|
||||
/* Brand scale */
|
||||
--color-brand-50: var(--color-brand-50);
|
||||
--color-brand-100: var(--color-brand-100);
|
||||
--color-brand-200: var(--color-brand-200);
|
||||
--color-brand-300: var(--color-brand-300);
|
||||
--color-brand-400: var(--color-brand-400);
|
||||
--color-brand-500: var(--color-brand-500);
|
||||
--color-brand-600: var(--color-brand-600);
|
||||
--color-brand-700: var(--color-brand-700);
|
||||
--color-brand-800: var(--color-brand-800);
|
||||
--color-brand-900: var(--color-brand-900);
|
||||
|
||||
/* Primary aliases */
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-hover: var(--color-primary-hover);
|
||||
|
||||
/* Auto-switch colors (use CSS variables from :root/.dark) */
|
||||
|
||||
/* Semantic */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-bg-alt: var(--color-bg-alt);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-surface-2: var(--color-surface-2);
|
||||
--color-surface-3: var(--color-surface-3);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-border: var(--color-border);
|
||||
--color-border-subtle: var(--color-border-subtle);
|
||||
--color-text: var(--color-text);
|
||||
--color-text-main: var(--color-text-main);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
|
||||
/* Static colors (for explicit light/dark usage) */
|
||||
--color-bg-light: #FBF9F6;
|
||||
--color-bg-dark: #191918;
|
||||
--color-surface-light: #FFFFFF;
|
||||
--color-surface-dark: #242423;
|
||||
--color-sidebar-light: #F0EFEC;
|
||||
--color-text-subtle: var(--color-text-subtle);
|
||||
--color-accent: var(--color-brand-500);
|
||||
--color-danger: var(--color-danger);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-info: var(--color-info);
|
||||
|
||||
/* Static fallbacks (explicit per-mode usage if needed) */
|
||||
--color-bg-light: #FCFBF9;
|
||||
--color-bg-dark: #1a1a1a;
|
||||
--color-surface-light: #ffffff;
|
||||
--color-surface-dark: #262626;
|
||||
--color-sidebar-light: #F4F1EC;
|
||||
--color-sidebar-dark: #1F1F1E;
|
||||
--color-border-light: #E6E4DD;
|
||||
--color-border-dark: #333331;
|
||||
--color-text-main-light: #383733;
|
||||
--color-text-main-dark: #ECEBE8;
|
||||
--color-text-muted-light: #75736E;
|
||||
--color-text-muted-dark: #9E9D99;
|
||||
|
||||
--color-border-light: #e5e7eb;
|
||||
--color-border-dark: #333333;
|
||||
--color-text-main-light: #0a0a0a;
|
||||
--color-text-main-dark: #ededed;
|
||||
--color-text-muted-light: #6B7280;
|
||||
--color-text-muted-dark: #9ca3af;
|
||||
|
||||
/* Radius */
|
||||
--radius-brand: var(--radius-brand);
|
||||
--radius-brand-lg: var(--radius-brand-lg);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-soft: var(--shadow-soft);
|
||||
--shadow-warm: var(--shadow-warm);
|
||||
--shadow-elevated: var(--shadow-elevated);
|
||||
|
||||
/* Font - macOS system fonts */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
|
||||
--shadow-elev: var(--shadow-elev);
|
||||
--shadow-focus: var(--shadow-focus);
|
||||
|
||||
/* Font - Inter primary, Apple system fallback */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
/* Base */
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-main);
|
||||
@@ -85,38 +188,94 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
/* Selection - brand-tinted */
|
||||
::selection {
|
||||
background-color: rgba(217, 119, 87, 0.2);
|
||||
background-color: rgba(229, 106, 74, 0.25);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.dark ::selection {
|
||||
background-color: rgba(229, 106, 74, 0.3);
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* iOS keyboard accent */
|
||||
input, textarea {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Scrollbar (custom-scrollbar utility kept for compat) */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Hero gradient */
|
||||
/* Thin horizontal scrollbar - brand colored */
|
||||
.scroll-thin-x {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
|
||||
}
|
||||
.dark .scroll-thin-x {
|
||||
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
|
||||
}
|
||||
.scroll-thin-x::-webkit-scrollbar { height: 3px; }
|
||||
.scroll-thin-x::-webkit-scrollbar-track { background: transparent; }
|
||||
.scroll-thin-x::-webkit-scrollbar-thumb {
|
||||
background: rgba(229, 106, 74, 0.55);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dark .scroll-thin-x::-webkit-scrollbar-thumb {
|
||||
background: rgba(229, 106, 74, 0.55);
|
||||
}
|
||||
|
||||
/* Reusable elevated card */
|
||||
.card-soft {
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
border-radius: var(--radius-brand-lg);
|
||||
}
|
||||
.card-elev {
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: var(--shadow-elev);
|
||||
border-radius: var(--radius-brand-lg);
|
||||
}
|
||||
|
||||
/* Hero gradient (compat) */
|
||||
.bg-hero-gradient {
|
||||
background: linear-gradient(180deg, #F5F1ED 0%, #FEFCFB 100%);
|
||||
background: linear-gradient(180deg, var(--color-bg-alt) 0%, var(--color-bg) 100%);
|
||||
}
|
||||
|
||||
.dark .bg-hero-gradient {
|
||||
background: linear-gradient(180deg, #1F1F1E 0%, #191918 100%);
|
||||
/* macOS Vibrancy */
|
||||
.bg-vibrancy {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.dark .bg-vibrancy {
|
||||
background: rgba(38, 38, 38, 0.72);
|
||||
}
|
||||
|
||||
/* macOS Traffic Lights */
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.traffic-light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.traffic-light.red { background: #FF5F56; }
|
||||
.traffic-light.yellow { background: #FFBD2E; }
|
||||
.traffic-light.green { background: #27C93F; }
|
||||
|
||||
/* Material Symbols */
|
||||
.material-symbols-outlined {
|
||||
@@ -136,86 +295,117 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
.material-symbols-outlined.fill-1 {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
/* Disable text selection on buttons */
|
||||
button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
/* ============================================================
|
||||
Animations
|
||||
============================================================ */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
||||
|
||||
@keyframes border-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(217, 119, 87, 0.3), 0 0 10px rgba(217, 119, 87, 0.2);
|
||||
border-color: rgba(217, 119, 87, 0.5);
|
||||
box-shadow: 0 0 5px rgba(229, 106, 74, 0.3), 0 0 10px rgba(229, 106, 74, 0.2);
|
||||
border-color: rgba(229, 106, 74, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px rgba(217, 119, 87, 0.5), 0 0 20px rgba(217, 119, 87, 0.3);
|
||||
border-color: rgba(217, 119, 87, 0.8);
|
||||
box-shadow: 0 0 10px rgba(229, 106, 74, 0.5), 0 0 20px rgba(229, 106, 74, 0.3);
|
||||
border-color: rgba(229, 106, 74, 0.8);
|
||||
}
|
||||
}
|
||||
.animate-border-glow { animation: border-glow 2s ease-in-out infinite; }
|
||||
|
||||
.animate-border-glow {
|
||||
animation: border-glow 2s ease-in-out infinite;
|
||||
@keyframes slideInFromRight {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes slideInFromTop {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.slide-in-right { animation: slideInFromRight 0.25s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
|
||||
.fade-in { animation: fadeIn 0.2s ease-out forwards; }
|
||||
.slide-in-top { animation: slideInFromTop 0.18s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.05); }
|
||||
}
|
||||
.animate-pulse-glow { animation: pulseGlow 3s ease-in-out infinite; }
|
||||
|
||||
/* CTA shimmer + glow pulse */
|
||||
@keyframes ctaShimmer {
|
||||
0% { transform: translateX(-120%) skewX(-20deg); }
|
||||
100% { transform: translateX(220%) skewX(-20deg); }
|
||||
}
|
||||
@keyframes ctaGlowPulse {
|
||||
0%, 100% { box-shadow: 0 8px 24px -8px rgba(229, 106, 74, 0.45), 0 0 0 0 rgba(229, 106, 74, 0.5); }
|
||||
50% { box-shadow: 0 12px 32px -8px rgba(229, 106, 74, 0.6), 0 0 0 8px rgba(229, 106, 74, 0); }
|
||||
}
|
||||
.btn-cta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
animation: ctaGlowPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.btn-cta::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.35), transparent);
|
||||
animation: ctaShimmer 2.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-cta > * { position: relative; z-index: 2; }
|
||||
|
||||
/* Dot Grid Background Pattern */
|
||||
.dot-grid-bg {
|
||||
background-color: var(--color-bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.10) 0%, transparent 40%),
|
||||
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.06) 0%, transparent 40%);
|
||||
}
|
||||
.dark .dot-grid-bg {
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.18) 0%, transparent 40%),
|
||||
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.10) 0%, transparent 40%);
|
||||
}
|
||||
|
||||
/* macOS Vibrancy/Blur Effect */
|
||||
.bg-vibrancy {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
/* Landing-style faint grid overlay (use absolute pos inside relative parent) */
|
||||
.landing-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--color-accent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--color-accent) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.dark .bg-vibrancy {
|
||||
background: rgba(30, 30, 30, 0.72);
|
||||
}
|
||||
|
||||
/* macOS Traffic Lights */
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.traffic-light.red {
|
||||
background: #FF5F56;
|
||||
}
|
||||
|
||||
.traffic-light.yellow {
|
||||
background: #FFBD2E;
|
||||
}
|
||||
|
||||
.traffic-light.green {
|
||||
background: #27C93F;
|
||||
.dark .landing-grid {
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
/* Changelog markdown body */
|
||||
@@ -226,9 +416,7 @@ body {
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.changelog-body h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.changelog-body h1:first-child { margin-top: 0; }
|
||||
.changelog-body h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
@@ -245,14 +433,8 @@ body {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.changelog-body li {
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.changelog-body p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.changelog-body li { margin: 0.25rem 0; line-height: 1.6; }
|
||||
.changelog-body p { margin: 0.5rem 0; line-height: 1.6; }
|
||||
.changelog-body code {
|
||||
background: var(--color-bg-alt);
|
||||
padding: 0.1rem 0.35rem;
|
||||
|
||||
@@ -82,8 +82,10 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg p-4 relative overflow-hidden">
|
||||
{/* Faint grid background */}
|
||||
<div className="landing-grid absolute inset-0 pointer-events-none" aria-hidden="true" />
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
|
||||
<p className="text-text-muted">Enter your password to access the dashboard</p>
|
||||
|
||||
@@ -152,8 +152,10 @@ export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName)
|
||||
const updaterPath = ensureRuntimeUpdater(resolveBundledUpdaterPath());
|
||||
const isTray = process.env.TRAY_MODE === "1";
|
||||
const relaunch = resolveRelaunchCommand();
|
||||
// Only relaunch in tray/background mode — foreground CLI loses TTY on exit
|
||||
const relaunchArgs = isTray ? [...relaunch.args, "--tray", "--skip-update"] : [];
|
||||
// Relaunch matching original env: tray stays tray, foreground stays foreground
|
||||
const relaunchArgs = isTray
|
||||
? [...relaunch.args, "--tray", "--skip-update"]
|
||||
: [...relaunch.args, "--skip-update"];
|
||||
|
||||
spawn(process.execPath, [updaterPath], {
|
||||
detached: true,
|
||||
@@ -171,7 +173,7 @@ export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName)
|
||||
UPDATER_WAIT_MAX_MS: String(UPDATER_CONFIG.waitForExitMaxMs),
|
||||
UPDATER_WAIT_CHECK_MS: String(UPDATER_CONFIG.waitForExitCheckMs),
|
||||
UPDATER_APP_PORT: String(UPDATER_CONFIG.appPort),
|
||||
UPDATER_RELAUNCH: isTray ? "1" : "0",
|
||||
UPDATER_RELAUNCH: "1",
|
||||
UPDATER_RELAUNCH_CMD: relaunch.cmd,
|
||||
UPDATER_RELAUNCH_ARGS: JSON.stringify(relaunchArgs),
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ const DEFAULT_SETTINGS = {
|
||||
outboundProxyUrl: "",
|
||||
outboundNoProxy: "",
|
||||
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
|
||||
dnsToolEnabled: {},
|
||||
rtkEnabled: true,
|
||||
cavemanEnabled: false,
|
||||
cavemanLevel: "full",
|
||||
|
||||
@@ -57,8 +57,8 @@ export const GEMINI_CONFIG = {
|
||||
// Qwen OAuth Configuration (Device Code Flow with PKCE)
|
||||
export const QWEN_CONFIG = {
|
||||
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
deviceCodeUrl: "https://qwen.ai/api/v1/oauth2/device/code",
|
||||
tokenUrl: "https://qwen.ai/api/v1/oauth2/token",
|
||||
deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
|
||||
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
scope: "openid profile email model.completion",
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
@@ -257,10 +257,8 @@ export async function spawnCloudflared(tunnelToken) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only notify on unexpected exit AFTER successful connection
|
||||
if (wasConnected && unexpectedExitHandler) {
|
||||
unexpectedExitHandler();
|
||||
}
|
||||
// Watchdog (initializeApp) handles recovery — no auto-reconnect here
|
||||
if (wasConnected && unexpectedExitHandler) unexpectedExitHandler();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
67
src/lib/tunnel/networkProbe.js
Normal file
67
src/lib/tunnel/networkProbe.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import net from "net";
|
||||
import dns from "dns";
|
||||
import { INTERNET_CHECK, HEALTH_CHECK } from "./tunnelConfig.js";
|
||||
|
||||
// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN)
|
||||
const resolver = new dns.promises.Resolver();
|
||||
resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
|
||||
|
||||
export function checkInternet() {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let done = false;
|
||||
const finish = (ok) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { socket.destroy(); } catch { /* ignore */ }
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(INTERNET_CHECK.timeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); }
|
||||
catch { finish(false); }
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDns(hostname, timeoutMs) {
|
||||
try {
|
||||
await Promise.race([
|
||||
resolver.resolve4(hostname),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Single health probe: DNS via 1.1.1.1 → fetch /api/health
|
||||
export async function probeUrlAlive(url) {
|
||||
if (!url) return false;
|
||||
let hostname;
|
||||
try { hostname = new URL(url).hostname; } catch { return false; }
|
||||
|
||||
if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`, {
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until tunnel responds /api/health, or timeout. Cancellable via token.
|
||||
export async function waitForHealth(url, cancelToken = { cancelled: false }) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < HEALTH_CHECK.timeoutMs) {
|
||||
if (cancelToken.cancelled) throw new Error("cancelled");
|
||||
if (await probeUrlAlive(url)) return true;
|
||||
await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));
|
||||
}
|
||||
throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`);
|
||||
}
|
||||
18
src/lib/tunnel/tunnelConfig.js
Normal file
18
src/lib/tunnel/tunnelConfig.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Tunnel + Tailscale shared config (all values in ms)
|
||||
export const HEALTH_CHECK = {
|
||||
intervalMs: 2000,
|
||||
timeoutMs: 180000,
|
||||
fetchTimeoutMs: 5000,
|
||||
dnsTimeoutMs: 2000,
|
||||
};
|
||||
|
||||
export const INTERNET_CHECK = {
|
||||
host: "1.1.1.1",
|
||||
port: 443,
|
||||
timeoutMs: 3000,
|
||||
};
|
||||
|
||||
export const RESTART_COOLDOWN_MS = 60000;
|
||||
export const NETWORK_SETTLE_MS = 2500;
|
||||
export const WATCHDOG_INTERVAL_MS = 60000;
|
||||
export const NETWORK_CHECK_INTERVAL_MS = 5000;
|
||||
@@ -4,27 +4,34 @@ import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedE
|
||||
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleLoggedIn, startLogin, startDaemonWithPassword } from "./tailscale.js";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
||||
import { waitForHealth, probeUrlAlive } from "./networkProbe.js";
|
||||
|
||||
initDbHooks(getSettings, updateSettings);
|
||||
|
||||
const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://9router.com";
|
||||
const MACHINE_ID_SALT = "9router-tunnel-salt";
|
||||
const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
|
||||
const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length;
|
||||
|
||||
let isReconnecting = false;
|
||||
let exitHandlerRegistered = false;
|
||||
let reconnectTimeoutId = null;
|
||||
let manualDisabled = false;
|
||||
let activeLocalPort = null;
|
||||
// Per-service state (independent: tunnel ≠ tailscale)
|
||||
const tunnelSvc = {
|
||||
cancelToken: { cancelled: false },
|
||||
spawnInProgress: false,
|
||||
lastRestartAt: 0,
|
||||
activeLocalPort: null,
|
||||
};
|
||||
|
||||
export function isTunnelManuallyDisabled() {
|
||||
return manualDisabled;
|
||||
}
|
||||
const tailscaleSvc = {
|
||||
cancelToken: { cancelled: false },
|
||||
spawnInProgress: false,
|
||||
lastRestartAt: 0,
|
||||
activeLocalPort: null,
|
||||
};
|
||||
|
||||
export function isTunnelReconnecting() {
|
||||
return isReconnecting;
|
||||
}
|
||||
export function getTunnelService() { return tunnelSvc; }
|
||||
export function getTailscaleService() { return tailscaleSvc; }
|
||||
|
||||
export function isTunnelManuallyDisabled() { return tunnelSvc.cancelToken.cancelled; }
|
||||
export function isTunnelReconnecting() { return tunnelSvc.spawnInProgress; }
|
||||
export function isTailscaleReconnecting() { return tailscaleSvc.spawnInProgress; }
|
||||
|
||||
function getMachineId() {
|
||||
try {
|
||||
@@ -46,96 +53,65 @@ async function registerTunnelUrl(shortId, tunnelUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableTunnel(localPort = 20128) {
|
||||
manualDisabled = false;
|
||||
activeLocalPort = localPort;
|
||||
|
||||
if (isCloudflaredRunning()) {
|
||||
const existing = loadState();
|
||||
if (existing?.tunnelUrl) {
|
||||
const publicUrl = `https://r${existing.shortId}.9router.com`;
|
||||
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
|
||||
}
|
||||
}
|
||||
|
||||
killCloudflared(localPort);
|
||||
|
||||
const machineId = getMachineId();
|
||||
const existing = loadState();
|
||||
const shortId = existing?.shortId || generateShortId();
|
||||
|
||||
// onUrlUpdate: called when URL changes AFTER initial connect
|
||||
const onUrlUpdate = async (url) => {
|
||||
if (manualDisabled) return;
|
||||
await registerTunnelUrl(shortId, url);
|
||||
saveState({ shortId, machineId, tunnelUrl: url });
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
|
||||
};
|
||||
|
||||
const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
|
||||
|
||||
await registerTunnelUrl(shortId, tunnelUrl);
|
||||
saveState({ shortId, machineId, tunnelUrl });
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl });
|
||||
|
||||
if (!exitHandlerRegistered) {
|
||||
setUnexpectedExitHandler(() => {
|
||||
if (!isReconnecting) scheduleReconnect(0);
|
||||
});
|
||||
exitHandlerRegistered = true;
|
||||
}
|
||||
|
||||
const publicUrl = `https://r${shortId}.9router.com`;
|
||||
return { success: true, tunnelUrl, shortId, publicUrl };
|
||||
function throwIfCancelled(token, label) {
|
||||
if (token.cancelled) throw new Error(`${label} cancelled`);
|
||||
}
|
||||
|
||||
async function scheduleReconnect(attempt) {
|
||||
if (isReconnecting || manualDisabled) return;
|
||||
isReconnecting = true;
|
||||
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
||||
console.log(`[Tunnel] Reconnecting in ${delay / 1000}s (attempt ${attempt + 1})...`);
|
||||
|
||||
await new Promise((r) => { reconnectTimeoutId = setTimeout(r, delay); });
|
||||
export async function enableTunnel(localPort = 20128) {
|
||||
tunnelSvc.cancelToken = { cancelled: false };
|
||||
tunnelSvc.activeLocalPort = localPort;
|
||||
tunnelSvc.spawnInProgress = true;
|
||||
const token = tunnelSvc.cancelToken;
|
||||
|
||||
try {
|
||||
if (manualDisabled) { isReconnecting = false; return; }
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) { isReconnecting = false; return; }
|
||||
await enableTunnel();
|
||||
console.log("[Tunnel] Reconnected successfully");
|
||||
isReconnecting = false;
|
||||
} catch (err) {
|
||||
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
|
||||
isReconnecting = false;
|
||||
const next = attempt + 1;
|
||||
if (next < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(next);
|
||||
else {
|
||||
console.log("[Tunnel] All reconnect attempts exhausted, disabling tunnel");
|
||||
await updateSettings({ tunnelEnabled: false });
|
||||
if (isCloudflaredRunning()) {
|
||||
const existing = loadState();
|
||||
if (existing?.tunnelUrl && await probeUrlAlive(existing.tunnelUrl)) {
|
||||
const publicUrl = `https://r${existing.shortId}.9router.com`;
|
||||
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
|
||||
}
|
||||
}
|
||||
|
||||
killCloudflared(localPort);
|
||||
throwIfCancelled(token, "tunnel");
|
||||
|
||||
const machineId = getMachineId();
|
||||
const existing = loadState();
|
||||
const shortId = existing?.shortId || generateShortId();
|
||||
|
||||
const onUrlUpdate = async (url) => {
|
||||
if (token.cancelled) return;
|
||||
await registerTunnelUrl(shortId, url);
|
||||
saveState({ shortId, machineId, tunnelUrl: url });
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
|
||||
};
|
||||
|
||||
const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
|
||||
throwIfCancelled(token, "tunnel");
|
||||
|
||||
const publicUrl = `https://r${shortId}.9router.com`;
|
||||
await registerTunnelUrl(shortId, tunnelUrl);
|
||||
saveState({ shortId, machineId, tunnelUrl });
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl });
|
||||
|
||||
// Block until /api/health responds via public URL — proves DNS propagated + tunnel works
|
||||
await waitForHealth(publicUrl, token);
|
||||
|
||||
return { success: true, tunnelUrl, shortId, publicUrl };
|
||||
} finally {
|
||||
tunnelSvc.spawnInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableTunnel() {
|
||||
manualDisabled = true;
|
||||
isReconnecting = true;
|
||||
if (reconnectTimeoutId) {
|
||||
clearTimeout(reconnectTimeoutId);
|
||||
reconnectTimeoutId = null;
|
||||
}
|
||||
tunnelSvc.cancelToken.cancelled = true;
|
||||
setUnexpectedExitHandler(null);
|
||||
exitHandlerRegistered = false;
|
||||
|
||||
killCloudflared(activeLocalPort);
|
||||
killCloudflared(tunnelSvc.activeLocalPort);
|
||||
|
||||
const state = loadState();
|
||||
if (state) {
|
||||
saveState({ shortId: state.shortId, machineId: state.machineId, tunnelUrl: null });
|
||||
}
|
||||
if (state) saveState({ shortId: state.shortId, machineId: state.machineId, tunnelUrl: null });
|
||||
|
||||
await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
|
||||
isReconnecting = false;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -158,43 +134,52 @@ export async function getTunnelStatus() {
|
||||
// ─── Tailscale Funnel ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function enableTailscale(localPort = 20128) {
|
||||
// Ensure daemon is running (needs sudo for TUN mode)
|
||||
const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
|
||||
await startDaemonWithPassword(sudoPass);
|
||||
tailscaleSvc.cancelToken = { cancelled: false };
|
||||
tailscaleSvc.activeLocalPort = localPort;
|
||||
tailscaleSvc.spawnInProgress = true;
|
||||
const token = tailscaleSvc.cancelToken;
|
||||
|
||||
// Generate hostname from machine ID (same as tunnel shortId prefix)
|
||||
const existing = loadState();
|
||||
const shortId = existing?.shortId || generateShortId();
|
||||
const tsHostname = shortId;
|
||||
try {
|
||||
const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
|
||||
await startDaemonWithPassword(sudoPass);
|
||||
throwIfCancelled(token, "tailscale");
|
||||
|
||||
// If not logged in, return auth URL for user to authenticate
|
||||
if (!isTailscaleLoggedIn()) {
|
||||
const loginResult = await startLogin(tsHostname);
|
||||
if (loginResult.authUrl) {
|
||||
return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
|
||||
const existing = loadState();
|
||||
const shortId = existing?.shortId || generateShortId();
|
||||
const tsHostname = shortId;
|
||||
|
||||
if (!isTailscaleLoggedIn()) {
|
||||
const loginResult = await startLogin(tsHostname);
|
||||
if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
|
||||
}
|
||||
}
|
||||
throwIfCancelled(token, "tailscale");
|
||||
|
||||
stopFunnel();
|
||||
const result = await startFunnel(localPort);
|
||||
|
||||
// Funnel not enabled on tailnet — return enable URL
|
||||
if (result.funnelNotEnabled) {
|
||||
return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
|
||||
}
|
||||
|
||||
// Verify device is actually connected (BackendState=Running + funnel active)
|
||||
if (!isTailscaleLoggedIn() || !isTailscaleRunning()) {
|
||||
stopFunnel();
|
||||
return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
|
||||
}
|
||||
const result = await startFunnel(localPort);
|
||||
throwIfCancelled(token, "tailscale");
|
||||
|
||||
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
|
||||
return { success: true, tunnelUrl: result.tunnelUrl };
|
||||
if (result.funnelNotEnabled) {
|
||||
return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
|
||||
}
|
||||
|
||||
if (!isTailscaleLoggedIn() || !isTailscaleRunning()) {
|
||||
stopFunnel();
|
||||
return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
|
||||
}
|
||||
|
||||
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
|
||||
|
||||
// Verify funnel actually serves /api/health
|
||||
await waitForHealth(result.tunnelUrl, token);
|
||||
|
||||
return { success: true, tunnelUrl: result.tunnelUrl };
|
||||
} finally {
|
||||
tailscaleSvc.spawnInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableTailscale() {
|
||||
// Only reset funnel — keep tailscaled daemon running to avoid breaking other apps using Tailscale
|
||||
tailscaleSvc.cancelToken.cancelled = true;
|
||||
stopFunnel();
|
||||
await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" });
|
||||
return { success: true };
|
||||
|
||||
@@ -65,6 +65,14 @@ function execWithPassword(command, password) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim trailing blank lines/whitespace, ensure file ends with exactly one newline.
|
||||
*/
|
||||
function normalizeHostsContent(content) {
|
||||
const eol = IS_WIN ? "\r\n" : "\n";
|
||||
return content.replace(/[\r\n\s]+$/g, "") + eol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush DNS cache (macOS/Linux)
|
||||
*/
|
||||
@@ -120,19 +128,26 @@ async function addDNSEntry(tool, sudoPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n");
|
||||
|
||||
try {
|
||||
if (IS_WIN) {
|
||||
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("`r`n");
|
||||
// Single elevated script: append to hosts + flush DNS (1 UAC popup, or zero if admin)
|
||||
// Read → trim → append → write (avoids stacked blank lines from Add-Content)
|
||||
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
const trimmed = current.replace(/[\r\n\s]+$/g, "");
|
||||
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n");
|
||||
const next = `${trimmed}\r\n${toAppend}\r\n`;
|
||||
const script = `
|
||||
Add-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(toAppend)}
|
||||
Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(next)} -NoNewline
|
||||
ipconfig /flushdns | Out-Null
|
||||
`;
|
||||
await runElevatedPowerShell(script);
|
||||
} else {
|
||||
await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword);
|
||||
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
const trimmed = current.replace(/[\r\n\s]+$/g, "");
|
||||
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n");
|
||||
const next = `${trimmed}\n${toAppend}\n`;
|
||||
// Use tee via sudo to overwrite atomically — escape single quotes in content
|
||||
const escaped = next.replace(/'/g, "'\\''");
|
||||
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
||||
await flushDNS(sudoPassword);
|
||||
}
|
||||
log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`);
|
||||
@@ -157,26 +172,20 @@ async function removeDNSEntry(tool, sudoPassword) {
|
||||
|
||||
try {
|
||||
if (IS_WIN) {
|
||||
// Build PowerShell list literal of hosts to strip
|
||||
const hostsList = entriesToRemove.map(quotePs).join(",");
|
||||
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n");
|
||||
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n";
|
||||
const script = `
|
||||
$hosts = @(${hostsList})
|
||||
$lines = Get-Content -LiteralPath ${quotePs(HOSTS_FILE)}
|
||||
$filtered = $lines | Where-Object {
|
||||
$line = $_
|
||||
-not ($hosts | Where-Object { $line -match [regex]::Escape($_) })
|
||||
}
|
||||
Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value $filtered
|
||||
Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(next)} -NoNewline
|
||||
ipconfig /flushdns | Out-Null
|
||||
`;
|
||||
await runElevatedPowerShell(script);
|
||||
} else {
|
||||
for (const host of entriesToRemove) {
|
||||
const sedCmd = IS_MAC
|
||||
? `sed -i '' '/${host}/d' ${HOSTS_FILE}`
|
||||
: `sed -i '/${host}/d' ${HOSTS_FILE}`;
|
||||
await execWithPassword(sedCmd, sudoPassword);
|
||||
}
|
||||
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\n");
|
||||
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
|
||||
const escaped = next.replace(/'/g, "'\\''");
|
||||
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
||||
await flushDNS(sudoPassword);
|
||||
}
|
||||
log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`);
|
||||
@@ -210,8 +219,9 @@ function removeAllDNSEntriesSync() {
|
||||
const content = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
const eol = IS_WIN ? "\r\n" : "\n";
|
||||
const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join(eol);
|
||||
if (filtered === content) return;
|
||||
fs.writeFileSync(HOSTS_FILE, filtered, "utf8");
|
||||
const next = filtered.replace(/[\r\n\s]+$/g, "") + eol;
|
||||
if (next === content) return;
|
||||
fs.writeFileSync(HOSTS_FILE, next, "utf8");
|
||||
if (IS_WIN) {
|
||||
try { execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ }
|
||||
} else if (IS_MAC) {
|
||||
|
||||
@@ -5,7 +5,8 @@ const os = require("os");
|
||||
const net = require("net");
|
||||
const https = require("https");
|
||||
const crypto = require("crypto");
|
||||
const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig");
|
||||
const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, removeAllDNSEntriesSync, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig");
|
||||
const { isAdmin } = require("./winElevated.js");
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const IS_MAC = process.platform === "darwin";
|
||||
@@ -219,6 +220,55 @@ async function loadEncryptedPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDnsToolState(tool, enabled) {
|
||||
if (!_updateSettings || !_getSettings) return;
|
||||
try {
|
||||
const s = await _getSettings();
|
||||
const next = { ...(s.dnsToolEnabled || {}), [tool]: enabled };
|
||||
await _updateSettings({ dnsToolEnabled: next });
|
||||
} catch (e) {
|
||||
err(`Failed to save DNS state: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDnsToolState() {
|
||||
if (!_getSettings) return {};
|
||||
try {
|
||||
const s = await _getSettings();
|
||||
return s.dnsToolEnabled || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply DNS for tools previously enabled — called on app startup after MITM running.
|
||||
*/
|
||||
async function restoreToolDNS(sudoPassword) {
|
||||
const state = await loadDnsToolState();
|
||||
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
|
||||
for (const [tool, enabled] of Object.entries(state)) {
|
||||
if (!enabled || !TOOL_HOSTS[tool]) continue;
|
||||
try {
|
||||
await addDNSEntry(tool, password);
|
||||
} catch (e) {
|
||||
err(`DNS ${tool}: restore failed — ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has privilege to mutate hosts file.
|
||||
* Win: needs admin. Mac/Linux: root OR cached/encrypted sudo password.
|
||||
*/
|
||||
async function hasDnsPrivilege() {
|
||||
if (IS_WIN) return isAdmin();
|
||||
if (isAdmin()) return true;
|
||||
if (!isSudoPasswordRequired()) return true;
|
||||
const pwd = getCachedPassword() || await loadEncryptedPassword();
|
||||
return !!pwd;
|
||||
}
|
||||
|
||||
function checkPort443Free() {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net.createServer();
|
||||
@@ -634,7 +684,8 @@ async function stopServer(sudoPassword) {
|
||||
// Direct fs write — bypass PowerShell to avoid parser pitfalls
|
||||
const content = fs.readFileSync(hostsFile, "utf8");
|
||||
const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n");
|
||||
if (filtered !== content) fs.writeFileSync(hostsFile, filtered, "utf8");
|
||||
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n";
|
||||
if (next !== content) fs.writeFileSync(hostsFile, next, "utf8");
|
||||
try { require("child_process").execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ }
|
||||
log("🌐 DNS: ✅ all tool hosts removed");
|
||||
} else {
|
||||
@@ -669,10 +720,10 @@ async function stopServer(sudoPassword) {
|
||||
async function enableToolDNS(tool, sudoPassword) {
|
||||
const status = await getMitmStatus();
|
||||
if (!status.running) throw new Error("MITM server is not running. Start the server first.");
|
||||
|
||||
// Use cached password if not provided
|
||||
|
||||
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
|
||||
await addDNSEntry(tool, password);
|
||||
await saveDnsToolState(tool, true);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -680,9 +731,9 @@ async function enableToolDNS(tool, sudoPassword) {
|
||||
* Disable DNS for a specific tool
|
||||
*/
|
||||
async function disableToolDNS(tool, sudoPassword) {
|
||||
// Use cached password if not provided
|
||||
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
|
||||
await removeDNSEntry(tool, password);
|
||||
await saveDnsToolState(tool, false);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -723,4 +774,7 @@ module.exports = {
|
||||
clearEncryptedPassword,
|
||||
isSudoPasswordRequired,
|
||||
initDbHooks,
|
||||
restoreToolDNS,
|
||||
hasDnsPrivilege,
|
||||
removeAllDNSEntriesSync,
|
||||
};
|
||||
|
||||
@@ -7,13 +7,15 @@ const IS_WIN = process.platform === "win32";
|
||||
* Uses `net session` which only succeeds when elevated.
|
||||
*/
|
||||
function isAdmin() {
|
||||
if (!IS_WIN) return false;
|
||||
try {
|
||||
execSync("net session >nul 2>&1", { windowsHide: true, stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
if (IS_WIN) {
|
||||
try {
|
||||
execSync("net session >nul 2>&1", { windowsHide: true, stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return typeof process.getuid === "function" && process.getuid() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
const variants = {
|
||||
default: "bg-black/5 dark:bg-white/10 text-text-muted",
|
||||
primary: "bg-primary/10 text-primary",
|
||||
default: "bg-surface-2 text-text-muted",
|
||||
primary: "bg-brand-500/10 text-brand-600 dark:text-brand-300",
|
||||
success: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
warning: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
error: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
@@ -42,7 +42,7 @@ export default function Badge({
|
||||
variant === "warning" && "bg-yellow-500",
|
||||
variant === "error" && "bg-red-500",
|
||||
variant === "info" && "bg-blue-500",
|
||||
variant === "primary" && "bg-primary",
|
||||
variant === "primary" && "bg-brand-500",
|
||||
variant === "default" && "bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
@@ -52,4 +52,3 @@ export default function Badge({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
const variants = {
|
||||
primary: "bg-gradient-to-b from-primary to-primary-hover text-white shadow-sm",
|
||||
secondary: "bg-white dark:bg-white/10 border border-black/10 dark:border-white/10 text-text-main hover:bg-black/5 dark:hover:bg-white/5",
|
||||
outline: "border border-black/15 dark:border-white/15 text-text-main hover:bg-black/5",
|
||||
ghost: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-text-main",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
|
||||
primary: "bg-brand-500 hover:bg-brand-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
secondary: "bg-surface-2 hover:bg-surface-3 text-text-main border border-border disabled:opacity-50",
|
||||
outline: "border border-border text-text-main hover:bg-surface-2 hover:border-brand-500/40",
|
||||
ghost: "text-text-muted hover:bg-surface-2 hover:text-text-main",
|
||||
danger: "bg-red-500 hover:bg-red-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
success: "bg-green-600 hover:bg-green-700 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-7 px-3 text-xs rounded-md",
|
||||
md: "h-9 px-4 text-sm rounded-lg",
|
||||
lg: "h-11 px-6 text-sm rounded-lg",
|
||||
sm: "h-7 px-3 text-xs rounded-[8px]",
|
||||
md: "h-9 px-4 text-sm rounded-[10px]",
|
||||
lg: "h-11 px-6 text-sm rounded-[10px]",
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
@@ -31,8 +32,8 @@ export default function Button({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 cursor-pointer",
|
||||
"active:scale-[0.99] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100",
|
||||
"inline-flex items-center justify-center gap-2 font-semibold transition-all duration-150 ease-out cursor-pointer",
|
||||
"active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
fullWidth && "w-full",
|
||||
@@ -53,4 +54,3 @@ export default function Button({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function Card({
|
||||
action,
|
||||
padding = "md",
|
||||
hover = false,
|
||||
elev = false,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
@@ -24,10 +25,9 @@ export default function Card({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-surface",
|
||||
"border border-black/5 dark:border-white/5",
|
||||
"rounded-lg shadow-sm",
|
||||
hover && "hover:shadow-md hover:border-primary/30 transition-all cursor-pointer",
|
||||
"bg-surface border border-border-subtle",
|
||||
elev ? "rounded-[14px] shadow-[var(--shadow-elev)]" : "rounded-[14px] shadow-[var(--shadow-soft)]",
|
||||
hover && "hover:shadow-[var(--shadow-warm)] hover:border-brand-500/30 transition-all cursor-pointer",
|
||||
paddings[padding],
|
||||
className
|
||||
)}
|
||||
@@ -37,7 +37,7 @@ export default function Card({
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="p-2 rounded-lg bg-bg text-text-muted">
|
||||
<div className="p-2 rounded-[10px] bg-bg text-text-muted">
|
||||
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -58,14 +58,12 @@ export default function Card({
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-component: Bordered section inside Card
|
||||
Card.Section = function CardSection({ children, className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg",
|
||||
"bg-black/[0.02] dark:bg-white/[0.02]",
|
||||
"border border-black/5 dark:border-white/5",
|
||||
"p-4 rounded-[10px]",
|
||||
"bg-bg border border-border-subtle",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,14 +73,13 @@ Card.Section = function CardSection({ children, className, ...props }) {
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-component: Hoverable row inside Card
|
||||
Card.Row = function CardRow({ children, className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 -mx-3 px-3 transition-colors",
|
||||
"border-b border-black/5 dark:border-white/5 last:border-b-0",
|
||||
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02]",
|
||||
"border-b border-border-subtle last:border-b-0",
|
||||
"hover:bg-surface-2/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,20 +89,18 @@ Card.Row = function CardRow({ children, className, ...props }) {
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-component: List item with hover actions (macOS style)
|
||||
Card.ListItem = function CardListItem({
|
||||
children,
|
||||
Card.ListItem = function CardListItem({
|
||||
children,
|
||||
actions,
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center justify-between p-3 -mx-3 px-3",
|
||||
"border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0",
|
||||
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02]",
|
||||
"transition-colors",
|
||||
"border-b border-border-subtle last:border-b-0",
|
||||
"hover:bg-surface-2/50 transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -119,4 +114,3 @@ Card.ListItem = function CardListItem({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = "md",
|
||||
className
|
||||
className
|
||||
}) {
|
||||
const widths = {
|
||||
sm: "w-[400px]",
|
||||
@@ -19,24 +19,18 @@ export default function Drawer({
|
||||
full: "w-full",
|
||||
};
|
||||
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Escape" && isOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
@@ -47,40 +41,39 @@ export default function Drawer({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity cursor-pointer"
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 h-full bg-surface shadow-2xl flex flex-col",
|
||||
"animate-in slide-in-from-right duration-200",
|
||||
"border-l border-black/10 dark:border-white/10",
|
||||
"absolute right-0 top-0 h-full bg-surface flex flex-col",
|
||||
"shadow-[var(--shadow-elev)]",
|
||||
"slide-in-right",
|
||||
"border-l border-border-subtle",
|
||||
widths[width] || widths.md,
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5 flex-shrink-0">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border-subtle flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{title && (
|
||||
<h2 className="text-lg font-semibold text-text-main">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import PropTypes from "prop-types";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import HeaderMenu from "@/shared/components/HeaderMenu";
|
||||
import ThemeToggle from "@/shared/components/ThemeToggle";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
@@ -181,9 +182,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-8 py-5 border-b border-black/5 dark:border-white/5 bg-bg/80 backdrop-blur-xl z-10 sticky top-0">
|
||||
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center gap-3 lg:hidden">
|
||||
<div className="flex items-center gap-3 lg:hidden shrink-0">
|
||||
{showMenuButton && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
@@ -194,8 +195,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Page title with breadcrumbs - desktop */}
|
||||
<div className="hidden lg:flex flex-col">
|
||||
{/* Page title with breadcrumbs */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{breadcrumbs.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
@@ -226,7 +227,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
|
||||
<h1 className="text-base lg:text-2xl font-semibold text-text-main tracking-tight truncate">
|
||||
{translate(crumb.label)}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -238,16 +239,16 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<span className="material-symbols-outlined text-primary text-2xl">
|
||||
<span className="material-symbols-outlined text-primary text-xl lg:text-2xl">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<h1 className="text-base lg:text-2xl font-semibold tracking-tight truncate">
|
||||
{translate(title)}
|
||||
</h1>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-text-muted">
|
||||
<p className="hidden lg:block text-sm text-text-muted truncate">
|
||||
{translate(description)}
|
||||
</p>
|
||||
)}
|
||||
@@ -255,8 +256,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right actions - consolidated into dropdown menu */}
|
||||
<div className="flex items-center ml-auto">
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<ThemeToggle />
|
||||
<HeaderMenu onLogout={handleLogout} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -38,17 +38,14 @@ export default function Input({
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full py-2 px-3 text-sm text-text-main",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md",
|
||||
"placeholder-text-muted/60",
|
||||
"focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none",
|
||||
"transition-all shadow-inner disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"w-full py-2.5 px-3 text-sm text-text-main bg-surface-2 rounded-[10px]",
|
||||
"border border-transparent placeholder-text-muted/70",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/40",
|
||||
"transition-all duration-150 ease-out disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
// iOS zoom fix
|
||||
"text-[16px] sm:text-sm",
|
||||
icon && "pl-10",
|
||||
error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "",
|
||||
error && "ring-1 ring-red-500 focus:ring-2 focus:ring-red-500/40 border-red-500/40",
|
||||
inputClassName
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,4 +63,3 @@ export default function Input({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Spinner({ size = "md", className }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined animate-spin text-primary",
|
||||
"material-symbols-outlined animate-spin text-brand-500",
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ export function Skeleton({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-lg bg-border",
|
||||
"animate-pulse rounded-[10px] bg-surface-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,10 +50,10 @@ export function Skeleton({ className, ...props }) {
|
||||
// Card skeleton
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="p-6 rounded-xl border border-border bg-surface">
|
||||
<div className="p-6 rounded-[14px] border border-border-subtle bg-surface shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<Skeleton className="size-10 rounded-[10px]" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 mb-2" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
@@ -61,7 +61,6 @@ export function CardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default function Loading({ type = "spinner", ...props }) {
|
||||
switch (type) {
|
||||
case "page":
|
||||
@@ -74,4 +73,3 @@ export default function Loading({ type = "spinner", ...props }) {
|
||||
return <Spinner {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function Modal({
|
||||
size = "md",
|
||||
closeOnOverlay = true,
|
||||
showCloseButton = true,
|
||||
showTrafficLights = true,
|
||||
className,
|
||||
}) {
|
||||
const sizes = {
|
||||
@@ -23,24 +24,18 @@ export default function Modal({
|
||||
full: "max-w-4xl",
|
||||
};
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Escape" && isOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
@@ -52,7 +47,7 @@ export default function Modal({
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in"
|
||||
onClick={closeOnOverlay ? onClose : undefined}
|
||||
/>
|
||||
|
||||
@@ -60,32 +55,32 @@ export default function Modal({
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full bg-surface",
|
||||
"border border-black/10 dark:border-white/10",
|
||||
"rounded-xl shadow-2xl",
|
||||
"animate-in fade-in zoom-in-95 duration-200",
|
||||
"border border-border-subtle",
|
||||
"rounded-[14px] shadow-[var(--shadow-elev)]",
|
||||
"fade-in",
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between p-2 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-between p-2 border-b border-border-subtle">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
||||
</div>
|
||||
{showTrafficLights && (
|
||||
<div className="flex items-center gap-2 mr-4 ml-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<h2 className="text-lg font-semibold text-text-main">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
@@ -94,11 +89,11 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto">{children}</div>
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto custom-scrollbar">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-border-subtle">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
@@ -107,7 +102,6 @@ export default function Modal({
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm Modal helper
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -140,4 +134,3 @@ export function ConfirmModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,20 +30,20 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200 flex flex-col bg-surface border border-black/10 dark:border-white/10">
|
||||
<div className="relative w-full max-w-sm rounded-[14px] overflow-hidden shadow-[var(--shadow-elev)] fade-in flex flex-col bg-surface border border-border-subtle">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#FF570A" }}>
|
||||
<div className="w-7 h-7 rounded-[8px] flex items-center justify-center bg-primary">
|
||||
<span className="material-symbols-outlined text-white text-base">terminal</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider" style={{ fontFamily: "monospace", color: "#FF570A" }}>9Remote</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary font-mono">9Remote</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 text-text-muted hover:text-text-main transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
@@ -53,11 +53,8 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
<div className="px-7 py-7 pb-9 flex flex-col gap-6">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center gap-2 text-center mt-2">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mb-1"
|
||||
style={{ background: "#FF570A", boxShadow: "rgba(255,87,10,0.35) 0px 8px 32px" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 30 }}>terminal</span>
|
||||
<div className="w-14 h-14 rounded-[14px] flex items-center justify-center mb-1 bg-primary shadow-[var(--shadow-warm)]">
|
||||
<span className="material-symbols-outlined text-white text-[30px]">terminal</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-text-main tracking-tight">9Remote</h1>
|
||||
<p className="text-xs text-text-muted leading-5 max-w-[220px]">
|
||||
@@ -68,8 +65,8 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
{/* Feature cards */}
|
||||
<div className="flex gap-2 w-full">
|
||||
{FEATURES.map(({ icon, label, desc }) => (
|
||||
<div key={label} className="flex-1 flex flex-col items-center gap-1.5 py-4 px-1 rounded-xl border border-black/10 dark:border-white/10 bg-bg-alt">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22, color: "#ff6e33" }}>{icon}</span>
|
||||
<div key={label} className="flex-1 flex flex-col items-center gap-1.5 py-4 px-1 rounded-[10px] border border-border-subtle bg-surface-2">
|
||||
<span className="material-symbols-outlined text-primary text-[22px]">{icon}</span>
|
||||
<p className="text-xs font-semibold text-text-main">{label}</p>
|
||||
<p className="text-[10px] text-text-muted text-center leading-4">{desc}</p>
|
||||
</div>
|
||||
@@ -80,7 +77,7 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{BULLETS.map(({ icon, text }) => (
|
||||
<div key={icon} className="flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 16, color: "#ff6e33" }}>{icon}</span>
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-primary text-[16px]">{icon}</span>
|
||||
<span className="text-xs text-text-muted">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -89,8 +86,7 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => window.open(NINE_REMOTE_URL, "_blank")}
|
||||
className="w-full py-3.5 flex items-center justify-center gap-2 text-sm font-semibold text-white rounded-xl hover:opacity-90 active:scale-[0.98] transition-all"
|
||||
style={{ background: "#FF570A", boxShadow: "0 4px 16px rgba(255,87,10,0.35)" }}
|
||||
className="w-full py-3 flex items-center justify-center gap-2 text-sm font-semibold text-white rounded-[10px] bg-primary hover:bg-primary-hover shadow-[var(--shadow-warm)] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">open_in_new</span>
|
||||
Get 9Remote
|
||||
|
||||
@@ -152,6 +152,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
setDeviceData(data);
|
||||
|
||||
// Auto-open verification URL in new tab
|
||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
const extraData = provider === "kiro"
|
||||
? {
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function SegmentedControl({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center p-1 rounded-lg overflow-x-auto",
|
||||
"bg-black/5 dark:bg-white/5",
|
||||
"inline-flex items-center p-1 rounded-[10px] overflow-x-auto",
|
||||
"bg-surface-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -28,10 +28,10 @@ export default function SegmentedControl({
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"shrink-0 px-4 rounded-md font-medium transition-all",
|
||||
"shrink-0 px-4 rounded-[8px] font-medium transition-all",
|
||||
sizes[size],
|
||||
value === option.value
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
? "bg-surface text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,14 +30,12 @@ export default function Select({
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full py-2 px-3 pr-10 text-sm text-text-main",
|
||||
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md appearance-none",
|
||||
"focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none",
|
||||
"transition-all disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"w-full py-2.5 px-3 pr-10 text-sm text-text-main",
|
||||
"bg-surface-2 border border-transparent rounded-[10px] appearance-none",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/40",
|
||||
"transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"text-[16px] sm:text-sm",
|
||||
error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "",
|
||||
error && "ring-1 ring-red-500 focus:ring-2 focus:ring-red-500/40 border-red-500/40",
|
||||
selectClassName
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,4 +65,3 @@ export default function Select({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function Sidebar({ onClose }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="flex w-72 flex-col border-r border-black/5 dark:border-white/5 bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
||||
<aside className="flex w-72 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
||||
{/* Traffic lights */}
|
||||
<div className="flex items-center gap-2 px-6 pt-5 pb-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
@@ -134,7 +134,7 @@ export default function Sidebar({ onClose }) {
|
||||
{/* Logo */}
|
||||
<div className="px-6 py-4 flex flex-col gap-2">
|
||||
<Link href="/dashboard" className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#f97815] to-[#c2590a]">
|
||||
<div className="flex items-center justify-center size-9 rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 shadow-[var(--shadow-warm)]">
|
||||
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -178,10 +178,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -192,12 +192,12 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* System section */}
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="pt-4 mt-2 space-y-1">
|
||||
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
|
||||
System
|
||||
</p>
|
||||
@@ -206,14 +206,14 @@ export default function Sidebar({ onClose }) {
|
||||
<button
|
||||
onClick={() => setMediaOpen((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith("/dashboard/media-providers")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">perm_media</span>
|
||||
<span className="text-sm font-medium flex-1 text-left">Media Providers</span>
|
||||
<span className="text-[13px] font-medium flex-1 text-left">Media Providers</span>
|
||||
<span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}>
|
||||
expand_more
|
||||
</span>
|
||||
@@ -229,7 +229,7 @@ export default function Sidebar({ onClose }) {
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
|
||||
@@ -244,7 +244,7 @@ export default function Sidebar({ onClose }) {
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith(COMBINED_WEB_ITEM.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{COMBINED_WEB_ITEM.icon}</span>
|
||||
@@ -259,10 +259,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -273,7 +273,7 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -286,10 +286,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -300,7 +300,7 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
@@ -310,10 +310,10 @@ export default function Sidebar({ onClose }) {
|
||||
href="/dashboard/profile"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive("/dashboard/profile")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -324,13 +324,13 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
settings
|
||||
</span>
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
<span className="text-[13px] font-medium">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer section */}
|
||||
<div className="p-3 border-t border-black/5 dark:border-white/5">
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
{/* Shutdown button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -4,24 +4,20 @@ import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export default function ThemeToggle({ className, variant = "default" }) {
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const variants = {
|
||||
default: cn(
|
||||
"flex items-center justify-center size-10 rounded-full",
|
||||
"text-text-muted",
|
||||
"hover:bg-black/5",
|
||||
"hover:text-text-main",
|
||||
"transition-colors"
|
||||
"text-text-muted hover:text-text-main",
|
||||
"hover:bg-surface-2 transition-colors"
|
||||
),
|
||||
card: cn(
|
||||
"flex items-center justify-center size-11 rounded-full",
|
||||
"bg-surface/60",
|
||||
"hover:bg-surface",
|
||||
"bg-surface/60 hover:bg-surface",
|
||||
"border border-border",
|
||||
"backdrop-blur-md shadow-sm hover:shadow-md",
|
||||
"text-text-muted-light hover:text-primary",
|
||||
"hover:text-primary",
|
||||
"backdrop-blur-md shadow-sm hover:shadow-[var(--shadow-warm)]",
|
||||
"text-text-muted hover:text-brand-500",
|
||||
"transition-all group"
|
||||
),
|
||||
};
|
||||
@@ -44,4 +40,3 @@ export default function ThemeToggle({ className, variant = "default" }) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,27 +12,13 @@ export default function Toggle({
|
||||
className,
|
||||
}) {
|
||||
const sizes = {
|
||||
sm: {
|
||||
track: "w-8 h-4",
|
||||
thumb: "size-3",
|
||||
translate: "translate-x-4",
|
||||
},
|
||||
md: {
|
||||
track: "w-11 h-6",
|
||||
thumb: "size-5",
|
||||
translate: "translate-x-5",
|
||||
},
|
||||
lg: {
|
||||
track: "w-14 h-7",
|
||||
thumb: "size-6",
|
||||
translate: "translate-x-7",
|
||||
},
|
||||
sm: { track: "w-8 h-4", thumb: "size-3", translate: "translate-x-4" },
|
||||
md: { track: "w-11 h-6", thumb: "size-5", translate: "translate-x-5" },
|
||||
lg: { track: "w-14 h-7", thumb: "size-6", translate: "translate-x-7" },
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(!checked);
|
||||
}
|
||||
if (!disabled && onChange) onChange(!checked);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -52,10 +38,8 @@ export default function Toggle({
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 cursor-pointer rounded-full",
|
||||
"transition-colors duration-200 ease-in-out",
|
||||
"focus:outline-none focus:ring-1 focus:ring-primary/30",
|
||||
checked
|
||||
? "bg-primary"
|
||||
: "bg-black/10 dark:bg-white/20",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30",
|
||||
checked ? "bg-brand-500" : "bg-surface-3",
|
||||
sizes[size].track,
|
||||
disabled && "cursor-not-allowed"
|
||||
)}
|
||||
@@ -73,18 +57,13 @@ export default function Toggle({
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-main">{label}</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{description}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ function RecentRequests({ requests = [] }) {
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">No requests yet.</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full min-w-[420px] border-collapse text-xs">
|
||||
<table className="w-full min-w-[300px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-bg z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted w-2"></th>
|
||||
@@ -181,7 +181,7 @@ const PERIODS = [
|
||||
{ value: "60d", label: "60D" },
|
||||
];
|
||||
|
||||
export default function UsageStats() {
|
||||
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -194,7 +194,9 @@ export default function UsageStats() {
|
||||
const [tableView, setTableView] = useState("model");
|
||||
const [viewMode, setViewMode] = useState("costs");
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [period, setPeriod] = useState("7d");
|
||||
const [periodLocal, setPeriodLocal] = useState("7d");
|
||||
const period = periodProp ?? periodLocal;
|
||||
const setPeriod = setPeriodProp ?? setPeriodLocal;
|
||||
|
||||
// Fetch connected providers once, deduplicate by provider type
|
||||
// Always include noAuth free providers (e.g. opencode) regardless of connections
|
||||
@@ -398,24 +400,26 @@ export default function UsageStats() {
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* Period selector */}
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:self-end">
|
||||
<div className="grid flex-1 grid-cols-4 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
disabled={fetching}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:bg-bg-hover hover:text-text"}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Period selector (hidden when controlled by parent) */}
|
||||
{!hidePeriodSelector && (
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:self-end">
|
||||
<div className="grid flex-1 grid-cols-4 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
disabled={fetching}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:bg-bg-hover hover:text-text"}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{fetching && (
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span>
|
||||
)}
|
||||
</div>
|
||||
{fetching && (
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview cards */}
|
||||
{loading ? spinner : <OverviewCards stats={stats} />}
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function DashboardLayout({ children }) {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300">
|
||||
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate">
|
||||
{/* Faint grid background */}
|
||||
<div className="landing-grid absolute inset-0 pointer-events-none -z-10" aria-hidden="true" />
|
||||
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
|
||||
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-6 lg:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
|
||||
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
// Free Providers (kiro first, iflow last)
|
||||
export const FREE_PROVIDERS = {
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35", website: "https://kiro.dev", notice: { signupUrl: "https://kiro.dev" } },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode Free", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } },
|
||||
};
|
||||
|
||||
@@ -42,14 +42,14 @@ export const THINKING_CONFIG = {
|
||||
|
||||
// OAuth Providers
|
||||
export const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL", website: "https://cline.bot", notice: { signupUrl: "https://cline.bot" } },
|
||||
// opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,112 +1,93 @@
|
||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
||||
import { enableTunnel, isTunnelManuallyDisabled, isTunnelReconnecting } from "@/lib/tunnel/tunnelManager";
|
||||
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
||||
import os from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
||||
import {
|
||||
enableTunnel, enableTailscale,
|
||||
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
||||
getTunnelService, getTailscaleService,
|
||||
} from "@/lib/tunnel/tunnelManager";
|
||||
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
||||
import { isTailscaleRunning } from "@/lib/tunnel/tailscale";
|
||||
import { loadState } from "@/lib/tunnel/state";
|
||||
import { checkInternet, probeUrlAlive } from "@/lib/tunnel/networkProbe";
|
||||
import {
|
||||
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
|
||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
|
||||
} from "@/lib/tunnel/tunnelConfig";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||
|
||||
import os from "os";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context.
|
||||
// Must run before any MITM function is called.
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
(function bootstrapMitm() {
|
||||
// 1. Resolve server.js path from real ESM __filename (not bundled path)
|
||||
if (!process.env.MITM_SERVER_PATH) {
|
||||
try {
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const appSrc = dirname(dirname(thisFile)); // src/
|
||||
const appSrc = dirname(dirname(thisFile));
|
||||
const candidate = join(appSrc, "mitm", "server.js");
|
||||
if (existsSync(candidate)) {
|
||||
process.env.MITM_SERVER_PATH = candidate;
|
||||
}
|
||||
if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 2. Inject DB functions so manager.js (CJS) can save/load settings
|
||||
// without dynamic import issues inside webpack bundles
|
||||
try {
|
||||
initDbHooks(getSettings, updateSettings);
|
||||
} catch { /* ignore */ }
|
||||
try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
// Multiple modules register SIGINT/SIGTERM handlers legitimately
|
||||
process.setMaxListeners(20);
|
||||
|
||||
// Use global to survive Next.js hot reload — prevents duplicate intervals
|
||||
// Survive Next.js hot reload
|
||||
const g = global.__appSingleton ??= {
|
||||
signalHandlersRegistered: false,
|
||||
watchdogInterval: null,
|
||||
networkMonitorInterval: null,
|
||||
lastNetworkFingerprint: null,
|
||||
lastWatchdogTick: Date.now(),
|
||||
lastTunnelRestartAt: 0,
|
||||
tunnelRestartInProgress: false,
|
||||
mitmStartInProgress: false,
|
||||
};
|
||||
|
||||
const WATCHDOG_INTERVAL_MS = 60000;
|
||||
const NETWORK_CHECK_INTERVAL_MS = 5000;
|
||||
const NETWORK_RESTART_COOLDOWN_MS = 30000;
|
||||
|
||||
/**
|
||||
* Initialize app on startup
|
||||
* - Cleanup stale data
|
||||
* - Auto-reconnect tunnel if previously enabled
|
||||
* - Register shutdown handler to kill cloudflared
|
||||
* - Start watchdog to recover tunnel after sleep/wake
|
||||
*/
|
||||
export async function initializeApp() {
|
||||
try {
|
||||
await cleanupProviderConnections();
|
||||
|
||||
// Auto-reconnect tunnel if it was enabled before restart
|
||||
const settings = await getSettings();
|
||||
if (settings.tunnelEnabled && !isCloudflaredRunning()) {
|
||||
console.log("[InitApp] Tunnel was enabled, auto-reconnecting...");
|
||||
try {
|
||||
await enableTunnel();
|
||||
console.log("[InitApp] Tunnel reconnected");
|
||||
} catch (error) {
|
||||
console.log("[InitApp] Tunnel reconnect failed:", error.message);
|
||||
}
|
||||
|
||||
// Auto-resume tunnel
|
||||
if (settings.tunnelEnabled) {
|
||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Auto-resume tailscale
|
||||
if (settings.tailscaleEnabled) {
|
||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Kill cloudflared on process exit (register once only)
|
||||
if (!g.signalHandlersRegistered) {
|
||||
const cleanup = () => {
|
||||
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
||||
killCloudflared();
|
||||
process.exit();
|
||||
};
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
|
||||
g.signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
// Pre-download cloudflared binary in background
|
||||
ensureCloudflared().catch(() => {});
|
||||
|
||||
// Watchdog: recover tunnel after process crash
|
||||
startWatchdog();
|
||||
|
||||
// Network monitor: detect sleep/wake + network changes → restart tunnel
|
||||
startNetworkMonitor();
|
||||
|
||||
// Auto-start MITM if it was enabled before restart
|
||||
autoStartMitm();
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-start MITM if it was enabled before restart */
|
||||
async function autoStartMitm() {
|
||||
if (g.mitmStartInProgress) return;
|
||||
g.mitmStartInProgress = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (!settings.mitmEnabled) return;
|
||||
|
||||
const mitmStatus = await getMitmStatus();
|
||||
if (mitmStatus.running) return;
|
||||
|
||||
@@ -116,13 +97,18 @@ async function autoStartMitm() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Need an active API key
|
||||
const keys = await getApiKeys();
|
||||
const activeKey = keys.find(k => k.isActive !== false);
|
||||
|
||||
console.log("[InitApp] MITM was enabled, auto-starting...");
|
||||
await startMitm(activeKey?.key || "sk_9router", password);
|
||||
console.log("[InitApp] MITM auto-started");
|
||||
try {
|
||||
await restoreToolDNS(password);
|
||||
console.log("[InitApp] DNS restored from saved state");
|
||||
} catch (e) {
|
||||
console.log("[InitApp] DNS restore failed:", e.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[InitApp] MITM auto-start failed:", err.message);
|
||||
} finally {
|
||||
@@ -130,34 +116,72 @@ async function autoStartMitm() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodically check tunnel process health and reconnect if crashed */
|
||||
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
|
||||
|
||||
async function safeRestartTunnel(reason) {
|
||||
const svc = getTunnelService();
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
if (svc.cancelToken.cancelled) return;
|
||||
if (svc.spawnInProgress) return;
|
||||
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
||||
|
||||
// Alive check: process up + URL responds → skip
|
||||
if (isCloudflaredRunning()) {
|
||||
const state = loadState();
|
||||
const publicUrl = state?.shortId ? `https://r${state.shortId}.9router.com` : null;
|
||||
if (publicUrl && await probeUrlAlive(publicUrl)) return;
|
||||
}
|
||||
|
||||
if (!await checkInternet()) return;
|
||||
|
||||
console.log(`[Tunnel] safeRestart (${reason})`);
|
||||
try {
|
||||
await enableTunnel();
|
||||
svc.lastRestartAt = Date.now();
|
||||
console.log("[Tunnel] restart success");
|
||||
} catch (err) {
|
||||
console.log("[Tunnel] restart failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRestartTailscale(reason) {
|
||||
const svc = getTailscaleService();
|
||||
const settings = await getSettings();
|
||||
if (!settings.tailscaleEnabled) return;
|
||||
if (svc.cancelToken.cancelled) return;
|
||||
if (svc.spawnInProgress) return;
|
||||
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
||||
|
||||
if (isTailscaleRunning() && settings.tailscaleUrl) {
|
||||
if (await probeUrlAlive(settings.tailscaleUrl)) return;
|
||||
}
|
||||
|
||||
if (!await checkInternet()) return;
|
||||
|
||||
console.log(`[Tailscale] safeRestart (${reason})`);
|
||||
try {
|
||||
await enableTailscale();
|
||||
svc.lastRestartAt = Date.now();
|
||||
console.log("[Tailscale] restart success");
|
||||
} catch (err) {
|
||||
console.log("[Tailscale] restart failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Watchdog: 60s tick check both services ──────────────────────────────────
|
||||
|
||||
function startWatchdog() {
|
||||
if (g.watchdogInterval) return;
|
||||
g.watchdogInterval = setInterval(async () => {
|
||||
try {
|
||||
if (isTunnelManuallyDisabled()) return;
|
||||
if (isTunnelReconnecting()) return;
|
||||
if (g.tunnelRestartInProgress) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
if (isCloudflaredRunning()) return;
|
||||
console.log("[Watchdog] Tunnel process is down, attempting recovery...");
|
||||
g.tunnelRestartInProgress = true;
|
||||
try {
|
||||
await enableTunnel();
|
||||
console.log("[Watchdog] Tunnel recovered");
|
||||
} finally {
|
||||
g.tunnelRestartInProgress = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[Watchdog] Recovery failed:", err.message);
|
||||
}
|
||||
g.watchdogInterval = setInterval(() => {
|
||||
safeRestartTunnel("watchdog").catch(() => {});
|
||||
safeRestartTailscale("watchdog").catch(() => {});
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
|
||||
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
||||
}
|
||||
|
||||
/** Get network fingerprint from active interfaces (IPv4 only) */
|
||||
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
||||
|
||||
function getNetworkFingerprint() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const active = [];
|
||||
@@ -172,7 +196,6 @@ function getNetworkFingerprint() {
|
||||
return active.sort().join("|");
|
||||
}
|
||||
|
||||
/** Monitor network changes + sleep/wake → kill and reconnect tunnel */
|
||||
function startNetworkMonitor() {
|
||||
if (g.networkMonitorInterval) return;
|
||||
|
||||
@@ -181,10 +204,6 @@ function startNetworkMonitor() {
|
||||
|
||||
g.networkMonitorInterval = setInterval(async () => {
|
||||
try {
|
||||
if (isTunnelManuallyDisabled()) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - g.lastWatchdogTick;
|
||||
g.lastWatchdogTick = now;
|
||||
@@ -194,31 +213,17 @@ function startNetworkMonitor() {
|
||||
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
|
||||
|
||||
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
||||
|
||||
if (!networkChanged && !wasSleep) return;
|
||||
|
||||
// Skip if restart already in progress or restarted recently
|
||||
if (g.tunnelRestartInProgress) return;
|
||||
if (isTunnelReconnecting()) return;
|
||||
if (now - g.lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
|
||||
// Wait for DHCP/DNS to settle before probing
|
||||
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
||||
|
||||
const reason = wasSleep && networkChanged ? "sleep/wake + network change"
|
||||
: wasSleep ? "sleep/wake" : "network change";
|
||||
console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`);
|
||||
|
||||
g.tunnelRestartInProgress = true;
|
||||
g.lastTunnelRestartAt = now;
|
||||
try {
|
||||
killCloudflared();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await enableTunnel();
|
||||
console.log("[NetworkMonitor] Tunnel restarted");
|
||||
g.lastNetworkFingerprint = getNetworkFingerprint();
|
||||
} finally {
|
||||
g.tunnelRestartInProgress = false;
|
||||
}
|
||||
const reason = wasSleep && networkChanged ? "sleep+netchange"
|
||||
: wasSleep ? "sleep" : "netchange";
|
||||
safeRestartTunnel(reason).catch(() => {});
|
||||
safeRestartTailscale(reason).catch(() => {});
|
||||
} catch (err) {
|
||||
console.log("[NetworkMonitor] Tunnel restart failed:", err.message);
|
||||
console.log("[NetworkMonitor] error:", err.message);
|
||||
}
|
||||
}, NETWORK_CHECK_INTERVAL_MS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user