kcg-code 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grup Settings "Remote access": tunnel publik `https://<name>.<domain>`.
|
|
3
|
+
*
|
|
4
|
+
* Alur UI (device authorization): Get started -> tampil kode + tautan/QR ->
|
|
5
|
+
* pengguna login Google & menyetujui di halaman API tunnel (boleh
|
|
6
|
+
* dari HP) -> kcgcode tersambung dan tunnel langsung menyala. Status di-poll
|
|
7
|
+
* dari `/api/tunnel`; token & secret tidak pernah dikirim ke browser.
|
|
8
|
+
*
|
|
9
|
+
* Bahasa UI sengaja non-teknis (tanpa "tunnel", "secret", "frpc"): istilah
|
|
10
|
+
* teknis & aksi jarang dipakai disimpan di bagian "Advanced" yang tertutup.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
CheckIcon,
|
|
14
|
+
ChevronDownIcon,
|
|
15
|
+
CircleAlertIcon,
|
|
16
|
+
CopyIcon,
|
|
17
|
+
ExternalLinkIcon,
|
|
18
|
+
GlobeIcon,
|
|
19
|
+
LockIcon,
|
|
20
|
+
PowerIcon,
|
|
21
|
+
QrCodeIcon,
|
|
22
|
+
RefreshCwIcon,
|
|
23
|
+
Settings2Icon,
|
|
24
|
+
Share2Icon,
|
|
25
|
+
TerminalSquareIcon,
|
|
26
|
+
UnplugIcon,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
|
+
import { toast } from "sonner";
|
|
30
|
+
import { encode } from "uqr";
|
|
31
|
+
import { LhrRemoteAccess } from "@/components/settings/LhrRemoteAccess";
|
|
32
|
+
import { ActionRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
|
|
33
|
+
import {
|
|
34
|
+
AlertDialog,
|
|
35
|
+
AlertDialogAction,
|
|
36
|
+
AlertDialogCancel,
|
|
37
|
+
AlertDialogContent,
|
|
38
|
+
AlertDialogDescription,
|
|
39
|
+
AlertDialogFooter,
|
|
40
|
+
AlertDialogHeader,
|
|
41
|
+
AlertDialogTitle,
|
|
42
|
+
} from "@/components/ui/alert-dialog";
|
|
43
|
+
import { Button } from "@/components/ui/button";
|
|
44
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
45
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
46
|
+
import { useRouter } from "@/hooks/useRouter";
|
|
47
|
+
import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
|
|
48
|
+
import { settingsPath } from "@/lib/routes";
|
|
49
|
+
import { cn } from "@/lib/utils";
|
|
50
|
+
import type { TunnelPhase, TunnelStatus } from "@/server/services/tunnel";
|
|
51
|
+
|
|
52
|
+
const BUSY_PHASES: TunnelPhase[] = ["starting", "connecting", "reconnecting"];
|
|
53
|
+
|
|
54
|
+
const DESCRIPTION = "Open KCG Code from your phone or another computer, even when you're away.";
|
|
55
|
+
|
|
56
|
+
function errorText(e: unknown, fallback: string): string {
|
|
57
|
+
return e instanceof ApiError ? e.message : fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readTunnel(res: Response): Promise<TunnelStatus> {
|
|
61
|
+
return ((await res.json()) as { tunnel: TunnelStatus }).tunnel;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Penyedia remote access (sinkron dengan `TunnelProvider` di server). */
|
|
65
|
+
type Provider = "kcg" | "lhr";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pembungkus Remote access: pemilih penyedia (KCG Code bawaan / localhost.run)
|
|
69
|
+
* lalu panel penyedia terpilih. Hanya satu tunnel aktif — berpindah
|
|
70
|
+
* penyedia mematikan yang lain di server (`PUT /api/tunnel/provider`).
|
|
71
|
+
*/
|
|
72
|
+
/** Ringkasan status kedua penyedia untuk kartu pilihan (bukan detail panel). */
|
|
73
|
+
interface ProviderOverview {
|
|
74
|
+
provider: Provider;
|
|
75
|
+
/** URL publik penyedia yang sedang menyala, null bila tidak ada. */
|
|
76
|
+
liveUrl: string | null;
|
|
77
|
+
live: Provider | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readOverview(b: {
|
|
81
|
+
provider?: Provider;
|
|
82
|
+
tunnel?: TunnelStatus;
|
|
83
|
+
lhr?: { enabled: boolean; phase: string; url: string | null };
|
|
84
|
+
}): ProviderOverview {
|
|
85
|
+
const kcgOn = !!b.tunnel && b.tunnel.enabled && b.tunnel.phase !== "stopped";
|
|
86
|
+
const lhrOn = !!b.lhr && b.lhr.enabled && b.lhr.phase !== "stopped";
|
|
87
|
+
const live: Provider | null = kcgOn ? "kcg" : lhrOn ? "lhr" : null;
|
|
88
|
+
return {
|
|
89
|
+
provider: b.provider === "lhr" ? "lhr" : "kcg",
|
|
90
|
+
live,
|
|
91
|
+
liveUrl:
|
|
92
|
+
live === "kcg"
|
|
93
|
+
? (b.tunnel?.account?.url ?? null)
|
|
94
|
+
: live === "lhr"
|
|
95
|
+
? (b.lhr?.url ?? null)
|
|
96
|
+
: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function RemoteAccessGroup({ protectedApp }: { protectedApp: boolean }) {
|
|
101
|
+
const [provider, setProvider] = useState<Provider | null>(null);
|
|
102
|
+
const [live, setLive] = useState<{ provider: Provider | null; url: string | null }>({
|
|
103
|
+
provider: null,
|
|
104
|
+
url: null,
|
|
105
|
+
});
|
|
106
|
+
const [switching, setSwitching] = useState(false);
|
|
107
|
+
/** Pindah penyedia saat yang lain menyala -> minta konfirmasi dulu. */
|
|
108
|
+
const [pendingSwitch, setPendingSwitch] = useState<Provider | null>(null);
|
|
109
|
+
/** Konfirmasi mematikan localhost.run (dialog yang sama dengan penyedia bawaan). */
|
|
110
|
+
const [lhrStop, setLhrStop] = useState<{ run: () => Promise<void>; via: boolean } | null>(null);
|
|
111
|
+
const [lhrStopping, setLhrStopping] = useState(false);
|
|
112
|
+
|
|
113
|
+
const refresh = useCallback(async (): Promise<ProviderOverview | null> => {
|
|
114
|
+
try {
|
|
115
|
+
const o = readOverview(await (await apiFetch("/api/tunnel")).json());
|
|
116
|
+
setLive({ provider: o.live, url: o.liveUrl });
|
|
117
|
+
return o;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
void refresh().then((o) => {
|
|
126
|
+
if (!cancelled) setProvider((cur) => cur ?? o?.provider ?? "kcg");
|
|
127
|
+
});
|
|
128
|
+
// Status "On" di kartu ikut berubah saat tunnel dinyalakan/dimatikan di panel.
|
|
129
|
+
const id = setInterval(() => void refresh(), 5000);
|
|
130
|
+
return () => {
|
|
131
|
+
cancelled = true;
|
|
132
|
+
clearInterval(id);
|
|
133
|
+
};
|
|
134
|
+
}, [refresh]);
|
|
135
|
+
|
|
136
|
+
const applySwitch = async (next: Provider) => {
|
|
137
|
+
setSwitching(true);
|
|
138
|
+
try {
|
|
139
|
+
await apiFetch("/api/tunnel/provider", {
|
|
140
|
+
method: "PUT",
|
|
141
|
+
body: JSON.stringify({ provider: next }),
|
|
142
|
+
});
|
|
143
|
+
setProvider(next);
|
|
144
|
+
await refresh();
|
|
145
|
+
} catch (e) {
|
|
146
|
+
toast.error(errorText(e, "Couldn't switch the remote access option."));
|
|
147
|
+
} finally {
|
|
148
|
+
setSwitching(false);
|
|
149
|
+
setPendingSwitch(null);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const choose = async (next: Provider) => {
|
|
154
|
+
if (next === provider || switching) return;
|
|
155
|
+
// Status terbaru: jangan mematikan tunnel yang menyala tanpa bertanya.
|
|
156
|
+
const o = await refresh();
|
|
157
|
+
if (o?.live && o.live !== next) setPendingSwitch(next);
|
|
158
|
+
else void applySwitch(next);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex flex-col gap-4">
|
|
163
|
+
<ProviderPicker
|
|
164
|
+
value={provider}
|
|
165
|
+
live={live.provider}
|
|
166
|
+
disabled={switching}
|
|
167
|
+
onChange={(p) => void choose(p)}
|
|
168
|
+
/>
|
|
169
|
+
<SwitchDialog
|
|
170
|
+
to={pendingSwitch}
|
|
171
|
+
viaTunnel={live.url !== null && sameOrigin(live.url)}
|
|
172
|
+
busy={switching}
|
|
173
|
+
onCancel={() => setPendingSwitch(null)}
|
|
174
|
+
onConfirm={(p) => void applySwitch(p)}
|
|
175
|
+
/>
|
|
176
|
+
{provider === null ? (
|
|
177
|
+
<PrefsGroup>
|
|
178
|
+
<li className="flex min-h-14 items-center justify-center" role="status">
|
|
179
|
+
<Spinner className="size-5 text-muted-foreground" />
|
|
180
|
+
<span className="sr-only">Loading remote access…</span>
|
|
181
|
+
</li>
|
|
182
|
+
</PrefsGroup>
|
|
183
|
+
) : provider === "lhr" ? (
|
|
184
|
+
<LhrRemoteAccess
|
|
185
|
+
protectedApp={protectedApp}
|
|
186
|
+
LiveAddress={LiveAddress}
|
|
187
|
+
LogPanel={LogPanel}
|
|
188
|
+
onRequestStop={(run, via) => setLhrStop({ run, via })}
|
|
189
|
+
onUseRecommended={() => void choose("kcg")}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
<KcgRemoteAccess protectedApp={protectedApp} />
|
|
193
|
+
)}
|
|
194
|
+
<ConfirmDialog
|
|
195
|
+
kind={lhrStop ? "stop" : null}
|
|
196
|
+
viaTunnel={lhrStop?.via ?? false}
|
|
197
|
+
busy={lhrStopping}
|
|
198
|
+
onOpenChange={(open) => !open && setLhrStop(null)}
|
|
199
|
+
onConfirm={() => {
|
|
200
|
+
const job = lhrStop;
|
|
201
|
+
if (!job) return;
|
|
202
|
+
setLhrStopping(true);
|
|
203
|
+
void job.run().finally(() => {
|
|
204
|
+
setLhrStopping(false);
|
|
205
|
+
setLhrStop(null);
|
|
206
|
+
void refresh();
|
|
207
|
+
});
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface ProviderInfo {
|
|
215
|
+
id: Provider;
|
|
216
|
+
title: string;
|
|
217
|
+
/** Satu kalimat pembeda (gaya subjudul AdwActionRow). */
|
|
218
|
+
subtitle: string;
|
|
219
|
+
recommended?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const PROVIDERS: ProviderInfo[] = [
|
|
223
|
+
{
|
|
224
|
+
id: "kcg",
|
|
225
|
+
title: "KCG Code link",
|
|
226
|
+
subtitle: "Same address every time · sign in with Google once",
|
|
227
|
+
recommended: true,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "lhr",
|
|
231
|
+
title: "localhost.run",
|
|
232
|
+
subtitle: "No account needed · address changes every few hours",
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const PROVIDER_TITLE: Record<Provider, string> = { kcg: "KCG Code link", lhr: "localhost.run" };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Pilihan penyedia sebagai kartu radio (dua kolom di layar lebar).
|
|
240
|
+
* KCG Code link ditandai "Recommended"; penyedia yang sedang menyala
|
|
241
|
+
* diberi penanda "On" agar jelas mana yang aktif sebelum berpindah.
|
|
242
|
+
*/
|
|
243
|
+
function ProviderPicker({
|
|
244
|
+
value,
|
|
245
|
+
live,
|
|
246
|
+
disabled,
|
|
247
|
+
onChange,
|
|
248
|
+
}: {
|
|
249
|
+
value: Provider | null;
|
|
250
|
+
live: Provider | null;
|
|
251
|
+
disabled: boolean;
|
|
252
|
+
onChange: (p: Provider) => void;
|
|
253
|
+
}) {
|
|
254
|
+
return (
|
|
255
|
+
<section aria-labelledby="remote-provider" className="flex flex-col gap-2.5">
|
|
256
|
+
<div className="flex flex-col gap-0.5 px-1">
|
|
257
|
+
<h2 id="remote-provider" className="text-[15px] font-semibold tracking-tight">
|
|
258
|
+
Remote access
|
|
259
|
+
</h2>
|
|
260
|
+
<p className="text-[13px] text-muted-foreground">{DESCRIPTION}</p>
|
|
261
|
+
</div>
|
|
262
|
+
{/* Boxed list ala AdwPreferencesGroup: satu baris radio per penyedia
|
|
263
|
+
(AdwActionRow + GtkCheckButton grup). Radio native di dalam label:
|
|
264
|
+
panah atas/bawah & Spasi bawaan browser. */}
|
|
265
|
+
<fieldset
|
|
266
|
+
aria-labelledby="remote-provider"
|
|
267
|
+
disabled={disabled || value === null}
|
|
268
|
+
className="m-0 min-w-0 overflow-hidden rounded-xl border bg-card p-0 shadow-xs"
|
|
269
|
+
>
|
|
270
|
+
<ul className="flex flex-col divide-y">
|
|
271
|
+
{PROVIDERS.map((p) => {
|
|
272
|
+
const selected = value === p.id;
|
|
273
|
+
return (
|
|
274
|
+
<li key={p.id}>
|
|
275
|
+
<label
|
|
276
|
+
className={cn(
|
|
277
|
+
"flex min-h-14 cursor-pointer items-center gap-3 px-4 py-2.5 transition-colors",
|
|
278
|
+
"hover:bg-muted/60 active:bg-muted has-[:focus-visible]:bg-muted/60",
|
|
279
|
+
"has-[:disabled]:cursor-default has-[:disabled]:opacity-60 has-[:disabled]:hover:bg-transparent",
|
|
280
|
+
)}
|
|
281
|
+
>
|
|
282
|
+
<input
|
|
283
|
+
type="radio"
|
|
284
|
+
name="remote-provider"
|
|
285
|
+
value={p.id}
|
|
286
|
+
checked={selected}
|
|
287
|
+
onChange={() => onChange(p.id)}
|
|
288
|
+
className="peer sr-only"
|
|
289
|
+
/>
|
|
290
|
+
{/* Radio GTK4: lingkaran, terisi aksen + titik saat dipilih. */}
|
|
291
|
+
<span
|
|
292
|
+
aria-hidden
|
|
293
|
+
className={cn(
|
|
294
|
+
"flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors",
|
|
295
|
+
"peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/40",
|
|
296
|
+
selected ? "border-primary bg-primary" : "border-muted-foreground/45",
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
299
|
+
{selected && <span className="size-1.5 rounded-full bg-primary-foreground" />}
|
|
300
|
+
</span>
|
|
301
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
|
302
|
+
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
303
|
+
<span className="text-[15px] leading-5">{p.title}</span>
|
|
304
|
+
{p.recommended && (
|
|
305
|
+
<span className="rounded-full bg-primary/12 px-2 py-px text-[11.5px] font-medium leading-4 text-primary">
|
|
306
|
+
Recommended
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
</span>
|
|
310
|
+
<span className="text-[13px] leading-snug text-muted-foreground">
|
|
311
|
+
{p.subtitle}
|
|
312
|
+
</span>
|
|
313
|
+
</span>
|
|
314
|
+
{live === p.id && <RowBadge tone="success">On</RowBadge>}
|
|
315
|
+
</label>
|
|
316
|
+
</li>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
</ul>
|
|
320
|
+
</fieldset>
|
|
321
|
+
</section>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Konfirmasi pindah penyedia saat penyedia lain sedang menyala. */
|
|
326
|
+
function SwitchDialog({
|
|
327
|
+
to,
|
|
328
|
+
viaTunnel,
|
|
329
|
+
busy,
|
|
330
|
+
onCancel,
|
|
331
|
+
onConfirm,
|
|
332
|
+
}: {
|
|
333
|
+
to: Provider | null;
|
|
334
|
+
viaTunnel: boolean;
|
|
335
|
+
busy: boolean;
|
|
336
|
+
onCancel: () => void;
|
|
337
|
+
onConfirm: (p: Provider) => void;
|
|
338
|
+
}) {
|
|
339
|
+
// Simpan tujuan terakhir agar teks tidak hilang saat animasi tutup.
|
|
340
|
+
const [last, setLast] = useState<Provider>("kcg");
|
|
341
|
+
if (to && to !== last) setLast(to);
|
|
342
|
+
const target = to ?? last;
|
|
343
|
+
const from: Provider = target === "kcg" ? "lhr" : "kcg";
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<AlertDialog open={to !== null} onOpenChange={(o) => !o && !busy && onCancel()}>
|
|
347
|
+
<AlertDialogContent>
|
|
348
|
+
<AlertDialogHeader>
|
|
349
|
+
<AlertDialogTitle>Switch to {PROVIDER_TITLE[target]}?</AlertDialogTitle>
|
|
350
|
+
<AlertDialogDescription>
|
|
351
|
+
Only one link can be on at a time, so your {PROVIDER_TITLE[from]} link will turn off and
|
|
352
|
+
stop working. You can turn on {PROVIDER_TITLE[target]} right after.
|
|
353
|
+
{viaTunnel && (
|
|
354
|
+
<>
|
|
355
|
+
{" "}
|
|
356
|
+
<strong className="font-medium text-foreground">
|
|
357
|
+
You're using this link right now, so this page will close its connection.
|
|
358
|
+
</strong>
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
361
|
+
</AlertDialogDescription>
|
|
362
|
+
</AlertDialogHeader>
|
|
363
|
+
<AlertDialogFooter>
|
|
364
|
+
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
|
365
|
+
<AlertDialogAction
|
|
366
|
+
disabled={busy}
|
|
367
|
+
onClick={(e) => {
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
onConfirm(target);
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{busy && <Spinner data-icon="inline-start" />}
|
|
373
|
+
Switch
|
|
374
|
+
</AlertDialogAction>
|
|
375
|
+
</AlertDialogFooter>
|
|
376
|
+
</AlertDialogContent>
|
|
377
|
+
</AlertDialog>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
|
|
382
|
+
const [t, setT] = useState<TunnelStatus | null>(null);
|
|
383
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
384
|
+
const [confirm, setConfirm] = useState<ConfirmKind | null>(null);
|
|
385
|
+
|
|
386
|
+
const load = useCallback(async () => {
|
|
387
|
+
try {
|
|
388
|
+
setT(await readTunnel(await apiFetch("/api/tunnel")));
|
|
389
|
+
} catch {
|
|
390
|
+
/* dilaporkan saat aksi; poll berikutnya mencoba lagi */
|
|
391
|
+
}
|
|
392
|
+
}, []);
|
|
393
|
+
|
|
394
|
+
// Poll lebih rapat saat pairing / tunnel sedang berjalan.
|
|
395
|
+
const active = t !== null && (t.pairing !== null || BUSY_PHASES.includes(t.phase));
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
void load();
|
|
398
|
+
const id = setInterval(() => void load(), active ? 1500 : 5000);
|
|
399
|
+
return () => clearInterval(id);
|
|
400
|
+
}, [load, active]);
|
|
401
|
+
|
|
402
|
+
const act = async (key: string, path: string, okText?: string) => {
|
|
403
|
+
setBusy(key);
|
|
404
|
+
try {
|
|
405
|
+
setT(await readTunnel(await apiFetch(path, { method: "POST", body: "{}" })));
|
|
406
|
+
if (okText) toast.success(okText);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
toast.error(errorText(e, "Something went wrong."));
|
|
409
|
+
void load();
|
|
410
|
+
} finally {
|
|
411
|
+
setBusy(null);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
if (!t) {
|
|
416
|
+
return (
|
|
417
|
+
<PrefsGroup>
|
|
418
|
+
<li className="flex min-h-14 items-center justify-center" role="status">
|
|
419
|
+
<Spinner className="size-5 text-muted-foreground" />
|
|
420
|
+
<span className="sr-only">Loading remote access…</span>
|
|
421
|
+
</li>
|
|
422
|
+
</PrefsGroup>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- API tunnel belum diatur (KCG_TUNNEL_API_URL / build) ----
|
|
427
|
+
if (!t.configured) {
|
|
428
|
+
return (
|
|
429
|
+
<PrefsGroup id="remote">
|
|
430
|
+
<ActionRow
|
|
431
|
+
prefix={<StatusIcon tone="muted" />}
|
|
432
|
+
title="Not available in this version"
|
|
433
|
+
subtitle={
|
|
434
|
+
<>
|
|
435
|
+
Remote access hasn't been set up for this copy of KCG Code. If you manage it, set{" "}
|
|
436
|
+
<code className="font-mono text-[12px]">KCG_TUNNEL_API_URL</code> and restart.
|
|
437
|
+
</>
|
|
438
|
+
}
|
|
439
|
+
/>
|
|
440
|
+
</PrefsGroup>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- belum tersambung ----
|
|
445
|
+
if (!t.account) {
|
|
446
|
+
return (
|
|
447
|
+
<PrefsGroup id="remote">
|
|
448
|
+
{t.pairing ? (
|
|
449
|
+
<PairingRow
|
|
450
|
+
pairing={t.pairing}
|
|
451
|
+
busy={busy !== null}
|
|
452
|
+
onCancel={() => void act("cancel", "/api/tunnel/connect/cancel")}
|
|
453
|
+
/>
|
|
454
|
+
) : (
|
|
455
|
+
<SetupSteps
|
|
456
|
+
protectedApp={protectedApp}
|
|
457
|
+
error={t.error}
|
|
458
|
+
busy={busy}
|
|
459
|
+
onStart={() => void act("connect", "/api/tunnel/connect")}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
462
|
+
</PrefsGroup>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- tersambung ----
|
|
467
|
+
const running = t.enabled && t.phase !== "stopped";
|
|
468
|
+
// Dibuka lewat alamat tunnel? Mematikan / memutus tunnel akan memutus sesi ini.
|
|
469
|
+
const viaTunnel = sameOrigin(t.account.url);
|
|
470
|
+
|
|
471
|
+
const runConfirmed = async (key: ConfirmKind) => {
|
|
472
|
+
if (key === "stop") await act("stop", "/api/tunnel/stop", "Remote access turned off.");
|
|
473
|
+
if (key === "rotate") await act("rotate", "/api/tunnel/rotate-secret", "Security key reset.");
|
|
474
|
+
if (key === "signout")
|
|
475
|
+
await act("signout", "/api/tunnel/signout", "This computer was unlinked.");
|
|
476
|
+
setConfirm(null);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Selalu konfirmasi: mematikan dari HP tidak bisa dinyalakan lagi dari jauh.
|
|
480
|
+
const turnOff = () => setConfirm("stop");
|
|
481
|
+
const account = t.account;
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<>
|
|
485
|
+
<PrefsGroup id="remote">
|
|
486
|
+
{running && <StatusHeader t={t} protectedApp={protectedApp} busy={busy} onStop={turnOff} />}
|
|
487
|
+
<AddressPanel
|
|
488
|
+
t={{ ...t, account }}
|
|
489
|
+
running={running}
|
|
490
|
+
protectedApp={protectedApp}
|
|
491
|
+
busy={busy}
|
|
492
|
+
onStart={() => void act("start", "/api/tunnel/start")}
|
|
493
|
+
/>
|
|
494
|
+
<ActionRow
|
|
495
|
+
title="Linked Google account"
|
|
496
|
+
subtitle={<span className="break-all">{t.account.email}</span>}
|
|
497
|
+
/>
|
|
498
|
+
</PrefsGroup>
|
|
499
|
+
|
|
500
|
+
<AdvancedSection
|
|
501
|
+
busy={busy !== null}
|
|
502
|
+
onRotate={() => setConfirm("rotate")}
|
|
503
|
+
onUnlink={() => setConfirm("signout")}
|
|
504
|
+
/>
|
|
505
|
+
|
|
506
|
+
<ConfirmDialog
|
|
507
|
+
kind={confirm}
|
|
508
|
+
viaTunnel={viaTunnel}
|
|
509
|
+
busy={busy !== null}
|
|
510
|
+
onOpenChange={(open) => !open && setConfirm(null)}
|
|
511
|
+
onConfirm={(k) => void runConfirmed(k)}
|
|
512
|
+
/>
|
|
513
|
+
</>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** `true` bila halaman ini sedang dibuka lewat alamat tunnel. */
|
|
518
|
+
function sameOrigin(url: string): boolean {
|
|
519
|
+
try {
|
|
520
|
+
return new URL(url).origin === window.location.origin;
|
|
521
|
+
} catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ------------------------------------------------------------ status ----
|
|
527
|
+
|
|
528
|
+
type Tone = "success" | "busy" | "warning" | "muted";
|
|
529
|
+
|
|
530
|
+
function StatusIcon({ tone, className }: { tone: Tone; className?: string }) {
|
|
531
|
+
return (
|
|
532
|
+
<span
|
|
533
|
+
className={cn(
|
|
534
|
+
"flex size-9 shrink-0 items-center justify-center rounded-full",
|
|
535
|
+
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
536
|
+
tone === "busy" && "bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
|
537
|
+
tone === "warning" && "bg-amber-500/15 text-amber-800 dark:text-amber-300",
|
|
538
|
+
tone === "muted" && "bg-muted text-muted-foreground",
|
|
539
|
+
className,
|
|
540
|
+
)}
|
|
541
|
+
>
|
|
542
|
+
{tone === "busy" ? (
|
|
543
|
+
<Spinner className="size-5" />
|
|
544
|
+
) : tone === "warning" ? (
|
|
545
|
+
<CircleAlertIcon className="size-5" aria-hidden />
|
|
546
|
+
) : (
|
|
547
|
+
<GlobeIcon className="size-5" aria-hidden />
|
|
548
|
+
)}
|
|
549
|
+
</span>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Baris status di atas kartu, hanya saat remote access menyala (atau sedang
|
|
555
|
+
* menyala). Saat mati, status + tombol "Turn on" ada di `AddressPanel`.
|
|
556
|
+
*/
|
|
557
|
+
function StatusHeader({
|
|
558
|
+
t,
|
|
559
|
+
protectedApp,
|
|
560
|
+
busy,
|
|
561
|
+
onStop,
|
|
562
|
+
}: {
|
|
563
|
+
t: TunnelStatus;
|
|
564
|
+
protectedApp: boolean;
|
|
565
|
+
busy: string | null;
|
|
566
|
+
onStop: () => void;
|
|
567
|
+
}) {
|
|
568
|
+
let tone: Tone;
|
|
569
|
+
let title: string;
|
|
570
|
+
let body: string;
|
|
571
|
+
if (!protectedApp || t.phase === "error") {
|
|
572
|
+
tone = "warning";
|
|
573
|
+
title = "Couldn't connect";
|
|
574
|
+
body = !protectedApp
|
|
575
|
+
? "Set a PIN or password to keep using remote access."
|
|
576
|
+
: "Something went wrong. See below for details.";
|
|
577
|
+
} else if (t.phase === "online") {
|
|
578
|
+
tone = "success";
|
|
579
|
+
title = "Remote access is on";
|
|
580
|
+
body = "Anyone opening your link still needs your PIN or password.";
|
|
581
|
+
} else {
|
|
582
|
+
tone = "busy";
|
|
583
|
+
title = t.phase === "reconnecting" ? "Reconnecting…" : "Turning on…";
|
|
584
|
+
body = "This usually takes a few seconds.";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<ActionRow
|
|
589
|
+
prefix={<StatusIcon tone={tone} />}
|
|
590
|
+
title={<span className="font-medium">{title}</span>}
|
|
591
|
+
subtitle={<span role="status">{body}</span>}
|
|
592
|
+
suffix={
|
|
593
|
+
<Button
|
|
594
|
+
type="button"
|
|
595
|
+
variant="outline"
|
|
596
|
+
size="sm"
|
|
597
|
+
disabled={busy !== null}
|
|
598
|
+
onClick={onStop}
|
|
599
|
+
className="hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/15"
|
|
600
|
+
>
|
|
601
|
+
{busy === "stop" ? (
|
|
602
|
+
<Spinner data-icon="inline-start" />
|
|
603
|
+
) : (
|
|
604
|
+
<PowerIcon data-icon="inline-start" />
|
|
605
|
+
)}
|
|
606
|
+
Turn off
|
|
607
|
+
</Button>
|
|
608
|
+
}
|
|
609
|
+
/>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// --------------------------------------------------------- not linked ----
|
|
614
|
+
|
|
615
|
+
function SetupSteps({
|
|
616
|
+
protectedApp,
|
|
617
|
+
error,
|
|
618
|
+
busy,
|
|
619
|
+
onStart,
|
|
620
|
+
}: {
|
|
621
|
+
protectedApp: boolean;
|
|
622
|
+
error: string | null;
|
|
623
|
+
busy: string | null;
|
|
624
|
+
onStart: () => void;
|
|
625
|
+
}) {
|
|
626
|
+
const { navigate } = useRouter();
|
|
627
|
+
return (
|
|
628
|
+
<li className="flex flex-col gap-4 px-4 py-4">
|
|
629
|
+
<div className="flex items-start gap-3">
|
|
630
|
+
<StatusIcon tone="muted" />
|
|
631
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
632
|
+
<p className="text-[15px] font-medium">Set up remote access</p>
|
|
633
|
+
<p className="text-[13px] text-muted-foreground">
|
|
634
|
+
It takes about a minute. You'll get your own link to open KCG Code from anywhere.
|
|
635
|
+
</p>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<ol className="flex flex-col gap-3 pl-1">
|
|
640
|
+
<Step
|
|
641
|
+
n={1}
|
|
642
|
+
done={protectedApp}
|
|
643
|
+
title="Lock KCG Code with a PIN or password"
|
|
644
|
+
subtitle={
|
|
645
|
+
protectedApp ? "Done." : "This keeps others out when your link is shared online."
|
|
646
|
+
}
|
|
647
|
+
action={
|
|
648
|
+
!protectedApp && (
|
|
649
|
+
<Button
|
|
650
|
+
type="button"
|
|
651
|
+
variant="outline"
|
|
652
|
+
size="sm"
|
|
653
|
+
onClick={() => navigate(settingsPath("security"))}
|
|
654
|
+
>
|
|
655
|
+
Set up lock
|
|
656
|
+
</Button>
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
/>
|
|
660
|
+
<Step
|
|
661
|
+
n={2}
|
|
662
|
+
title="Sign in with Google"
|
|
663
|
+
subtitle="You can do this on your phone. We only use it to give you a personal link."
|
|
664
|
+
action={
|
|
665
|
+
<Button
|
|
666
|
+
type="button"
|
|
667
|
+
size="sm"
|
|
668
|
+
disabled={busy !== null || !protectedApp}
|
|
669
|
+
onClick={onStart}
|
|
670
|
+
>
|
|
671
|
+
{busy === "connect" && <Spinner data-icon="inline-start" />}
|
|
672
|
+
Get started
|
|
673
|
+
</Button>
|
|
674
|
+
}
|
|
675
|
+
/>
|
|
676
|
+
</ol>
|
|
677
|
+
|
|
678
|
+
{error && (
|
|
679
|
+
<p role="alert" className="flex items-start gap-2 text-[13px] text-destructive">
|
|
680
|
+
<CircleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
681
|
+
{apiErrorMessage(error)}
|
|
682
|
+
</p>
|
|
683
|
+
)}
|
|
684
|
+
</li>
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function Step({
|
|
689
|
+
n,
|
|
690
|
+
done,
|
|
691
|
+
title,
|
|
692
|
+
subtitle,
|
|
693
|
+
action,
|
|
694
|
+
}: {
|
|
695
|
+
n: number;
|
|
696
|
+
done?: boolean;
|
|
697
|
+
title: string;
|
|
698
|
+
subtitle: string;
|
|
699
|
+
action?: ReactNode;
|
|
700
|
+
}) {
|
|
701
|
+
return (
|
|
702
|
+
<li className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
703
|
+
<span
|
|
704
|
+
className={cn(
|
|
705
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full text-[12px] font-semibold",
|
|
706
|
+
done
|
|
707
|
+
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
|
|
708
|
+
: "bg-muted text-muted-foreground",
|
|
709
|
+
)}
|
|
710
|
+
aria-hidden
|
|
711
|
+
>
|
|
712
|
+
{done ? <CheckIcon className="size-3.5" /> : n}
|
|
713
|
+
</span>
|
|
714
|
+
<div className="flex min-w-[min(100%,12rem)] flex-1 flex-col">
|
|
715
|
+
<span className={cn("text-[14px]", done && "text-muted-foreground line-through")}>
|
|
716
|
+
<span className="sr-only">
|
|
717
|
+
Step {n}
|
|
718
|
+
{done ? " (done)" : ""}:{" "}
|
|
719
|
+
</span>
|
|
720
|
+
{title}
|
|
721
|
+
</span>
|
|
722
|
+
<span className="text-[12.5px] text-muted-foreground">{subtitle}</span>
|
|
723
|
+
</div>
|
|
724
|
+
{action && <div className="ml-9 shrink-0 sm:ml-0">{action}</div>}
|
|
725
|
+
</li>
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Kode + tautan + QR selama menunggu persetujuan di VPS. */
|
|
730
|
+
function PairingRow({
|
|
731
|
+
pairing,
|
|
732
|
+
busy,
|
|
733
|
+
onCancel,
|
|
734
|
+
}: {
|
|
735
|
+
pairing: NonNullable<TunnelStatus["pairing"]>;
|
|
736
|
+
busy: boolean;
|
|
737
|
+
onCancel: () => void;
|
|
738
|
+
}) {
|
|
739
|
+
const [left, setLeft] = useState(() => Math.max(0, pairing.expiresAt - Date.now()));
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
const id = setInterval(() => setLeft(Math.max(0, pairing.expiresAt - Date.now())), 1000);
|
|
742
|
+
return () => clearInterval(id);
|
|
743
|
+
}, [pairing.expiresAt]);
|
|
744
|
+
const mm = Math.floor(left / 60_000);
|
|
745
|
+
const ss = String(Math.floor((left % 60_000) / 1000)).padStart(2, "0");
|
|
746
|
+
|
|
747
|
+
return (
|
|
748
|
+
<li className="flex flex-col items-center gap-5 px-4 py-5 text-center sm:flex-row sm:items-start sm:text-left">
|
|
749
|
+
<QrCode
|
|
750
|
+
value={pairing.verificationUriComplete}
|
|
751
|
+
label="QR code to open the sign-in page on your phone"
|
|
752
|
+
/>
|
|
753
|
+
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
754
|
+
<p className="text-[15px] font-medium">Sign in to finish</p>
|
|
755
|
+
<ol className="flex list-decimal flex-col gap-1.5 pl-5 text-left text-[13px] text-muted-foreground marker:text-foreground/70">
|
|
756
|
+
<li>
|
|
757
|
+
Scan the code with your phone camera, or{" "}
|
|
758
|
+
<a
|
|
759
|
+
href={pairing.verificationUriComplete}
|
|
760
|
+
target="_blank"
|
|
761
|
+
rel="noopener noreferrer"
|
|
762
|
+
className="font-medium text-foreground underline underline-offset-2"
|
|
763
|
+
>
|
|
764
|
+
open the sign-in page
|
|
765
|
+
</a>
|
|
766
|
+
.
|
|
767
|
+
</li>
|
|
768
|
+
<li>Sign in with your Google account.</li>
|
|
769
|
+
<li>Make sure the page shows the same code as below, then tap Approve.</li>
|
|
770
|
+
</ol>
|
|
771
|
+
<p className="rounded-lg bg-muted px-3 py-2 text-center font-mono text-2xl font-semibold tracking-[0.2em]">
|
|
772
|
+
<span className="sr-only">Code: </span>
|
|
773
|
+
{pairing.userCode}
|
|
774
|
+
</p>
|
|
775
|
+
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-between">
|
|
776
|
+
<span className="flex items-center gap-2 text-[12px] text-muted-foreground" role="status">
|
|
777
|
+
<Spinner className="size-3.5" />
|
|
778
|
+
Waiting for you to approve · {mm}:{ss} left
|
|
779
|
+
</span>
|
|
780
|
+
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={onCancel}>
|
|
781
|
+
Cancel
|
|
782
|
+
</Button>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
</li>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** QR sebagai SVG murni (tanpa innerHTML). */
|
|
790
|
+
function QrCode({ value, label, className }: { value: string; label: string; className?: string }) {
|
|
791
|
+
const { data, size } = useMemo(() => {
|
|
792
|
+
const qr = encode(value, { ecc: "M", border: 2 });
|
|
793
|
+
return { data: qr.data, size: qr.size };
|
|
794
|
+
}, [value]);
|
|
795
|
+
const path = useMemo(() => {
|
|
796
|
+
let d = "";
|
|
797
|
+
for (let y = 0; y < data.length; y++) {
|
|
798
|
+
const row = data[y] ?? [];
|
|
799
|
+
for (let x = 0; x < row.length; x++) if (row[x]) d += `M${x} ${y}h1v1h-1z`;
|
|
800
|
+
}
|
|
801
|
+
return d;
|
|
802
|
+
}, [data]);
|
|
803
|
+
return (
|
|
804
|
+
<svg
|
|
805
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
806
|
+
role="img"
|
|
807
|
+
aria-label={label}
|
|
808
|
+
shapeRendering="crispEdges"
|
|
809
|
+
className={cn("size-40 shrink-0 rounded-lg bg-white p-1", className)}
|
|
810
|
+
>
|
|
811
|
+
<path d={path} fill="#000" />
|
|
812
|
+
</svg>
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ------------------------------------------------------------ address ----
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Area QR + link. Hanya tampil utuh saat online; selain itu diganti
|
|
820
|
+
* placeholder seukuran QR berisi ikon, keterangan, dan aksi utama di tengah
|
|
821
|
+
* (Turn on / Try again / Set up lock) agar tata letak tidak "melompat".
|
|
822
|
+
*/
|
|
823
|
+
function AddressPanel({
|
|
824
|
+
t,
|
|
825
|
+
running,
|
|
826
|
+
protectedApp,
|
|
827
|
+
busy,
|
|
828
|
+
onStart,
|
|
829
|
+
}: {
|
|
830
|
+
t: TunnelStatus & { account: NonNullable<TunnelStatus["account"]> };
|
|
831
|
+
running: boolean;
|
|
832
|
+
protectedApp: boolean;
|
|
833
|
+
busy: string | null;
|
|
834
|
+
onStart: () => void;
|
|
835
|
+
}) {
|
|
836
|
+
const { navigate } = useRouter();
|
|
837
|
+
|
|
838
|
+
if (t.phase === "online" && protectedApp) return <LiveAddress url={t.account.url} />;
|
|
839
|
+
|
|
840
|
+
let icon: ReactNode;
|
|
841
|
+
let title: string;
|
|
842
|
+
let body: string;
|
|
843
|
+
let action: ReactNode = null;
|
|
844
|
+
if (!protectedApp) {
|
|
845
|
+
icon = <LockIcon className="size-9" aria-hidden />;
|
|
846
|
+
title = "Set a PIN or password first";
|
|
847
|
+
body = "For your safety, remote access only works when KCG Code is locked.";
|
|
848
|
+
action = (
|
|
849
|
+
<Button type="button" onClick={() => navigate(settingsPath("security"))}>
|
|
850
|
+
<LockIcon data-icon="inline-start" />
|
|
851
|
+
Set up lock
|
|
852
|
+
</Button>
|
|
853
|
+
);
|
|
854
|
+
} else if (running && t.phase === "error") {
|
|
855
|
+
icon = <CircleAlertIcon className="size-9 text-amber-600 dark:text-amber-400" aria-hidden />;
|
|
856
|
+
title = "Your link isn't working right now";
|
|
857
|
+
body = t.error ? apiErrorMessage(t.error) : "Check your internet connection and try again.";
|
|
858
|
+
action = (
|
|
859
|
+
<Button type="button" disabled={busy !== null} onClick={onStart}>
|
|
860
|
+
{busy === "start" ? (
|
|
861
|
+
<Spinner data-icon="inline-start" />
|
|
862
|
+
) : (
|
|
863
|
+
<RefreshCwIcon data-icon="inline-start" />
|
|
864
|
+
)}
|
|
865
|
+
Try again
|
|
866
|
+
</Button>
|
|
867
|
+
);
|
|
868
|
+
} else if (running) {
|
|
869
|
+
icon = <Spinner className="size-9" />;
|
|
870
|
+
title = "Getting your link ready…";
|
|
871
|
+
body = "Your QR code and link will appear here in a moment.";
|
|
872
|
+
} else {
|
|
873
|
+
icon = <QrCodeIcon className="size-9" aria-hidden />;
|
|
874
|
+
title = "Remote access is off";
|
|
875
|
+
body = "Turn it on to get a QR code and link for opening KCG Code on your phone.";
|
|
876
|
+
action = (
|
|
877
|
+
<Button type="button" disabled={busy !== null} onClick={onStart}>
|
|
878
|
+
{busy === "start" ? (
|
|
879
|
+
<Spinner data-icon="inline-start" />
|
|
880
|
+
) : (
|
|
881
|
+
<PowerIcon data-icon="inline-start" />
|
|
882
|
+
)}
|
|
883
|
+
Turn on
|
|
884
|
+
</Button>
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return (
|
|
889
|
+
<li className="flex flex-col items-center gap-4 px-4 py-6 text-center">
|
|
890
|
+
<div
|
|
891
|
+
className="flex size-40 items-center justify-center rounded-xl border-2 border-dashed bg-muted/30 text-muted-foreground"
|
|
892
|
+
aria-hidden
|
|
893
|
+
>
|
|
894
|
+
{icon}
|
|
895
|
+
</div>
|
|
896
|
+
<div className="flex max-w-sm flex-col gap-1" role="status">
|
|
897
|
+
<p className="text-[15px] font-medium">{title}</p>
|
|
898
|
+
<p className="text-[13px] leading-snug text-muted-foreground">{body}</p>
|
|
899
|
+
</div>
|
|
900
|
+
{action}
|
|
901
|
+
</li>
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function LiveAddress({ url }: { url: string }) {
|
|
906
|
+
const [copied, setCopied] = useState(false);
|
|
907
|
+
const [showQr, setShowQr] = useState(false);
|
|
908
|
+
const copy = async () => {
|
|
909
|
+
try {
|
|
910
|
+
await navigator.clipboard.writeText(url);
|
|
911
|
+
setCopied(true);
|
|
912
|
+
toast.success("Link copied.");
|
|
913
|
+
setTimeout(() => setCopied(false), 1500);
|
|
914
|
+
} catch {
|
|
915
|
+
// Clipboard API butuh konteks aman (HTTPS / localhost).
|
|
916
|
+
toast.error("Couldn't copy. Select the link and copy it manually.");
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Web Share API (umumnya HP): kirim link lewat WhatsApp / email / dll.
|
|
921
|
+
const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
|
|
922
|
+
const share = async () => {
|
|
923
|
+
try {
|
|
924
|
+
await navigator.share({ title: "KCG Code", url });
|
|
925
|
+
} catch {
|
|
926
|
+
/* dibatalkan pengguna */
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
return (
|
|
931
|
+
<li className="flex flex-col gap-4 px-4 py-4">
|
|
932
|
+
{/* Desktop: QR selalu tampil (cara tercepat pindah ke HP).
|
|
933
|
+
Mobile: disembunyikan (pengguna sudah di HP) kecuali diminta. */}
|
|
934
|
+
<div className={cn("justify-center sm:flex", showQr ? "flex" : "hidden")}>
|
|
935
|
+
<QrCode value={url} label="QR code of your remote access link" className="size-40" />
|
|
936
|
+
</div>
|
|
937
|
+
|
|
938
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
939
|
+
<span className="text-[13px] font-medium">Your link</span>
|
|
940
|
+
{/* Link + tombol salin menyatu, seperti kolom input read-only. */}
|
|
941
|
+
<div className="flex items-center gap-1 rounded-lg border bg-muted/40 py-1 pr-1 pl-3">
|
|
942
|
+
<a
|
|
943
|
+
href={url}
|
|
944
|
+
target="_blank"
|
|
945
|
+
rel="noopener noreferrer"
|
|
946
|
+
className="min-w-0 flex-1 truncate py-1 font-mono text-[13.5px] hover:underline"
|
|
947
|
+
title={url}
|
|
948
|
+
>
|
|
949
|
+
{url.replace(/^https?:\/\//, "")}
|
|
950
|
+
</a>
|
|
951
|
+
<Button
|
|
952
|
+
type="button"
|
|
953
|
+
variant="ghost"
|
|
954
|
+
size="icon-sm"
|
|
955
|
+
aria-label={copied ? "Copied" : "Copy link"}
|
|
956
|
+
onClick={() => void copy()}
|
|
957
|
+
>
|
|
958
|
+
{copied ? (
|
|
959
|
+
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
|
960
|
+
) : (
|
|
961
|
+
<CopyIcon />
|
|
962
|
+
)}
|
|
963
|
+
</Button>
|
|
964
|
+
<Button asChild variant="ghost" size="icon-sm">
|
|
965
|
+
<a
|
|
966
|
+
href={url}
|
|
967
|
+
target="_blank"
|
|
968
|
+
rel="noopener noreferrer"
|
|
969
|
+
aria-label="Open link in new tab"
|
|
970
|
+
>
|
|
971
|
+
<ExternalLinkIcon />
|
|
972
|
+
</a>
|
|
973
|
+
</Button>
|
|
974
|
+
</div>
|
|
975
|
+
<div className="flex flex-wrap gap-2 sm:hidden">
|
|
976
|
+
{canShare && (
|
|
977
|
+
<Button type="button" size="sm" onClick={() => void share()}>
|
|
978
|
+
<Share2Icon data-icon="inline-start" />
|
|
979
|
+
Share link
|
|
980
|
+
</Button>
|
|
981
|
+
)}
|
|
982
|
+
<Button
|
|
983
|
+
type="button"
|
|
984
|
+
variant="outline"
|
|
985
|
+
size="sm"
|
|
986
|
+
aria-expanded={showQr}
|
|
987
|
+
onClick={() => setShowQr((v) => !v)}
|
|
988
|
+
>
|
|
989
|
+
<QrCodeIcon data-icon="inline-start" />
|
|
990
|
+
{showQr ? "Hide QR code" : "Show QR code"}
|
|
991
|
+
</Button>
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
</li>
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ----------------------------------------------------------- advanced ----
|
|
999
|
+
|
|
1000
|
+
function AdvancedSection({
|
|
1001
|
+
busy,
|
|
1002
|
+
onRotate,
|
|
1003
|
+
onUnlink,
|
|
1004
|
+
}: {
|
|
1005
|
+
busy: boolean;
|
|
1006
|
+
onRotate: () => void;
|
|
1007
|
+
onUnlink: () => void;
|
|
1008
|
+
}) {
|
|
1009
|
+
const [open, setOpen] = useState(false);
|
|
1010
|
+
const [showLog, setShowLog] = useState(false);
|
|
1011
|
+
|
|
1012
|
+
return (
|
|
1013
|
+
// Expander row ala AdwExpanderRow: header di dalam kartu, isi terbuka di
|
|
1014
|
+
// kartu yang sama (bukan tautan teks kecil yang mudah terlewat).
|
|
1015
|
+
<Collapsible
|
|
1016
|
+
open={open}
|
|
1017
|
+
onOpenChange={setOpen}
|
|
1018
|
+
className="overflow-hidden rounded-xl border bg-card shadow-xs"
|
|
1019
|
+
>
|
|
1020
|
+
<CollapsibleTrigger asChild>
|
|
1021
|
+
<button
|
|
1022
|
+
type="button"
|
|
1023
|
+
// Saat terbuka header diberi latar agar terbaca sebagai judul menu.
|
|
1024
|
+
className="flex min-h-14 w-full items-center gap-3 px-4 py-2.5 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset data-[state=open]:bg-muted/50 data-[state=open]:hover:bg-muted/70"
|
|
1025
|
+
>
|
|
1026
|
+
<RowGlyph icon={<Settings2Icon className="size-[18px]" aria-hidden />} />
|
|
1027
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
1028
|
+
<span className="text-[15px] leading-5">Advanced options</span>
|
|
1029
|
+
<span className="text-[13px] leading-snug text-muted-foreground">
|
|
1030
|
+
Security key, connection log, and unlinking this computer
|
|
1031
|
+
</span>
|
|
1032
|
+
</span>
|
|
1033
|
+
<ChevronDownIcon
|
|
1034
|
+
className={cn(
|
|
1035
|
+
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
|
1036
|
+
open && "rotate-180",
|
|
1037
|
+
)}
|
|
1038
|
+
aria-hidden
|
|
1039
|
+
/>
|
|
1040
|
+
</button>
|
|
1041
|
+
</CollapsibleTrigger>
|
|
1042
|
+
<CollapsibleContent className="border-t">
|
|
1043
|
+
<ul className="flex flex-col divide-y">
|
|
1044
|
+
<ActionRow
|
|
1045
|
+
prefix={<RowGlyph icon={<RefreshCwIcon className="size-[18px]" aria-hidden />} />}
|
|
1046
|
+
title="Reset security key"
|
|
1047
|
+
subtitle="Only needed if you think someone else got access. Your link stays the same."
|
|
1048
|
+
disabled={busy}
|
|
1049
|
+
onActivate={onRotate}
|
|
1050
|
+
/>
|
|
1051
|
+
<ActionRow
|
|
1052
|
+
prefix={<RowGlyph icon={<TerminalSquareIcon className="size-[18px]" aria-hidden />} />}
|
|
1053
|
+
title="Connection log"
|
|
1054
|
+
subtitle="Technical details that can help when troubleshooting"
|
|
1055
|
+
suffix={
|
|
1056
|
+
<Button
|
|
1057
|
+
type="button"
|
|
1058
|
+
variant="ghost"
|
|
1059
|
+
size="sm"
|
|
1060
|
+
aria-expanded={showLog}
|
|
1061
|
+
onClick={() => setShowLog((v) => !v)}
|
|
1062
|
+
>
|
|
1063
|
+
{showLog ? "Hide" : "Show"}
|
|
1064
|
+
</Button>
|
|
1065
|
+
}
|
|
1066
|
+
/>
|
|
1067
|
+
{showLog && <LogPanel />}
|
|
1068
|
+
<ActionRow
|
|
1069
|
+
prefix={
|
|
1070
|
+
<RowGlyph destructive icon={<UnplugIcon className="size-[18px]" aria-hidden />} />
|
|
1071
|
+
}
|
|
1072
|
+
title="Unlink this computer"
|
|
1073
|
+
subtitle="Turns remote access off and removes this computer from your Google account"
|
|
1074
|
+
destructive
|
|
1075
|
+
disabled={busy}
|
|
1076
|
+
onActivate={onUnlink}
|
|
1077
|
+
/>
|
|
1078
|
+
</ul>
|
|
1079
|
+
</CollapsibleContent>
|
|
1080
|
+
</Collapsible>
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function RowGlyph({ icon, destructive }: { icon: ReactNode; destructive?: boolean }) {
|
|
1085
|
+
return (
|
|
1086
|
+
<span
|
|
1087
|
+
className={cn(
|
|
1088
|
+
"flex size-9 items-center justify-center rounded-full",
|
|
1089
|
+
destructive ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
|
|
1090
|
+
)}
|
|
1091
|
+
>
|
|
1092
|
+
{icon}
|
|
1093
|
+
</span>
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Log frpc live (polling bertahap `?from=`). */
|
|
1098
|
+
function LogPanel({ endpoint = "/api/tunnel/logs" }: { endpoint?: string }) {
|
|
1099
|
+
const [lines, setLines] = useState<string[]>([]);
|
|
1100
|
+
const next = useRef(0);
|
|
1101
|
+
const box = useRef<HTMLPreElement>(null);
|
|
1102
|
+
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
let stop = false;
|
|
1105
|
+
const tick = async () => {
|
|
1106
|
+
try {
|
|
1107
|
+
const res = await apiFetch(`${endpoint}?from=${next.current}`);
|
|
1108
|
+
const body = (await res.json()) as { lines: string[]; next: number };
|
|
1109
|
+
if (stop) return;
|
|
1110
|
+
next.current = body.next;
|
|
1111
|
+
if (body.lines.length > 0) setLines((prev) => [...prev, ...body.lines].slice(-300));
|
|
1112
|
+
} catch {
|
|
1113
|
+
/* coba lagi di tick berikutnya */
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
void tick();
|
|
1117
|
+
const id = setInterval(() => void tick(), 1500);
|
|
1118
|
+
return () => {
|
|
1119
|
+
stop = true;
|
|
1120
|
+
clearInterval(id);
|
|
1121
|
+
};
|
|
1122
|
+
}, [endpoint]);
|
|
1123
|
+
|
|
1124
|
+
// Ikuti baris terbaru.
|
|
1125
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll saat baris bertambah
|
|
1126
|
+
useEffect(() => {
|
|
1127
|
+
box.current?.scrollTo({ top: box.current.scrollHeight });
|
|
1128
|
+
}, [lines.length]);
|
|
1129
|
+
|
|
1130
|
+
return (
|
|
1131
|
+
<li className="px-4 py-3">
|
|
1132
|
+
<pre
|
|
1133
|
+
ref={box}
|
|
1134
|
+
role="log"
|
|
1135
|
+
aria-label="Connection log"
|
|
1136
|
+
className="max-h-56 overflow-auto rounded-md bg-muted/60 p-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap break-all"
|
|
1137
|
+
>
|
|
1138
|
+
{lines.length === 0 ? "No output yet." : lines.join("\n")}
|
|
1139
|
+
</pre>
|
|
1140
|
+
</li>
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ------------------------------------------------------------ confirm ----
|
|
1145
|
+
|
|
1146
|
+
type ConfirmKind = "stop" | "rotate" | "signout";
|
|
1147
|
+
|
|
1148
|
+
const CONFIRM_COPY: Record<
|
|
1149
|
+
ConfirmKind,
|
|
1150
|
+
{ title: string; body: string; action: string; destructive: boolean }
|
|
1151
|
+
> = {
|
|
1152
|
+
stop: {
|
|
1153
|
+
title: "Turn off remote access?",
|
|
1154
|
+
body: "Your link will stop working and any phone or computer using it will be disconnected. You can turn it back on from this computer anytime.",
|
|
1155
|
+
action: "Turn off",
|
|
1156
|
+
destructive: true,
|
|
1157
|
+
},
|
|
1158
|
+
rotate: {
|
|
1159
|
+
title: "Reset security key?",
|
|
1160
|
+
body: "Remote access restarts, so your link stops for a few seconds. Other computers linked to your account will need to sign in again.",
|
|
1161
|
+
action: "Reset",
|
|
1162
|
+
destructive: false,
|
|
1163
|
+
},
|
|
1164
|
+
signout: {
|
|
1165
|
+
title: "Unlink this computer?",
|
|
1166
|
+
body: "Remote access turns off and this computer is removed from your Google account. You can set it up again anytime.",
|
|
1167
|
+
action: "Unlink",
|
|
1168
|
+
destructive: true,
|
|
1169
|
+
},
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
/** Konfirmasi aksi yang sulit dibatalkan dari jarak jauh (mis. dari HP). */
|
|
1173
|
+
function ConfirmDialog({
|
|
1174
|
+
kind,
|
|
1175
|
+
viaTunnel,
|
|
1176
|
+
busy,
|
|
1177
|
+
onOpenChange,
|
|
1178
|
+
onConfirm,
|
|
1179
|
+
}: {
|
|
1180
|
+
kind: ConfirmKind | null;
|
|
1181
|
+
viaTunnel: boolean;
|
|
1182
|
+
busy: boolean;
|
|
1183
|
+
onOpenChange: (open: boolean) => void;
|
|
1184
|
+
onConfirm: (kind: ConfirmKind) => void;
|
|
1185
|
+
}) {
|
|
1186
|
+
// Simpan salinan terakhir agar teks tidak hilang saat animasi tutup.
|
|
1187
|
+
const [last, setLast] = useState<ConfirmKind>("stop");
|
|
1188
|
+
if (kind && kind !== last) setLast(kind);
|
|
1189
|
+
const copy = CONFIRM_COPY[kind ?? last];
|
|
1190
|
+
const cutsSession = viaTunnel && (kind ?? last) !== "rotate";
|
|
1191
|
+
|
|
1192
|
+
return (
|
|
1193
|
+
<AlertDialog open={kind !== null} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
|
1194
|
+
<AlertDialogContent>
|
|
1195
|
+
<AlertDialogHeader>
|
|
1196
|
+
<AlertDialogTitle>{copy.title}</AlertDialogTitle>
|
|
1197
|
+
<AlertDialogDescription>
|
|
1198
|
+
{copy.body}
|
|
1199
|
+
{cutsSession && (
|
|
1200
|
+
<>
|
|
1201
|
+
{" "}
|
|
1202
|
+
<strong className="font-medium text-foreground">
|
|
1203
|
+
You're using this link right now, so this page will close its connection.
|
|
1204
|
+
</strong>
|
|
1205
|
+
</>
|
|
1206
|
+
)}
|
|
1207
|
+
</AlertDialogDescription>
|
|
1208
|
+
</AlertDialogHeader>
|
|
1209
|
+
<AlertDialogFooter>
|
|
1210
|
+
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
|
1211
|
+
<AlertDialogAction
|
|
1212
|
+
variant={copy.destructive ? "destructive" : "default"}
|
|
1213
|
+
disabled={busy}
|
|
1214
|
+
onClick={(e) => {
|
|
1215
|
+
e.preventDefault();
|
|
1216
|
+
onConfirm(kind ?? last);
|
|
1217
|
+
}}
|
|
1218
|
+
>
|
|
1219
|
+
{busy && <Spinner data-icon="inline-start" />}
|
|
1220
|
+
{copy.action}
|
|
1221
|
+
</AlertDialogAction>
|
|
1222
|
+
</AlertDialogFooter>
|
|
1223
|
+
</AlertDialogContent>
|
|
1224
|
+
</AlertDialog>
|
|
1225
|
+
);
|
|
1226
|
+
}
|