palmier 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/dist/agents/agent.d.ts +0 -1
- package/dist/agents/agent.js +0 -1
- package/dist/agents/aider.d.ts +0 -1
- package/dist/agents/aider.js +0 -1
- package/dist/agents/claude.d.ts +0 -1
- package/dist/agents/claude.js +0 -1
- package/dist/agents/cline.d.ts +0 -1
- package/dist/agents/cline.js +0 -1
- package/dist/agents/codex.d.ts +0 -1
- package/dist/agents/codex.js +0 -1
- package/dist/agents/copilot.d.ts +0 -1
- package/dist/agents/copilot.js +0 -1
- package/dist/agents/cursor.d.ts +0 -1
- package/dist/agents/cursor.js +0 -1
- package/dist/agents/deepagents.d.ts +0 -1
- package/dist/agents/deepagents.js +0 -1
- package/dist/agents/droid.d.ts +0 -1
- package/dist/agents/droid.js +0 -1
- package/dist/agents/gemini.d.ts +0 -1
- package/dist/agents/gemini.js +0 -1
- package/dist/agents/goose.d.ts +0 -1
- package/dist/agents/goose.js +0 -1
- package/dist/agents/hermes.d.ts +0 -1
- package/dist/agents/hermes.js +0 -1
- package/dist/agents/kimi.d.ts +0 -1
- package/dist/agents/kimi.js +0 -1
- package/dist/agents/kiro.d.ts +0 -1
- package/dist/agents/kiro.js +0 -1
- package/dist/agents/openclaw.d.ts +0 -1
- package/dist/agents/openclaw.js +0 -1
- package/dist/agents/opencode.d.ts +0 -1
- package/dist/agents/opencode.js +0 -1
- package/dist/agents/qoder.d.ts +0 -1
- package/dist/agents/qoder.js +0 -1
- package/dist/agents/qwen.d.ts +0 -1
- package/dist/agents/qwen.js +0 -1
- package/dist/agents/shared-prompt.d.ts +0 -1
- package/dist/agents/shared-prompt.js +0 -1
- package/dist/client-store.d.ts +0 -1
- package/dist/client-store.js +0 -1
- package/dist/commands/clients.d.ts +0 -1
- package/dist/commands/clients.js +0 -1
- package/dist/commands/info.d.ts +0 -1
- package/dist/commands/info.js +0 -1
- package/dist/commands/init.d.ts +0 -1
- package/dist/commands/init.js +1 -2
- package/dist/commands/pair.d.ts +0 -1
- package/dist/commands/pair.js +0 -1
- package/dist/commands/restart.d.ts +0 -1
- package/dist/commands/restart.js +0 -1
- package/dist/commands/run.d.ts +0 -1
- package/dist/commands/run.js +19 -3
- package/dist/commands/serve.d.ts +0 -1
- package/dist/commands/serve.js +0 -1
- package/dist/commands/uninstall.d.ts +0 -1
- package/dist/commands/uninstall.js +0 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/event-queues.d.ts +0 -1
- package/dist/event-queues.js +0 -1
- package/dist/events.d.ts +0 -1
- package/dist/events.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/linked-device.d.ts +0 -1
- package/dist/linked-device.js +0 -1
- package/dist/mcp-handler.d.ts +0 -1
- package/dist/mcp-handler.js +0 -1
- package/dist/mcp-tools.d.ts +0 -1
- package/dist/mcp-tools.js +0 -1
- package/dist/nats-client.d.ts +0 -1
- package/dist/nats-client.js +0 -1
- package/dist/network.d.ts +0 -1
- package/dist/network.js +0 -1
- package/dist/notification-store.d.ts +0 -1
- package/dist/notification-store.js +0 -1
- package/dist/pending-requests.d.ts +0 -1
- package/dist/pending-requests.js +0 -1
- package/dist/platform/index.d.ts +0 -1
- package/dist/platform/index.js +0 -1
- package/dist/platform/linux.d.ts +0 -1
- package/dist/platform/linux.js +0 -1
- package/dist/platform/macos.d.ts +0 -1
- package/dist/platform/macos.js +0 -1
- package/dist/platform/platform.d.ts +0 -1
- package/dist/platform/platform.js +0 -1
- package/dist/platform/windows.d.ts +0 -1
- package/dist/platform/windows.js +0 -1
- package/dist/pwa/assets/{index-MLEFUP3r.js → index-DWvRAUiy.js} +31 -31
- package/dist/pwa/assets/{web-B1sKCc7e.js → web-C4iZbqTC.js} +1 -1
- package/dist/pwa/assets/{web-ETD-8ZHd.js → web-CBFqJGX6.js} +1 -1
- package/dist/pwa/assets/{web-B4xEa6WO.js → web-DL4uXOpS.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/rpc-handler.d.ts +0 -1
- package/dist/rpc-handler.js +0 -1
- package/dist/sms-store.d.ts +0 -1
- package/dist/sms-store.js +0 -1
- package/dist/spawn-command.d.ts +0 -1
- package/dist/spawn-command.js +0 -1
- package/dist/task.d.ts +0 -1
- package/dist/task.js +0 -1
- package/dist/transports/http-transport.d.ts +0 -1
- package/dist/transports/http-transport.js +0 -1
- package/dist/transports/nats-transport.d.ts +0 -1
- package/dist/transports/nats-transport.js +0 -1
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/dist/update-checker.d.ts +0 -1
- package/dist/update-checker.js +0 -1
- package/package.json +11 -1
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -37
- package/CLAUDE.md +0 -22
- package/dist/pwa/apple-touch-icon.png +0 -0
- package/dist/pwa/manifest.webmanifest +0 -1
- package/dist/pwa/pwa-192x192.png +0 -0
- package/dist/pwa/pwa-512x512.png +0 -0
- package/dist/pwa/registerSW.js +0 -1
- package/dist/pwa/service-worker.js +0 -2
- package/palmier-server/.github/workflows/ci.yml +0 -21
- package/palmier-server/.github/workflows/deploy.yml +0 -38
- package/palmier-server/CLAUDE.md +0 -17
- package/palmier-server/PRODUCTION.md +0 -358
- package/palmier-server/README.md +0 -231
- package/palmier-server/nats.conf +0 -19
- package/palmier-server/package.json +0 -15
- package/palmier-server/pnpm-lock.yaml +0 -7639
- package/palmier-server/pnpm-workspace.yaml +0 -3
- package/palmier-server/pwa/index.html +0 -16
- package/palmier-server/pwa/logo/logo_20260421.png +0 -0
- package/palmier-server/pwa/package.json +0 -34
- package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
- package/palmier-server/pwa/public/favicon.ico +0 -0
- package/palmier-server/pwa/public/pwa-192x192.png +0 -0
- package/palmier-server/pwa/public/pwa-512x512.png +0 -0
- package/palmier-server/pwa/src/App.css +0 -3012
- package/palmier-server/pwa/src/App.tsx +0 -59
- package/palmier-server/pwa/src/agentLabels.ts +0 -11
- package/palmier-server/pwa/src/api.ts +0 -67
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +0 -170
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +0 -113
- package/palmier-server/pwa/src/components/HostMenu.tsx +0 -429
- package/palmier-server/pwa/src/components/PermissionsDialog.tsx +0 -34
- package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +0 -46
- package/palmier-server/pwa/src/components/RunDetailView.tsx +0 -343
- package/palmier-server/pwa/src/components/SessionComposer.tsx +0 -157
- package/palmier-server/pwa/src/components/SessionsView.tsx +0 -326
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +0 -170
- package/palmier-server/pwa/src/components/TabBar.tsx +0 -40
- package/palmier-server/pwa/src/components/TaskCard.tsx +0 -255
- package/palmier-server/pwa/src/components/TaskForm.tsx +0 -766
- package/palmier-server/pwa/src/components/TasksView.tsx +0 -179
- package/palmier-server/pwa/src/constants.ts +0 -2
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +0 -432
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +0 -124
- package/palmier-server/pwa/src/draftGuard.ts +0 -24
- package/palmier-server/pwa/src/formatTime.ts +0 -44
- package/palmier-server/pwa/src/hooks/useBackClose.ts +0 -75
- package/palmier-server/pwa/src/hooks/useMediaQuery.ts +0 -17
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +0 -102
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +0 -77
- package/palmier-server/pwa/src/main.tsx +0 -14
- package/palmier-server/pwa/src/native/Device.ts +0 -49
- package/palmier-server/pwa/src/pages/Dashboard.tsx +0 -542
- package/palmier-server/pwa/src/pages/PairHost.tsx +0 -232
- package/palmier-server/pwa/src/pages/PairSetup.tsx +0 -134
- package/palmier-server/pwa/src/service-worker.ts +0 -142
- package/palmier-server/pwa/src/types.ts +0 -75
- package/palmier-server/pwa/src/vite-env.d.ts +0 -11
- package/palmier-server/pwa/tsconfig.json +0 -21
- package/palmier-server/pwa/tsconfig.node.json +0 -19
- package/palmier-server/pwa/vite.config.ts +0 -47
- package/palmier-server/server/.env.example +0 -20
- package/palmier-server/server/package.json +0 -36
- package/palmier-server/server/src/db.ts +0 -44
- package/palmier-server/server/src/fcm.ts +0 -74
- package/palmier-server/server/src/index.ts +0 -688
- package/palmier-server/server/src/nats-jwt.ts +0 -299
- package/palmier-server/server/src/nats-setup.ts +0 -48
- package/palmier-server/server/src/nats.ts +0 -33
- package/palmier-server/server/src/notify.ts +0 -34
- package/palmier-server/server/src/push.ts +0 -68
- package/palmier-server/server/src/routes/device.ts +0 -224
- package/palmier-server/server/src/routes/fcm.ts +0 -64
- package/palmier-server/server/src/routes/hosts.ts +0 -56
- package/palmier-server/server/src/routes/push.ts +0 -101
- package/palmier-server/server/tsconfig.json +0 -20
- package/palmier-server/spec.md +0 -533
- package/src/agents/agent-instructions.md +0 -28
- package/src/agents/agent.ts +0 -114
- package/src/agents/aider.ts +0 -35
- package/src/agents/claude.ts +0 -39
- package/src/agents/cline.ts +0 -35
- package/src/agents/codex.ts +0 -40
- package/src/agents/copilot.ts +0 -37
- package/src/agents/cursor.ts +0 -36
- package/src/agents/deepagents.ts +0 -36
- package/src/agents/droid.ts +0 -35
- package/src/agents/gemini.ts +0 -43
- package/src/agents/goose.ts +0 -33
- package/src/agents/hermes.ts +0 -36
- package/src/agents/kimi.ts +0 -35
- package/src/agents/kiro.ts +0 -36
- package/src/agents/openclaw.ts +0 -29
- package/src/agents/opencode.ts +0 -36
- package/src/agents/qoder.ts +0 -36
- package/src/agents/qwen.ts +0 -32
- package/src/agents/shared-prompt.ts +0 -30
- package/src/client-store.ts +0 -68
- package/src/commands/clients.ts +0 -29
- package/src/commands/info.ts +0 -29
- package/src/commands/init.ts +0 -165
- package/src/commands/pair.ts +0 -137
- package/src/commands/restart.ts +0 -6
- package/src/commands/run.ts +0 -608
- package/src/commands/serve.ts +0 -211
- package/src/commands/uninstall.ts +0 -9
- package/src/config.ts +0 -36
- package/src/cross-spawn.d.ts +0 -5
- package/src/event-queues.ts +0 -41
- package/src/events.ts +0 -29
- package/src/index.ts +0 -111
- package/src/linked-device.ts +0 -52
- package/src/mcp-handler.ts +0 -200
- package/src/mcp-tools.ts +0 -839
- package/src/nats-client.ts +0 -19
- package/src/network.ts +0 -96
- package/src/notification-store.ts +0 -30
- package/src/pending-requests.ts +0 -73
- package/src/platform/index.ts +0 -20
- package/src/platform/linux.ts +0 -296
- package/src/platform/macos.ts +0 -329
- package/src/platform/platform.ts +0 -31
- package/src/platform/windows.ts +0 -299
- package/src/rpc-handler.ts +0 -691
- package/src/sms-store.ts +0 -28
- package/src/spawn-command.ts +0 -123
- package/src/task.ts +0 -343
- package/src/transports/http-transport.ts +0 -478
- package/src/transports/nats-transport.ts +0 -76
- package/src/types.ts +0 -89
- package/src/update-checker.ts +0 -40
- package/test/agent-instructions.test.ts +0 -209
- package/test/agent-output-parsing.test.ts +0 -74
- package/test/linux-cron.test.ts +0 -41
- package/test/macos-plist.test.ts +0 -112
- package/test/notification-store.test.ts +0 -57
- package/test/pairing.test.ts +0 -35
- package/test/result-state.test.ts +0 -110
- package/test/task-parsing.test.ts +0 -82
- package/test/taskrun-messages.test.ts +0 -224
- package/test/tsconfig.json +0 -9
- package/test/windows-xml.test.ts +0 -89
- package/tsconfig.json +0 -19
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
2
|
-
import TaskCard from "./TaskCard";
|
|
3
|
-
import TaskForm from "./TaskForm";
|
|
4
|
-
import PullToRefreshIndicator from "./PullToRefreshIndicator";
|
|
5
|
-
import { usePullToRefresh } from "../hooks/usePullToRefresh";
|
|
6
|
-
import type { AgentInfo, Task, TaskStatus } from "../types";
|
|
7
|
-
|
|
8
|
-
interface TasksViewProps {
|
|
9
|
-
connected: boolean;
|
|
10
|
-
hostId: string | null;
|
|
11
|
-
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
12
|
-
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
13
|
-
agents: AgentInfo[];
|
|
14
|
-
hostPlatform?: string;
|
|
15
|
-
isNotificationListener: boolean;
|
|
16
|
-
onViewRun(taskId: string, runId?: string): void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export default function TasksView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, isNotificationListener, onViewRun }: TasksViewProps) {
|
|
20
|
-
const [tasks, setTasks] = useState<Task[]>([]);
|
|
21
|
-
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
22
|
-
const [taskError, setTaskError] = useState<string | null>(null);
|
|
23
|
-
const [taskStatuses, setTaskStatuses] = useState<Map<string, TaskStatus>>(new Map());
|
|
24
|
-
|
|
25
|
-
const [showForm, setShowForm] = useState(false);
|
|
26
|
-
const [editingTask, setEditingTask] = useState<Task | undefined>(undefined);
|
|
27
|
-
|
|
28
|
-
const closeForm = useCallback(() => { setShowForm(false); setEditingTask(undefined); }, []);
|
|
29
|
-
|
|
30
|
-
const loadTasks = useCallback(async () => {
|
|
31
|
-
if (!connected) return;
|
|
32
|
-
setLoadingTasks(true);
|
|
33
|
-
setTaskError(null);
|
|
34
|
-
try {
|
|
35
|
-
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[] }>("task.list");
|
|
36
|
-
const taskList = result.tasks ?? [];
|
|
37
|
-
const initialStatuses = new Map<string, TaskStatus>();
|
|
38
|
-
for (const t of taskList) {
|
|
39
|
-
if (t.status) initialStatuses.set(t.id, t.status);
|
|
40
|
-
}
|
|
41
|
-
setTaskStatuses(initialStatuses);
|
|
42
|
-
setTasks(taskList);
|
|
43
|
-
} catch (err) {
|
|
44
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
45
|
-
if (errMsg === "Not connected" || errMsg.includes("503") || errMsg.toLowerCase().includes("no responders")) {
|
|
46
|
-
setTaskError(null);
|
|
47
|
-
} else {
|
|
48
|
-
setTaskError(errMsg);
|
|
49
|
-
}
|
|
50
|
-
setTasks([]);
|
|
51
|
-
} finally {
|
|
52
|
-
setLoadingTasks(false);
|
|
53
|
-
}
|
|
54
|
-
}, [connected, hostId, request]);
|
|
55
|
-
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (connected) loadTasks();
|
|
58
|
-
else { setTasks([]); setLoadingTasks(false); }
|
|
59
|
-
}, [connected, hostId, loadTasks]);
|
|
60
|
-
|
|
61
|
-
const ptr = usePullToRefresh({ onRefresh: loadTasks, enabled: connected });
|
|
62
|
-
|
|
63
|
-
// While mounted, keep task-card state fresh by refetching task.status on
|
|
64
|
-
// running-state / permission-resolved events. Dashboard owns the modal
|
|
65
|
-
// subscription; this one only drives the card indicators.
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (!connected || !hostId) return;
|
|
68
|
-
const unsubscribe = subscribeEvents(hostId, async (msg) => {
|
|
69
|
-
const tokens = msg.subject.split(".");
|
|
70
|
-
if (tokens.length < 3) return;
|
|
71
|
-
const taskId = tokens.slice(2).join(".");
|
|
72
|
-
|
|
73
|
-
let parsed: Record<string, unknown> = {};
|
|
74
|
-
try {
|
|
75
|
-
parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
|
|
76
|
-
} catch { return; }
|
|
77
|
-
const eventType = parsed.event_type as string | undefined;
|
|
78
|
-
if (eventType !== "running-state" && eventType !== "permission-resolved") return;
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
|
|
82
|
-
if (status.error) return;
|
|
83
|
-
setTaskStatuses((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
|
|
84
|
-
} catch { /* skip */ }
|
|
85
|
-
});
|
|
86
|
-
return unsubscribe;
|
|
87
|
-
}, [connected, hostId, subscribeEvents, request]);
|
|
88
|
-
|
|
89
|
-
function handleTaskSaved(task: Task) {
|
|
90
|
-
setTasks((prev) => {
|
|
91
|
-
const idx = prev.findIndex((t) => t.id === task.id);
|
|
92
|
-
if (idx >= 0) { const next = [...prev]; next[idx] = task; return next; }
|
|
93
|
-
return [task, ...prev];
|
|
94
|
-
});
|
|
95
|
-
setShowForm(false);
|
|
96
|
-
setEditingTask(undefined);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function handleTaskDeleted(taskId: string) {
|
|
100
|
-
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
101
|
-
setTaskStatuses((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<>
|
|
106
|
-
<PullToRefreshIndicator pullDistance={ptr.pullDistance} refreshing={ptr.refreshing} threshold={ptr.threshold} />
|
|
107
|
-
{taskError && <div className="form-error">{taskError}{taskError.toLowerCase().includes("failed to fetch") && <>{" "}<a href="#" onClick={(e) => { e.preventDefault(); window.location.reload(); }}>Reload</a></>}</div>}
|
|
108
|
-
|
|
109
|
-
{loadingTasks && !tasks.length ? (
|
|
110
|
-
<div className="task-list">
|
|
111
|
-
{[0, 1, 2].map((i) => (
|
|
112
|
-
<div key={i} className="task-card" style={{ pointerEvents: "none" }}>
|
|
113
|
-
<div className="task-card-header">
|
|
114
|
-
<div className="task-card-title-row">
|
|
115
|
-
<div className="skeleton-line" style={{ width: 10, height: 10, borderRadius: "50%" }} />
|
|
116
|
-
<div className="skeleton-line" style={{ width: `${60 + i * 12}%` }} />
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
<div className="task-card-meta">
|
|
120
|
-
<div className="skeleton-line" style={{ width: "30%" }} />
|
|
121
|
-
<div className="skeleton-line" style={{ width: "25%" }} />
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
))}
|
|
125
|
-
</div>
|
|
126
|
-
) : tasks.length === 0 ? (
|
|
127
|
-
<div className="empty-state">
|
|
128
|
-
<p className="empty-state-text">No tasks yet</p>
|
|
129
|
-
<p className="empty-state-hint">Tap the + button to create your first task.</p>
|
|
130
|
-
</div>
|
|
131
|
-
) : (
|
|
132
|
-
<div className="task-list">
|
|
133
|
-
{tasks.map((task) => (
|
|
134
|
-
<TaskCard
|
|
135
|
-
key={task.id}
|
|
136
|
-
task={task}
|
|
137
|
-
lastEvent={taskStatuses.get(task.id)}
|
|
138
|
-
onEdit={async (t) => {
|
|
139
|
-
try {
|
|
140
|
-
const latest = await request<Task & { error?: string }>("task.get", { id: t.id });
|
|
141
|
-
setEditingTask(latest.error ? t : latest);
|
|
142
|
-
} catch {
|
|
143
|
-
setEditingTask(t);
|
|
144
|
-
}
|
|
145
|
-
setShowForm(true);
|
|
146
|
-
}}
|
|
147
|
-
onDelete={handleTaskDeleted}
|
|
148
|
-
onViewRun={onViewRun}
|
|
149
|
-
/>
|
|
150
|
-
))}
|
|
151
|
-
</div>
|
|
152
|
-
)}
|
|
153
|
-
|
|
154
|
-
<button
|
|
155
|
-
className="fab"
|
|
156
|
-
onClick={() => { setEditingTask(undefined); setShowForm(true); }}
|
|
157
|
-
aria-label="New task"
|
|
158
|
-
title="New task"
|
|
159
|
-
>
|
|
160
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
161
|
-
<line x1="12" y1="5" x2="12" y2="19" />
|
|
162
|
-
<line x1="5" y1="12" x2="19" y2="12" />
|
|
163
|
-
</svg>
|
|
164
|
-
</button>
|
|
165
|
-
|
|
166
|
-
{showForm && (
|
|
167
|
-
<TaskForm
|
|
168
|
-
initial={editingTask}
|
|
169
|
-
agents={agents}
|
|
170
|
-
hostPlatform={hostPlatform}
|
|
171
|
-
isNotificationListener={isNotificationListener}
|
|
172
|
-
onSaved={handleTaskSaved}
|
|
173
|
-
onRun={onViewRun}
|
|
174
|
-
onCancel={closeForm}
|
|
175
|
-
/>
|
|
176
|
-
)}
|
|
177
|
-
</>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
@@ -1,432 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useContext,
|
|
4
|
-
useState,
|
|
5
|
-
useEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
useCallback,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
|
-
import { Capacitor } from "@capacitor/core";
|
|
12
|
-
import { App as CapacitorApp } from "@capacitor/app";
|
|
13
|
-
import { Network } from "@capacitor/network";
|
|
14
|
-
import { SERVER_URL } from "../api";
|
|
15
|
-
import type { PairedHost } from "../types";
|
|
16
|
-
|
|
17
|
-
type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
|
|
18
|
-
|
|
19
|
-
const isNative = Capacitor.isNativePlatform();
|
|
20
|
-
|
|
21
|
-
interface HostConnectionContextValue {
|
|
22
|
-
/** Whether we have an active connection to the host. */
|
|
23
|
-
connected: boolean;
|
|
24
|
-
/** Current connection mode. */
|
|
25
|
-
mode: ConnectionMode;
|
|
26
|
-
/** The raw NATS connection (null in direct-only mode). Used for pub/sub. */
|
|
27
|
-
nc: NatsConnection | null;
|
|
28
|
-
/** Transport-agnostic RPC request. */
|
|
29
|
-
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
30
|
-
/** Subscribe to task events. Returns unsubscribe function. */
|
|
31
|
-
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
32
|
-
/** Current active host. */
|
|
33
|
-
activeHost: PairedHost;
|
|
34
|
-
/** Whether the current client has been revoked by the host. */
|
|
35
|
-
unauthorized: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const HostConnectionContext = createContext<HostConnectionContextValue | null>(null);
|
|
39
|
-
|
|
40
|
-
const SSE_CONNECT_TIMEOUT_MS = 2_000;
|
|
41
|
-
const HEARTBEAT_TIMEOUT_MS = 6_000;
|
|
42
|
-
const LAN_PROBE_TIMEOUT_MS = 1_500;
|
|
43
|
-
const LAN_KEEPALIVE_MS = 60_000;
|
|
44
|
-
|
|
45
|
-
export function HostConnectionProvider({ children, activeHost }: { children: ReactNode; activeHost: PairedHost }) {
|
|
46
|
-
|
|
47
|
-
const [nc, setNc] = useState<NatsConnection | null>(null);
|
|
48
|
-
const [natsConnected, setNatsConnected] = useState(false);
|
|
49
|
-
const ncRef = useRef<NatsConnection | null>(null);
|
|
50
|
-
|
|
51
|
-
const [sseConnected, setSseConnected] = useState(false);
|
|
52
|
-
const [unauthorized, setUnauthorized] = useState(false);
|
|
53
|
-
const [hostNotFound, setHostNotFound] = useState(false);
|
|
54
|
-
const [lanReachable, setLanReachable] = useState(false);
|
|
55
|
-
|
|
56
|
-
const sc = useRef(StringCodec());
|
|
57
|
-
const sseEventCallbacksRef = useRef<Set<(msg: { subject: string; data: Uint8Array }) => void>>(new Set());
|
|
58
|
-
const lastHeartbeat = useRef(0);
|
|
59
|
-
|
|
60
|
-
// Host is a "direct-only" host if it has a directUrl (paired with address)
|
|
61
|
-
const isDirectHost = !!activeHost.directUrl;
|
|
62
|
-
// Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
|
|
63
|
-
const useLanRpc = isNative && !isDirectHost && !!activeHost.lanUrl && lanReachable;
|
|
64
|
-
|
|
65
|
-
const mode: ConnectionMode = unauthorized || hostNotFound
|
|
66
|
-
? "disconnected"
|
|
67
|
-
: isDirectHost
|
|
68
|
-
? (sseConnected ? "direct" : "connecting")
|
|
69
|
-
: !natsConnected
|
|
70
|
-
? "connecting"
|
|
71
|
-
: (useLanRpc ? "lan" : "nats");
|
|
72
|
-
const connected = mode !== "connecting" && mode !== "disconnected";
|
|
73
|
-
|
|
74
|
-
// Reset terminal states when switching hosts or re-pairing (new client token).
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
setUnauthorized(false);
|
|
77
|
-
setHostNotFound(false);
|
|
78
|
-
}, [activeHost.hostId, activeHost.clientToken]);
|
|
79
|
-
|
|
80
|
-
// Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
|
|
81
|
-
// HTTP when the probe succeeds; events stay on NATS regardless.
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (!isNative || isDirectHost || !activeHost.lanUrl) {
|
|
84
|
-
setLanReachable(false);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let cancelled = false;
|
|
89
|
-
const lanUrl = activeHost.lanUrl;
|
|
90
|
-
const expectedHostId = activeHost.hostId;
|
|
91
|
-
|
|
92
|
-
async function probe() {
|
|
93
|
-
console.log("[HOST/LAN] probing", lanUrl);
|
|
94
|
-
try {
|
|
95
|
-
const res = await fetch(`${lanUrl}/health`, {
|
|
96
|
-
signal: AbortSignal.timeout(LAN_PROBE_TIMEOUT_MS),
|
|
97
|
-
});
|
|
98
|
-
if (cancelled) return;
|
|
99
|
-
if (!res.ok) {
|
|
100
|
-
console.log("[HOST/LAN] probe got non-ok:", res.status);
|
|
101
|
-
setLanReachable(false);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
const data = await res.json() as { hostId?: string };
|
|
105
|
-
const reachable = data.hostId === expectedHostId;
|
|
106
|
-
if (!reachable) console.log("[HOST/LAN] hostId mismatch — got:", data.hostId, "expected:", expectedHostId);
|
|
107
|
-
else console.log("[HOST/LAN] reachable");
|
|
108
|
-
setLanReachable(reachable);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (!cancelled) {
|
|
111
|
-
console.log("[HOST/LAN] probe failed:", err instanceof Error ? err.message : err);
|
|
112
|
-
setLanReachable(false);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
probe();
|
|
118
|
-
const intervalId = window.setInterval(probe, LAN_KEEPALIVE_MS);
|
|
119
|
-
|
|
120
|
-
const appHandle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
|
|
121
|
-
if (state.isActive) probe();
|
|
122
|
-
});
|
|
123
|
-
const netHandle = Network.addListener("networkStatusChange", () => probe());
|
|
124
|
-
|
|
125
|
-
return () => {
|
|
126
|
-
cancelled = true;
|
|
127
|
-
clearInterval(intervalId);
|
|
128
|
-
appHandle.then((h) => h.remove());
|
|
129
|
-
netHandle.then((h) => h.remove());
|
|
130
|
-
};
|
|
131
|
-
}, [activeHost.hostId, activeHost.lanUrl, isDirectHost]);
|
|
132
|
-
|
|
133
|
-
// Fetch NATS config from server and connect (only for NATS hosts)
|
|
134
|
-
useEffect(() => {
|
|
135
|
-
if (isDirectHost) {
|
|
136
|
-
if (ncRef.current) {
|
|
137
|
-
ncRef.current.close().catch(() => {});
|
|
138
|
-
ncRef.current = null;
|
|
139
|
-
setNc(null);
|
|
140
|
-
setNatsConnected(false);
|
|
141
|
-
}
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let cancelled = false;
|
|
146
|
-
let retryDelayMs = 1_000;
|
|
147
|
-
const MAX_RETRY_MS = 30_000;
|
|
148
|
-
|
|
149
|
-
async function connectLoop() {
|
|
150
|
-
while (!cancelled) {
|
|
151
|
-
try {
|
|
152
|
-
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost.hostId}`);
|
|
153
|
-
if (cancelled) return;
|
|
154
|
-
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
155
|
-
console.error("[NATS] Host not found or rejected by relay:", res.status);
|
|
156
|
-
setHostNotFound(true);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (!res.ok) throw new Error(`credentials fetch ${res.status}`);
|
|
160
|
-
|
|
161
|
-
const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
162
|
-
if (!config.natsWsUrl) {
|
|
163
|
-
console.error("[NATS] Relay returned empty natsWsUrl — treating as terminal");
|
|
164
|
-
setHostNotFound(true);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (cancelled) return;
|
|
168
|
-
|
|
169
|
-
console.log("[NATS] Connecting to", config.natsWsUrl);
|
|
170
|
-
const conn = await connect({
|
|
171
|
-
servers: config.natsWsUrl,
|
|
172
|
-
authenticator: jwtAuthenticator(
|
|
173
|
-
config.natsJwt,
|
|
174
|
-
new TextEncoder().encode(config.natsNkeySeed),
|
|
175
|
-
),
|
|
176
|
-
// Default is 2 min — too slow to notice a silently-dead WebSocket
|
|
177
|
-
// when the user has the page foregrounded but the network drops.
|
|
178
|
-
// The resume-probe useEffect below catches dead conns within 2s
|
|
179
|
-
// when the user returns to the app, so this background heartbeat
|
|
180
|
-
// can stay conservative (maxPingOut: 2 → ~60s) and avoid false
|
|
181
|
-
// positives from transient network jitter.
|
|
182
|
-
pingInterval: 30_000,
|
|
183
|
-
maxPingOut: 2,
|
|
184
|
-
});
|
|
185
|
-
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
186
|
-
console.log("[NATS] Connected");
|
|
187
|
-
ncRef.current = conn;
|
|
188
|
-
setNc(conn);
|
|
189
|
-
setNatsConnected(true);
|
|
190
|
-
retryDelayMs = 1_000;
|
|
191
|
-
|
|
192
|
-
await conn.closed();
|
|
193
|
-
if (cancelled) return;
|
|
194
|
-
|
|
195
|
-
console.log("[NATS] Connection closed, will reconnect");
|
|
196
|
-
ncRef.current = null;
|
|
197
|
-
setNc(null);
|
|
198
|
-
setNatsConnected(false);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
if (cancelled) return;
|
|
201
|
-
console.warn(`[NATS] Connect failed, retrying in ${retryDelayMs}ms:`, err);
|
|
202
|
-
setNatsConnected(false);
|
|
203
|
-
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
204
|
-
retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_MS);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
connectLoop();
|
|
210
|
-
|
|
211
|
-
return () => {
|
|
212
|
-
cancelled = true;
|
|
213
|
-
if (ncRef.current) {
|
|
214
|
-
ncRef.current.close().catch(() => {});
|
|
215
|
-
ncRef.current = null;
|
|
216
|
-
setNc(null);
|
|
217
|
-
setNatsConnected(false);
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
}, [isDirectHost, activeHost]);
|
|
221
|
-
|
|
222
|
-
// WebSockets can die silently — mobile WebViews suspend them on background,
|
|
223
|
-
// laptops on sleep, networks on switch. On any resume (visibility change on
|
|
224
|
-
// web, foreground on native), probe the connection with a short-timeout
|
|
225
|
-
// flush (PING/PONG round-trip); close only if it doesn't respond. nats.ws's
|
|
226
|
-
// own pingInterval catches it eventually, but probing on resume gets us
|
|
227
|
-
// back faster when the user immediately fires an RPC.
|
|
228
|
-
useEffect(() => {
|
|
229
|
-
if (isDirectHost) return;
|
|
230
|
-
const handle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
|
|
231
|
-
if (!state.isActive || !ncRef.current) return;
|
|
232
|
-
const conn = ncRef.current;
|
|
233
|
-
Promise.race([
|
|
234
|
-
conn.flush(),
|
|
235
|
-
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("ping timeout")), 2_000)),
|
|
236
|
-
]).catch(() => {
|
|
237
|
-
console.log("[NATS] Resume probe failed — cycling conn");
|
|
238
|
-
conn.close().catch(() => {});
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
return () => { handle.then((h) => h.remove()); };
|
|
242
|
-
}, [isDirectHost]);
|
|
243
|
-
|
|
244
|
-
// SSE connection for direct (LAN) hosts only
|
|
245
|
-
useEffect(() => {
|
|
246
|
-
if (!isDirectHost) {
|
|
247
|
-
setSseConnected(false);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const controller = new AbortController();
|
|
252
|
-
|
|
253
|
-
async function connectSSE(): Promise<ReadableStreamDefaultReader<Uint8Array> | null> {
|
|
254
|
-
const connectAc = new AbortController();
|
|
255
|
-
const timer = setTimeout(() => connectAc.abort(), SSE_CONNECT_TIMEOUT_MS);
|
|
256
|
-
controller.signal.addEventListener("abort", () => connectAc.abort());
|
|
257
|
-
try {
|
|
258
|
-
const res = await fetch(`${activeHost.directUrl}/events`, {
|
|
259
|
-
headers: { Authorization: `Bearer ${activeHost.clientToken}` },
|
|
260
|
-
signal: connectAc.signal,
|
|
261
|
-
});
|
|
262
|
-
clearTimeout(timer);
|
|
263
|
-
if (!res.ok) return null;
|
|
264
|
-
return res.body?.getReader() ?? null;
|
|
265
|
-
} catch {
|
|
266
|
-
clearTimeout(timer);
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function readStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
|
272
|
-
setSseConnected(true);
|
|
273
|
-
console.log("[HOST] SSE connected to", activeHost.directUrl);
|
|
274
|
-
|
|
275
|
-
lastHeartbeat.current = Date.now();
|
|
276
|
-
function checkHeartbeat() {
|
|
277
|
-
setTimeout(() => {
|
|
278
|
-
if (controller.signal.aborted) return;
|
|
279
|
-
if (Date.now() - lastHeartbeat.current < HEARTBEAT_TIMEOUT_MS) return;
|
|
280
|
-
console.log("[HOST] Heartbeat timeout — no data for", HEARTBEAT_TIMEOUT_MS / 1000, "s");
|
|
281
|
-
controller.abort();
|
|
282
|
-
setSseConnected(false);
|
|
283
|
-
console.log("[HOST] Direct host unreachable");
|
|
284
|
-
}, HEARTBEAT_TIMEOUT_MS);
|
|
285
|
-
}
|
|
286
|
-
checkHeartbeat();
|
|
287
|
-
|
|
288
|
-
const decoder = new TextDecoder();
|
|
289
|
-
let buffer = "";
|
|
290
|
-
try {
|
|
291
|
-
while (true) {
|
|
292
|
-
const { done, value } = await reader.read();
|
|
293
|
-
if (done) break;
|
|
294
|
-
buffer += decoder.decode(value, { stream: true });
|
|
295
|
-
lastHeartbeat.current = Date.now();
|
|
296
|
-
checkHeartbeat();
|
|
297
|
-
|
|
298
|
-
const lines = buffer.split("\n");
|
|
299
|
-
buffer = lines.pop() ?? "";
|
|
300
|
-
for (const line of lines) {
|
|
301
|
-
if (line.startsWith("data: ")) {
|
|
302
|
-
const jsonStr = line.slice(6);
|
|
303
|
-
try {
|
|
304
|
-
const event = JSON.parse(jsonStr) as { task_id?: string; event_type?: string };
|
|
305
|
-
if (event.task_id && event.event_type) {
|
|
306
|
-
const subject = `host-event.${activeHost.hostId}.${event.task_id}`;
|
|
307
|
-
for (const cb of sseEventCallbacksRef.current) cb({ subject, data: sc.current.encode(jsonStr) });
|
|
308
|
-
}
|
|
309
|
-
} catch { /* skip malformed — includes heartbeat pings */ }
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
} catch {
|
|
314
|
-
// Stream ended or aborted
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
(async () => {
|
|
319
|
-
const reader = await connectSSE();
|
|
320
|
-
if (reader) await readStream(reader);
|
|
321
|
-
})();
|
|
322
|
-
|
|
323
|
-
return () => { controller.abort(); setSseConnected(false); };
|
|
324
|
-
}, [activeHost, isDirectHost]);
|
|
325
|
-
|
|
326
|
-
// Transport-agnostic RPC
|
|
327
|
-
const request = useCallback(async <T = unknown>(
|
|
328
|
-
method: string,
|
|
329
|
-
params?: unknown,
|
|
330
|
-
opts?: { timeout?: number },
|
|
331
|
-
): Promise<T> => {
|
|
332
|
-
function checkUnauthorized(data: unknown): void {
|
|
333
|
-
if (data && typeof data === "object" && (data as Record<string, unknown>).error === "Unauthorized") {
|
|
334
|
-
setUnauthorized(true);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async function callHttp(baseUrl: string, label: string): Promise<T> {
|
|
339
|
-
console.log(`[HOST/${label}] → ${method}`, params ?? "");
|
|
340
|
-
const res = await fetch(`${baseUrl}/rpc/${method}`, {
|
|
341
|
-
method: "POST",
|
|
342
|
-
headers: {
|
|
343
|
-
"Content-Type": "application/json",
|
|
344
|
-
Authorization: `Bearer ${activeHost.clientToken}`,
|
|
345
|
-
},
|
|
346
|
-
body: params != null ? JSON.stringify(params) : undefined,
|
|
347
|
-
signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
|
|
348
|
-
});
|
|
349
|
-
if (res.status === 401) {
|
|
350
|
-
setUnauthorized(true);
|
|
351
|
-
throw new Error("Unauthorized");
|
|
352
|
-
}
|
|
353
|
-
const data = await res.json() as T;
|
|
354
|
-
checkUnauthorized(data);
|
|
355
|
-
console.log(`[HOST/${label}] ← ${method}`, data);
|
|
356
|
-
return data;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function callNats(): Promise<T> {
|
|
360
|
-
if (!ncRef.current) throw new Error("Not connected");
|
|
361
|
-
const subject = `host.${activeHost.hostId}.rpc.${method}`;
|
|
362
|
-
const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
|
|
363
|
-
const body = sc.current.encode(JSON.stringify(payload));
|
|
364
|
-
console.log(`[HOST/NATS] → ${method}`, params ?? "");
|
|
365
|
-
const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
|
|
366
|
-
const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
|
|
367
|
-
checkUnauthorized(decoded);
|
|
368
|
-
console.log(`[HOST/NATS] ← ${method}`, decoded);
|
|
369
|
-
return decoded;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Direct (loopback) host — always HTTP, no fallback.
|
|
373
|
-
if (isDirectHost) return callHttp(activeHost.directUrl!, "HTTP");
|
|
374
|
-
|
|
375
|
-
// Auto-LAN: try HTTP first; on failure mark unreachable and retry over NATS.
|
|
376
|
-
if (useLanRpc && activeHost.lanUrl) {
|
|
377
|
-
try {
|
|
378
|
-
return await callHttp(activeHost.lanUrl, "LAN");
|
|
379
|
-
} catch (err) {
|
|
380
|
-
if (err instanceof Error && err.message === "Unauthorized") throw err;
|
|
381
|
-
console.log("[HOST/LAN] failed, falling back to NATS:", err);
|
|
382
|
-
setLanReachable(false);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return callNats();
|
|
387
|
-
}, [activeHost, isDirectHost, useLanRpc]);
|
|
388
|
-
|
|
389
|
-
// Subscribe to task events
|
|
390
|
-
const subscribeEvents = useCallback((hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void => {
|
|
391
|
-
// Direct mode — register callback for SSE to forward events
|
|
392
|
-
if (isDirectHost) {
|
|
393
|
-
sseEventCallbacksRef.current.add(callback);
|
|
394
|
-
return () => { sseEventCallbacksRef.current.delete(callback); };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// NATS subscription
|
|
398
|
-
if (ncRef.current) {
|
|
399
|
-
const sub: Subscription = ncRef.current.subscribe(`host-event.${hostId}.>`);
|
|
400
|
-
let cancelled = false;
|
|
401
|
-
|
|
402
|
-
(async () => {
|
|
403
|
-
try {
|
|
404
|
-
for await (const msg of sub) {
|
|
405
|
-
if (cancelled) break;
|
|
406
|
-
callback({ subject: msg.subject, data: msg.data });
|
|
407
|
-
}
|
|
408
|
-
} catch {
|
|
409
|
-
// Subscription ended
|
|
410
|
-
}
|
|
411
|
-
})();
|
|
412
|
-
|
|
413
|
-
return () => { cancelled = true; sub.unsubscribe(); };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return () => {};
|
|
417
|
-
}, [activeHost, isDirectHost]);
|
|
418
|
-
|
|
419
|
-
return (
|
|
420
|
-
<HostConnectionContext.Provider
|
|
421
|
-
value={{ connected, mode, nc, request, subscribeEvents, activeHost, unauthorized }}
|
|
422
|
-
>
|
|
423
|
-
{children}
|
|
424
|
-
</HostConnectionContext.Provider>
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export function useHostConnection(): HostConnectionContextValue {
|
|
429
|
-
const ctx = useContext(HostConnectionContext);
|
|
430
|
-
if (!ctx) throw new Error("useHostConnection must be used within HostConnectionProvider");
|
|
431
|
-
return ctx;
|
|
432
|
-
}
|