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