gitspace 0.2.0-rc.1
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/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for terminal connection to relay with X3DH handshake and E2E encryption
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes after handshake:
|
|
5
|
+
* - "browsing": List workspaces and sessions
|
|
6
|
+
* - "attached": Connected to a PTY session
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
10
|
+
import {
|
|
11
|
+
createClientHello,
|
|
12
|
+
processServerHello,
|
|
13
|
+
createClientAuth,
|
|
14
|
+
processServerAuth,
|
|
15
|
+
isX3DHResponseMessage,
|
|
16
|
+
isX3DHResultMessage,
|
|
17
|
+
type X3DHClientState,
|
|
18
|
+
} from "../lib/crypto/handshake";
|
|
19
|
+
import { createFrame, openFrame, MASTER_STREAM_ID } from "../lib/crypto/frames";
|
|
20
|
+
import { signRelayMessage } from "../lib/crypto/relay-signing";
|
|
21
|
+
import type { Identity, SessionKeys } from "../types/identity";
|
|
22
|
+
import type { InboxItem } from "../../../lib/remote-session/protocol";
|
|
23
|
+
import { findUtf8Boundary } from "../../../utils/utf8";
|
|
24
|
+
|
|
25
|
+
/** Stream ID for control messages (resize, detach, etc.) */
|
|
26
|
+
const CONTROL_STREAM_ID = 1;
|
|
27
|
+
|
|
28
|
+
export type ConnectionStatus =
|
|
29
|
+
| "disconnected"
|
|
30
|
+
| "connecting"
|
|
31
|
+
| "connected"
|
|
32
|
+
| "handshaking"
|
|
33
|
+
| "established"
|
|
34
|
+
| "error";
|
|
35
|
+
|
|
36
|
+
/** Mode after handshake is established */
|
|
37
|
+
export type SessionMode = "browsing" | "attached";
|
|
38
|
+
|
|
39
|
+
/** Workspace information from machine */
|
|
40
|
+
export interface WorkspaceInfo {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
path: string;
|
|
44
|
+
projectName: string;
|
|
45
|
+
branch?: string;
|
|
46
|
+
sessionCount: number;
|
|
47
|
+
isStale?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Session information from machine */
|
|
51
|
+
export interface SessionInfo {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
workspaceId: string;
|
|
55
|
+
attached: boolean;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
processTitle?: string;
|
|
58
|
+
exitCode?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Project information from machine */
|
|
62
|
+
export interface ProjectInfo {
|
|
63
|
+
name: string;
|
|
64
|
+
repository: string;
|
|
65
|
+
workspaceCount: number;
|
|
66
|
+
isCurrent: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ConnectionParams {
|
|
70
|
+
ws: WebSocket; // Existing WebSocket from relay connection
|
|
71
|
+
identity: Identity; // Client identity
|
|
72
|
+
machineId: string;
|
|
73
|
+
inviteId?: string; // Short hash for relay lookup (connect_with_invite)
|
|
74
|
+
inviteToken?: string; // Full invite token for X3DH authorization
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useTerminal() {
|
|
78
|
+
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
|
|
79
|
+
const [mode, setMode] = useState<SessionMode>("browsing");
|
|
80
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
81
|
+
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
|
|
82
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
83
|
+
const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null);
|
|
84
|
+
const [attachedSessionName, setAttachedSessionName] = useState<string | null>(null);
|
|
85
|
+
const [selectedProjectName, setSelectedProjectName] = useState<string | null>(null);
|
|
86
|
+
const [inbox, setInbox] = useState<InboxItem[]>([]);
|
|
87
|
+
const [inboxUnreadCount, setInboxUnreadCount] = useState(0);
|
|
88
|
+
|
|
89
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
90
|
+
const identityRef = useRef<Identity | null>(null);
|
|
91
|
+
const sessionKeysRef = useRef<SessionKeys | null>(null);
|
|
92
|
+
const handshakeStateRef = useRef<X3DHClientState | null>(null);
|
|
93
|
+
const writeCallbackRef = useRef<((data: Uint8Array) => void) | null>(null);
|
|
94
|
+
const connectionParamsRef = useRef<ConnectionParams | null>(null);
|
|
95
|
+
const modeRef = useRef<SessionMode>("browsing"); // For use in callbacks
|
|
96
|
+
const utf8BufferRef = useRef<Uint8Array>(new Uint8Array(0)); // Buffer for incomplete UTF-8 sequences
|
|
97
|
+
const handleDataMessageRef = useRef<((data: string) => void) | null>(null); // For use in handleMessage
|
|
98
|
+
|
|
99
|
+
const connect = useCallback(async (params: ConnectionParams) => {
|
|
100
|
+
try {
|
|
101
|
+
setStatus("connecting");
|
|
102
|
+
connectionParamsRef.current = params;
|
|
103
|
+
|
|
104
|
+
// Use the passed WebSocket and identity (from relay connection)
|
|
105
|
+
const { ws, identity } = params;
|
|
106
|
+
wsRef.current = ws;
|
|
107
|
+
identityRef.current = identity;
|
|
108
|
+
|
|
109
|
+
// Take over message handling for this WebSocket
|
|
110
|
+
ws.onmessage = (event) => {
|
|
111
|
+
handleMessage(event.data);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onclose = () => {
|
|
115
|
+
setStatus("disconnected");
|
|
116
|
+
wsRef.current = null;
|
|
117
|
+
sessionKeysRef.current = null;
|
|
118
|
+
handshakeStateRef.current = null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
ws.onerror = () => {
|
|
122
|
+
setStatus("error");
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
setStatus("connected");
|
|
126
|
+
|
|
127
|
+
// Send connect request on existing WebSocket
|
|
128
|
+
const connectMsg = params.inviteId
|
|
129
|
+
? {
|
|
130
|
+
type: "connect_with_invite",
|
|
131
|
+
inviteId: params.inviteId,
|
|
132
|
+
clientIdentityId: identity.id,
|
|
133
|
+
}
|
|
134
|
+
: {
|
|
135
|
+
type: "connect_to_machine",
|
|
136
|
+
machineId: params.machineId,
|
|
137
|
+
clientIdentityId: identity.id,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const signed = signRelayMessage(connectMsg, identity);
|
|
141
|
+
ws.send(JSON.stringify(signed));
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error("Connection failed:", e);
|
|
144
|
+
setStatus("error");
|
|
145
|
+
}
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const handleMessage = useCallback((raw: string) => {
|
|
149
|
+
try {
|
|
150
|
+
const msg = JSON.parse(raw);
|
|
151
|
+
|
|
152
|
+
switch (msg.type) {
|
|
153
|
+
case "connection_established":
|
|
154
|
+
console.log("Connection established, starting X3DH handshake...");
|
|
155
|
+
setStatus("handshaking");
|
|
156
|
+
startHandshake();
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case "data":
|
|
160
|
+
// All data messages (handshake and encrypted frames) come through here
|
|
161
|
+
// handleDataMessage will parse and route appropriately
|
|
162
|
+
// Use ref to avoid stale closure (handleMessage has [] deps)
|
|
163
|
+
handleDataMessageRef.current?.(msg.data);
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case "error":
|
|
167
|
+
console.error("Relay error:", msg.message);
|
|
168
|
+
setStatus("error");
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case "pong":
|
|
172
|
+
// Keepalive response - connection is alive (from relay ping)
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
default:
|
|
176
|
+
console.log("Unknown message type:", msg.type);
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error("Failed to parse message:", e);
|
|
180
|
+
}
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const startHandshake = useCallback(() => {
|
|
184
|
+
if (!wsRef.current || !identityRef.current) return;
|
|
185
|
+
|
|
186
|
+
const machineId = connectionParamsRef.current?.machineId;
|
|
187
|
+
const { state, message } = createClientHello(machineId);
|
|
188
|
+
handshakeStateRef.current = state;
|
|
189
|
+
|
|
190
|
+
// Send ClientHello wrapped in handshake message
|
|
191
|
+
// Format must match HandshakeMessageEnvelope: { type, phase, data }
|
|
192
|
+
wsRef.current.send(JSON.stringify({
|
|
193
|
+
type: "handshake",
|
|
194
|
+
phase: "client_hello",
|
|
195
|
+
data: message,
|
|
196
|
+
}));
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
const handleHandshakeMessage = useCallback((msg: { phase: string; data: Record<string, unknown> }) => {
|
|
200
|
+
const ws = wsRef.current;
|
|
201
|
+
const identity = identityRef.current;
|
|
202
|
+
const state = handshakeStateRef.current;
|
|
203
|
+
|
|
204
|
+
if (!ws || !identity || !state) {
|
|
205
|
+
console.error("Missing handshake prerequisites");
|
|
206
|
+
setStatus("error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
switch (msg.phase) {
|
|
211
|
+
case "server_hello": {
|
|
212
|
+
if (!isX3DHResponseMessage(msg.data)) {
|
|
213
|
+
console.error("Invalid ServerHello message structure");
|
|
214
|
+
setStatus("error");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const response = msg.data;
|
|
218
|
+
const newState = processServerHello(state, response);
|
|
219
|
+
|
|
220
|
+
if (!newState) {
|
|
221
|
+
console.error("Failed to process ServerHello");
|
|
222
|
+
setStatus("error");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
handshakeStateRef.current = newState;
|
|
227
|
+
|
|
228
|
+
// Create ClientAuth
|
|
229
|
+
// Use inviteToken (full invite token) for X3DH authorization
|
|
230
|
+
// inviteId is only used for relay's connect_with_invite message
|
|
231
|
+
const inviteToken = connectionParamsRef.current?.inviteToken;
|
|
232
|
+
const authorization = inviteToken
|
|
233
|
+
? { type: "invite" as const, inviteToken }
|
|
234
|
+
: { type: "access_list" as const };
|
|
235
|
+
|
|
236
|
+
const { state: authState, message, sessionKeys } = createClientAuth(
|
|
237
|
+
newState,
|
|
238
|
+
identity,
|
|
239
|
+
authorization
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
handshakeStateRef.current = authState;
|
|
243
|
+
sessionKeysRef.current = sessionKeys;
|
|
244
|
+
|
|
245
|
+
ws.send(JSON.stringify({
|
|
246
|
+
type: "handshake",
|
|
247
|
+
phase: "client_auth",
|
|
248
|
+
data: message,
|
|
249
|
+
}));
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case "server_auth": {
|
|
254
|
+
if (!isX3DHResultMessage(msg.data)) {
|
|
255
|
+
console.error("Invalid ServerAuth message structure");
|
|
256
|
+
setStatus("error");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const response = msg.data;
|
|
260
|
+
const sessionKeys = sessionKeysRef.current;
|
|
261
|
+
|
|
262
|
+
console.log("ServerAuth response:", response);
|
|
263
|
+
console.log("Current state:", {
|
|
264
|
+
peerIdentityKey: state.peerIdentityKey ? "present" : "missing",
|
|
265
|
+
serverNonce: state.serverNonce ? "present" : "missing",
|
|
266
|
+
clientNonce: state.clientNonce ? "present" : "missing",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!sessionKeys) {
|
|
270
|
+
console.error("Missing session keys");
|
|
271
|
+
setStatus("error");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = processServerAuth(state, response, sessionKeys);
|
|
276
|
+
|
|
277
|
+
if (!result) {
|
|
278
|
+
console.error("Failed to process ServerAuth - response:", response);
|
|
279
|
+
if (response.result?.type === "rejected") {
|
|
280
|
+
console.error("Handshake rejected:", response.result);
|
|
281
|
+
}
|
|
282
|
+
setStatus("error");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log("X3DH handshake complete! Peer:", result.peerIdentityId);
|
|
287
|
+
setStatus("established");
|
|
288
|
+
setMode("browsing");
|
|
289
|
+
modeRef.current = "browsing";
|
|
290
|
+
|
|
291
|
+
// Request workspace list now that handshake is complete
|
|
292
|
+
requestWorkspaces();
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
default:
|
|
297
|
+
console.log("Unknown handshake phase:", msg.phase);
|
|
298
|
+
}
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Send an encrypted JSON command to the machine
|
|
303
|
+
* Uses CONTROL stream ID for proper routing on server side
|
|
304
|
+
*/
|
|
305
|
+
const sendCommand = useCallback(async (command: Record<string, unknown>) => {
|
|
306
|
+
const ws = wsRef.current;
|
|
307
|
+
const sessionKeys = sessionKeysRef.current;
|
|
308
|
+
|
|
309
|
+
console.log("[useTerminal] sendCommand called:", command.type, "ws:", !!ws, "wsState:", ws?.readyState, "keys:", !!sessionKeys);
|
|
310
|
+
|
|
311
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || !sessionKeys) {
|
|
312
|
+
console.warn("Cannot send command: not connected");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const json = JSON.stringify(command);
|
|
318
|
+
const data = new TextEncoder().encode(json);
|
|
319
|
+
const frame = await createFrame(CONTROL_STREAM_ID, data, sessionKeys.sendKey);
|
|
320
|
+
const base64 = btoa(String.fromCharCode(...frame));
|
|
321
|
+
ws.send(JSON.stringify({ type: "data", data: base64 }));
|
|
322
|
+
console.log("[useTerminal] Command sent successfully:", command.type);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.error("Failed to send command:", e);
|
|
325
|
+
}
|
|
326
|
+
}, []);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Request workspace list from machine
|
|
330
|
+
*/
|
|
331
|
+
const requestWorkspaces = useCallback(() => {
|
|
332
|
+
sendCommand({ type: "list_workspaces" });
|
|
333
|
+
}, [sendCommand]);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Request session list from machine
|
|
337
|
+
*/
|
|
338
|
+
const requestSessions = useCallback((workspaceId?: string) => {
|
|
339
|
+
sendCommand({ type: "list_sessions", workspaceId });
|
|
340
|
+
}, [sendCommand]);
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Attach to a session (existing or new in workspace)
|
|
344
|
+
*/
|
|
345
|
+
const attachSession = useCallback((params: {
|
|
346
|
+
sessionId?: string;
|
|
347
|
+
workspaceId?: string;
|
|
348
|
+
sessionName?: string;
|
|
349
|
+
cols?: number;
|
|
350
|
+
rows?: number;
|
|
351
|
+
}) => {
|
|
352
|
+
console.log("[useTerminal] attachSession:", params);
|
|
353
|
+
sendCommand({ type: "attach_session", ...params });
|
|
354
|
+
}, [sendCommand]);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Detach from current session (return to browsing)
|
|
358
|
+
*/
|
|
359
|
+
const detachSession = useCallback(() => {
|
|
360
|
+
sendCommand({ type: "detach" });
|
|
361
|
+
}, [sendCommand]);
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Request project list from machine
|
|
365
|
+
*/
|
|
366
|
+
const requestProjects = useCallback(() => {
|
|
367
|
+
sendCommand({ type: "list_projects" });
|
|
368
|
+
}, [sendCommand]);
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Kill a session
|
|
372
|
+
*/
|
|
373
|
+
const killSession = useCallback((sessionId: string) => {
|
|
374
|
+
sendCommand({ type: "kill_session", sessionId });
|
|
375
|
+
}, [sendCommand]);
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Delete a workspace
|
|
379
|
+
*/
|
|
380
|
+
const deleteWorkspace = useCallback((projectName: string, workspaceId: string) => {
|
|
381
|
+
sendCommand({ type: "delete_workspace", projectName, workspaceId });
|
|
382
|
+
}, [sendCommand]);
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resize terminal
|
|
386
|
+
*/
|
|
387
|
+
const resize = useCallback((cols: number, rows: number) => {
|
|
388
|
+
sendCommand({ type: "resize", cols, rows });
|
|
389
|
+
}, [sendCommand]);
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Request inbox items from machine
|
|
393
|
+
*/
|
|
394
|
+
const requestInbox = useCallback(() => {
|
|
395
|
+
sendCommand({ type: "get_inbox" });
|
|
396
|
+
}, [sendCommand]);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Clear inbox item(s)
|
|
400
|
+
*/
|
|
401
|
+
const clearInboxItem = useCallback((id?: string) => {
|
|
402
|
+
sendCommand({ type: "clear_inbox", id });
|
|
403
|
+
}, [sendCommand]);
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Mark inbox item as read
|
|
407
|
+
*/
|
|
408
|
+
const markInboxItemRead = useCallback((id: string) => {
|
|
409
|
+
sendCommand({ type: "mark_inbox_read", id });
|
|
410
|
+
}, [sendCommand]);
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Select a project (for filtering workspaces)
|
|
414
|
+
*/
|
|
415
|
+
const selectProject = useCallback((projectName: string | null) => {
|
|
416
|
+
setSelectedProjectName(projectName);
|
|
417
|
+
if (projectName) {
|
|
418
|
+
// Request workspaces for the selected project
|
|
419
|
+
requestWorkspaces();
|
|
420
|
+
}
|
|
421
|
+
}, [requestWorkspaces]);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Handle a decrypted browse response (workspace_list, session_list, etc.)
|
|
425
|
+
*/
|
|
426
|
+
const handleBrowseResponse = useCallback((msg: Record<string, unknown>) => {
|
|
427
|
+
switch (msg.type) {
|
|
428
|
+
case "project_list":
|
|
429
|
+
console.log("[useTerminal] Received project_list:", (msg.projects as ProjectInfo[]).length, "projects");
|
|
430
|
+
setProjects(msg.projects as ProjectInfo[]);
|
|
431
|
+
break;
|
|
432
|
+
|
|
433
|
+
case "workspace_list":
|
|
434
|
+
console.log("[useTerminal] Received workspace_list:", (msg.workspaces as WorkspaceInfo[]).length, "workspaces");
|
|
435
|
+
setWorkspaces(msg.workspaces as WorkspaceInfo[]);
|
|
436
|
+
break;
|
|
437
|
+
|
|
438
|
+
case "session_list":
|
|
439
|
+
setSessions(msg.sessions as SessionInfo[]);
|
|
440
|
+
break;
|
|
441
|
+
|
|
442
|
+
case "session_killed":
|
|
443
|
+
console.log("Session killed:", msg.sessionId, "in workspace:", msg.workspaceId);
|
|
444
|
+
// Refresh workspace list to update session counts
|
|
445
|
+
requestWorkspaces();
|
|
446
|
+
// Also refresh the sessions for that workspace so the killed session disappears
|
|
447
|
+
if (msg.workspaceId && msg.workspaceId !== "unknown") {
|
|
448
|
+
requestSessions(msg.workspaceId as string);
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
|
|
452
|
+
case "workspace_deleted":
|
|
453
|
+
console.log("Workspace deleted:", msg.workspaceId);
|
|
454
|
+
// Refresh workspace list
|
|
455
|
+
requestWorkspaces();
|
|
456
|
+
break;
|
|
457
|
+
|
|
458
|
+
case "attached":
|
|
459
|
+
console.log("Attached to session:", msg.sessionId, msg.sessionName);
|
|
460
|
+
setMode("attached");
|
|
461
|
+
modeRef.current = "attached";
|
|
462
|
+
setAttachedSessionId(msg.sessionId as string);
|
|
463
|
+
setAttachedSessionName(msg.sessionName as string || null);
|
|
464
|
+
break;
|
|
465
|
+
|
|
466
|
+
case "detached":
|
|
467
|
+
console.log("Detached from session");
|
|
468
|
+
setMode("browsing");
|
|
469
|
+
modeRef.current = "browsing";
|
|
470
|
+
setAttachedSessionId(null);
|
|
471
|
+
setAttachedSessionName(null);
|
|
472
|
+
// Refresh workspace list
|
|
473
|
+
requestWorkspaces();
|
|
474
|
+
break;
|
|
475
|
+
|
|
476
|
+
case "session_exited":
|
|
477
|
+
console.log("Session exited:", msg.sessionId, "code:", msg.exitCode);
|
|
478
|
+
setMode("browsing");
|
|
479
|
+
modeRef.current = "browsing";
|
|
480
|
+
setAttachedSessionId(null);
|
|
481
|
+
setAttachedSessionName(null);
|
|
482
|
+
requestWorkspaces();
|
|
483
|
+
break;
|
|
484
|
+
|
|
485
|
+
case "inbox_list":
|
|
486
|
+
setInbox(msg.items as InboxItem[]);
|
|
487
|
+
setInboxUnreadCount(msg.unreadCount as number);
|
|
488
|
+
break;
|
|
489
|
+
|
|
490
|
+
case "inbox_cleared":
|
|
491
|
+
// Refresh inbox after clearing
|
|
492
|
+
requestInbox();
|
|
493
|
+
break;
|
|
494
|
+
|
|
495
|
+
case "inbox_marked_read":
|
|
496
|
+
// Refresh inbox after marking read
|
|
497
|
+
requestInbox();
|
|
498
|
+
break;
|
|
499
|
+
|
|
500
|
+
case "error":
|
|
501
|
+
console.error("Machine error:", msg.code, msg.message);
|
|
502
|
+
break;
|
|
503
|
+
|
|
504
|
+
default:
|
|
505
|
+
console.log("Unknown browse response:", msg.type);
|
|
506
|
+
}
|
|
507
|
+
}, [requestWorkspaces, requestSessions, requestInbox]);
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Write PTY data to terminal with UTF-8 boundary handling
|
|
511
|
+
* Buffers incomplete UTF-8 sequences to prevent garbled output
|
|
512
|
+
*/
|
|
513
|
+
const writePtyData = useCallback((data: Uint8Array) => {
|
|
514
|
+
if (!writeCallbackRef.current) return;
|
|
515
|
+
|
|
516
|
+
// Combine with any buffered incomplete UTF-8 bytes
|
|
517
|
+
let combined: Uint8Array;
|
|
518
|
+
if (utf8BufferRef.current.length > 0) {
|
|
519
|
+
combined = new Uint8Array(utf8BufferRef.current.length + data.length);
|
|
520
|
+
combined.set(utf8BufferRef.current, 0);
|
|
521
|
+
combined.set(data, utf8BufferRef.current.length);
|
|
522
|
+
utf8BufferRef.current = new Uint8Array(0);
|
|
523
|
+
} else {
|
|
524
|
+
combined = data;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Find UTF-8 boundary
|
|
528
|
+
const boundary = findUtf8Boundary(combined);
|
|
529
|
+
if (boundary < combined.length) {
|
|
530
|
+
// Buffer incomplete sequence for next time
|
|
531
|
+
utf8BufferRef.current = combined.slice(boundary);
|
|
532
|
+
combined = combined.slice(0, boundary);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (combined.length > 0) {
|
|
536
|
+
writeCallbackRef.current(combined);
|
|
537
|
+
}
|
|
538
|
+
}, []);
|
|
539
|
+
|
|
540
|
+
const handleDataMessage = useCallback(async (base64Data: string) => {
|
|
541
|
+
const sessionKeys = sessionKeysRef.current;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
// Decode base64 to bytes
|
|
545
|
+
const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
|
546
|
+
|
|
547
|
+
// Try to parse as JSON first - could be a handshake message
|
|
548
|
+
// This is important because session keys are set before server_auth arrives
|
|
549
|
+
try {
|
|
550
|
+
const jsonStr = new TextDecoder().decode(bytes);
|
|
551
|
+
const envelope = JSON.parse(jsonStr);
|
|
552
|
+
|
|
553
|
+
if (envelope.type === "handshake") {
|
|
554
|
+
handleHandshakeMessage(envelope);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
// Not JSON, must be encrypted data - continue to decryption
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// If no session keys, we can't decrypt
|
|
562
|
+
if (!sessionKeys) {
|
|
563
|
+
console.warn("Received encrypted data before session established");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Session established - decrypt as encrypted frame
|
|
568
|
+
const result = await openFrame(bytes, sessionKeys.receiveKey);
|
|
569
|
+
if (!result) {
|
|
570
|
+
console.error("Failed to decrypt frame");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Try to parse as JSON - could be a browse response or PTY output message
|
|
575
|
+
try {
|
|
576
|
+
const jsonStr = new TextDecoder().decode(result.data);
|
|
577
|
+
const msg = JSON.parse(jsonStr);
|
|
578
|
+
|
|
579
|
+
// Check if it's a browse response or pty_output
|
|
580
|
+
if (msg.type === "pty_output") {
|
|
581
|
+
// Decode base64 PTY data and forward to terminal with UTF-8 handling
|
|
582
|
+
const ptyData = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0));
|
|
583
|
+
writePtyData(ptyData);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Handle as browse response
|
|
588
|
+
handleBrowseResponse(msg);
|
|
589
|
+
return;
|
|
590
|
+
} catch {
|
|
591
|
+
// Not JSON - in attached mode, this is raw PTY data
|
|
592
|
+
if (modeRef.current === "attached") {
|
|
593
|
+
writePtyData(result.data);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch (e) {
|
|
597
|
+
console.error("Failed to handle data message:", e);
|
|
598
|
+
}
|
|
599
|
+
}, [handleBrowseResponse, writePtyData]);
|
|
600
|
+
|
|
601
|
+
// Keep ref updated to avoid stale closure in handleMessage
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
handleDataMessageRef.current = handleDataMessage;
|
|
604
|
+
}, [handleDataMessage]);
|
|
605
|
+
|
|
606
|
+
const send = useCallback(async (data: Uint8Array) => {
|
|
607
|
+
const ws = wsRef.current;
|
|
608
|
+
const sessionKeys = sessionKeysRef.current;
|
|
609
|
+
|
|
610
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
611
|
+
console.warn("WebSocket not connected");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!sessionKeys) {
|
|
616
|
+
console.warn("Session keys not established");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
// Encrypt data into a frame
|
|
622
|
+
const frame = await createFrame(MASTER_STREAM_ID, data, sessionKeys.sendKey);
|
|
623
|
+
|
|
624
|
+
// Encode as base64 and send
|
|
625
|
+
const base64 = btoa(String.fromCharCode(...frame));
|
|
626
|
+
ws.send(JSON.stringify({
|
|
627
|
+
type: "data",
|
|
628
|
+
data: base64,
|
|
629
|
+
}));
|
|
630
|
+
} catch (e) {
|
|
631
|
+
console.error("Failed to send data:", e);
|
|
632
|
+
}
|
|
633
|
+
}, []);
|
|
634
|
+
|
|
635
|
+
const setWriteCallback = useCallback((fn: (data: Uint8Array) => void) => {
|
|
636
|
+
writeCallbackRef.current = fn;
|
|
637
|
+
}, []);
|
|
638
|
+
|
|
639
|
+
const disconnect = useCallback(() => {
|
|
640
|
+
wsRef.current?.close();
|
|
641
|
+
wsRef.current = null;
|
|
642
|
+
sessionKeysRef.current = null;
|
|
643
|
+
handshakeStateRef.current = null;
|
|
644
|
+
utf8BufferRef.current = new Uint8Array(0); // Clear UTF-8 buffer
|
|
645
|
+
setStatus("disconnected");
|
|
646
|
+
setMode("browsing");
|
|
647
|
+
modeRef.current = "browsing";
|
|
648
|
+
setProjects([]);
|
|
649
|
+
setWorkspaces([]);
|
|
650
|
+
setSessions([]);
|
|
651
|
+
setAttachedSessionId(null);
|
|
652
|
+
setAttachedSessionName(null);
|
|
653
|
+
setSelectedProjectName(null);
|
|
654
|
+
setInbox([]);
|
|
655
|
+
setInboxUnreadCount(0);
|
|
656
|
+
}, []);
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
// Connection state
|
|
660
|
+
status,
|
|
661
|
+
mode,
|
|
662
|
+
|
|
663
|
+
// Browse data
|
|
664
|
+
projects,
|
|
665
|
+
workspaces,
|
|
666
|
+
sessions,
|
|
667
|
+
attachedSessionId,
|
|
668
|
+
attachedSessionName,
|
|
669
|
+
selectedProjectName,
|
|
670
|
+
|
|
671
|
+
// Connection actions
|
|
672
|
+
connect,
|
|
673
|
+
disconnect,
|
|
674
|
+
|
|
675
|
+
// Browse actions
|
|
676
|
+
requestProjects,
|
|
677
|
+
requestWorkspaces,
|
|
678
|
+
requestSessions,
|
|
679
|
+
attachSession,
|
|
680
|
+
detachSession,
|
|
681
|
+
selectProject,
|
|
682
|
+
|
|
683
|
+
// Session/workspace management
|
|
684
|
+
killSession,
|
|
685
|
+
deleteWorkspace,
|
|
686
|
+
|
|
687
|
+
// Terminal I/O (for attached mode)
|
|
688
|
+
send,
|
|
689
|
+
resize,
|
|
690
|
+
setWriteCallback,
|
|
691
|
+
|
|
692
|
+
// Inbox
|
|
693
|
+
inbox,
|
|
694
|
+
inboxUnreadCount,
|
|
695
|
+
requestInbox,
|
|
696
|
+
clearInboxItem,
|
|
697
|
+
markInboxItemRead,
|
|
698
|
+
};
|
|
699
|
+
}
|