linkshell-cli 0.10.45 → 0.10.47
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 +2 -0
- package/dist/cli/src/auth.d.ts +6 -2
- package/dist/cli/src/auth.js +12 -4
- package/dist/cli/src/auth.js.map +1 -1
- package/dist/cli/src/commands/desktop-workspace-state.d.ts +19 -0
- package/dist/cli/src/commands/desktop-workspace-state.js +92 -0
- package/dist/cli/src/commands/desktop-workspace-state.js.map +1 -0
- package/dist/cli/src/commands/desktop-workspace.d.ts +12 -0
- package/dist/cli/src/commands/desktop-workspace.js +415 -0
- package/dist/cli/src/commands/desktop-workspace.js.map +1 -0
- package/dist/cli/src/commands/desktop.d.ts +5 -5
- package/dist/cli/src/commands/desktop.js +55 -5
- package/dist/cli/src/commands/desktop.js.map +1 -1
- package/dist/cli/src/commands/login.d.ts +3 -1
- package/dist/cli/src/commands/login.js +44 -26
- package/dist/cli/src/commands/login.js.map +1 -1
- package/dist/cli/src/index.js +15 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/pro-membership.d.ts +16 -0
- package/dist/cli/src/pro-membership.js +91 -0
- package/dist/cli/src/pro-membership.js.map +1 -0
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/client-core/src/pro-membership.d.ts +18 -0
- package/dist/client-core/src/pro-membership.js +109 -0
- package/dist/client-core/src/pro-membership.js.map +1 -0
- package/dist/host/src/input.d.ts +29 -2
- package/dist/host/src/input.js +99 -4
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/screen-session.d.ts +35 -0
- package/dist/host/src/screen-session.js +26 -0
- package/dist/host/src/screen-session.js.map +1 -0
- package/dist/host/src/screen-viewer.js +293 -30
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +3 -1
- package/dist/host/src/screen.js +454 -160
- package/dist/host/src/screen.js.map +1 -1
- package/package.json +6 -4
- package/src/auth.ts +9 -4
- package/src/commands/desktop-workspace-state.ts +87 -0
- package/src/commands/desktop-workspace.ts +353 -0
- package/src/commands/desktop.ts +49 -5
- package/src/commands/login.ts +33 -34
- package/src/index.ts +16 -0
- package/src/pro-membership.ts +82 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
4
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import type { Readable } from "node:stream";
|
|
7
|
+
import type { Socket } from "node:net";
|
|
8
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
9
|
+
import { isMethodName, machineInfoSchema, rpcMessageSchema, type MachineEntry } from "@linkshell/wire";
|
|
10
|
+
import { getValidToken, loadAuth } from "../auth.js";
|
|
11
|
+
import { runLogin } from "./login.js";
|
|
12
|
+
import { runLogout } from "./logout.js";
|
|
13
|
+
import { silenceSqliteWarning } from "./host.js";
|
|
14
|
+
import { DesktopClientState, validStorageKey } from "./desktop-workspace-state.js";
|
|
15
|
+
|
|
16
|
+
const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_PROXY_BYTES = 40 * 1024 * 1024;
|
|
18
|
+
const MIME: Record<string, string> = {
|
|
19
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
20
|
+
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon",
|
|
22
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".json": "application/json",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface LocalComputer {
|
|
26
|
+
key: "desktop:local";
|
|
27
|
+
gateway: "desktop:local";
|
|
28
|
+
machine: MachineEntry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DesktopWorkspace {
|
|
32
|
+
url: string;
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class HttpError extends Error {
|
|
37
|
+
constructor(readonly status: number, message: string) { super(message); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function json(response: ServerResponse, status: number, value: unknown): void {
|
|
41
|
+
if (response.destroyed || response.writableEnded) return;
|
|
42
|
+
response.writeHead(status, {
|
|
43
|
+
"content-type": "application/json; charset=utf-8",
|
|
44
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff",
|
|
45
|
+
"referrer-policy": "no-referrer", "x-frame-options": "DENY",
|
|
46
|
+
});
|
|
47
|
+
response.end(JSON.stringify(value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
51
|
+
if (request.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
|
|
52
|
+
throw new HttpError(415, "请求需要使用 JSON 格式");
|
|
53
|
+
}
|
|
54
|
+
if (Number(request.headers["content-length"]) > MAX_REQUEST_BYTES) throw new HttpError(413, "偏好内容过长");
|
|
55
|
+
let length = 0;
|
|
56
|
+
const chunks: Buffer[] = [];
|
|
57
|
+
for await (const chunk of request) {
|
|
58
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
59
|
+
length += bytes.length;
|
|
60
|
+
if (length > MAX_REQUEST_BYTES) throw new HttpError(413, "偏好内容过长");
|
|
61
|
+
chunks.push(bytes);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
65
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
66
|
+
} catch { /* Request contents may contain private drafts; never echo them. */ }
|
|
67
|
+
throw new HttpError(400, "无效的 JSON 请求");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadAssets(directory: string): Promise<Map<string, Buffer>> {
|
|
71
|
+
if (!isAbsolute(directory)) throw new Error("桌面工作台资源目录必须使用绝对路径");
|
|
72
|
+
const assets = new Map<string, Buffer>();
|
|
73
|
+
const root = await realpath(directory);
|
|
74
|
+
const walk = async (path: string, prefix = "") => {
|
|
75
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
76
|
+
if (entry.isDirectory()) await walk(join(path, entry.name), `${prefix}/${entry.name}`);
|
|
77
|
+
else if (entry.isFile()) assets.set(`${prefix}/${entry.name}`, await readFile(join(path, entry.name)));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
await walk(root);
|
|
81
|
+
if (!assets.has("/index.html")) throw new Error("桌面工作台资源缺失,请重新安装 LinkShell");
|
|
82
|
+
return assets;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function localComputer(target: string, upstreams: Set<WebSocket>): Promise<LocalComputer | undefined> {
|
|
86
|
+
// Query the existing socket directly: discovering this computer never starts a host.
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const client = new WebSocket(`ws+unix://${target}:/`, { perMessageDeflate: false, handshakeTimeout: 1_500, maxPayload: MAX_PROXY_BYTES });
|
|
89
|
+
upstreams.add(client);
|
|
90
|
+
const finish = (computer?: LocalComputer) => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
resolve(computer);
|
|
93
|
+
client.terminate();
|
|
94
|
+
};
|
|
95
|
+
const timeout = setTimeout(() => finish(), 3_000);
|
|
96
|
+
client.on("error", () => finish());
|
|
97
|
+
client.on("close", () => { upstreams.delete(client); clearTimeout(timeout); resolve(undefined); });
|
|
98
|
+
client.on("open", () => client.send(JSON.stringify({ jsonrpc: "2.0", id: "desktop-info", method: "machine.info", params: {} })));
|
|
99
|
+
client.on("message", (raw) => {
|
|
100
|
+
try {
|
|
101
|
+
const response: unknown = JSON.parse(raw.toString());
|
|
102
|
+
if (!response || typeof response !== "object" || !("id" in response) || response.id !== "desktop-info") return;
|
|
103
|
+
const parsed = machineInfoSchema.safeParse("result" in response ? response.result : undefined);
|
|
104
|
+
if (!parsed.success) { finish(); return; }
|
|
105
|
+
const info = parsed.data;
|
|
106
|
+
finish({
|
|
107
|
+
key: "desktop:local", gateway: "desktop:local",
|
|
108
|
+
machine: {
|
|
109
|
+
id: info.machineId, name: info.hostname.replace(/\.local$/, ""), platform: info.platform,
|
|
110
|
+
// Local transport uses a protected Unix socket, not a relay identity or tunnel.
|
|
111
|
+
signKey: "", boxKey: "", online: true, via: "paired",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
} catch { finish(); }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function account(signal: AbortSignal) {
|
|
120
|
+
const accessToken = await getValidToken({ signal });
|
|
121
|
+
const current = loadAuth();
|
|
122
|
+
if (!current?.userId) return {};
|
|
123
|
+
return {
|
|
124
|
+
userId: current.userId, email: current.email,
|
|
125
|
+
...(accessToken && current.accessToken === accessToken ? { accessToken } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function proxyHost(client: WebSocket, target: string, upstreams: Set<WebSocket>): void {
|
|
130
|
+
const host = new WebSocket(`ws+unix://${target}:/`, {
|
|
131
|
+
perMessageDeflate: false, handshakeTimeout: 3_000, maxPayload: MAX_PROXY_BYTES,
|
|
132
|
+
});
|
|
133
|
+
upstreams.add(host);
|
|
134
|
+
let pending: string[] = [];
|
|
135
|
+
let pendingBytes = 0;
|
|
136
|
+
const fail = (code: number, reason: string) => {
|
|
137
|
+
if (client.readyState === WebSocket.OPEN) client.close(code, reason);
|
|
138
|
+
host.terminate();
|
|
139
|
+
pending = [];
|
|
140
|
+
};
|
|
141
|
+
const forward = (text: string) => {
|
|
142
|
+
if (host.bufferedAmount + Buffer.byteLength(text) > MAX_PROXY_BYTES) {
|
|
143
|
+
fail(1013, "连接繁忙,请重试");
|
|
144
|
+
} else host.send(text, (error) => { if (error) fail(1011, "本机连接已断开"); });
|
|
145
|
+
};
|
|
146
|
+
client.on("message", (raw, binary) => {
|
|
147
|
+
if (binary) { fail(1008, "不支持此消息格式"); return; }
|
|
148
|
+
const text = raw.toString();
|
|
149
|
+
let message;
|
|
150
|
+
try { message = rpcMessageSchema.safeParse(JSON.parse(text)); }
|
|
151
|
+
catch { fail(1008, "无效的 RPC 请求"); return; }
|
|
152
|
+
if (!message.success || typeof message.data.method !== "string") {
|
|
153
|
+
fail(1008, "无效的 RPC 请求"); return;
|
|
154
|
+
}
|
|
155
|
+
const { method, id } = message.data;
|
|
156
|
+
// Unix clients include privileged native-agent shims; this browser is only a normal client.
|
|
157
|
+
if (method.startsWith("desktop.") || !isMethodName(method)) {
|
|
158
|
+
if (id !== undefined && client.readyState === WebSocket.OPEN) {
|
|
159
|
+
client.send(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: "不支持此操作" } }));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (host.readyState === WebSocket.CONNECTING) {
|
|
164
|
+
pendingBytes += Buffer.byteLength(text);
|
|
165
|
+
if (pendingBytes > MAX_PROXY_BYTES) { fail(1013, "连接繁忙,请重试"); return; }
|
|
166
|
+
pending.push(text);
|
|
167
|
+
} else if (host.readyState === WebSocket.OPEN) forward(text);
|
|
168
|
+
});
|
|
169
|
+
host.on("open", () => {
|
|
170
|
+
if (client.readyState !== WebSocket.OPEN) { host.terminate(); return; }
|
|
171
|
+
for (const text of pending) {
|
|
172
|
+
if (host.readyState !== WebSocket.OPEN) break;
|
|
173
|
+
forward(text);
|
|
174
|
+
}
|
|
175
|
+
pending = [];
|
|
176
|
+
pendingBytes = 0;
|
|
177
|
+
});
|
|
178
|
+
host.on("message", (data, binary) => {
|
|
179
|
+
if (client.readyState !== WebSocket.OPEN) return;
|
|
180
|
+
const size = Array.isArray(data) ? data.reduce((total, part) => total + part.length, 0) : data.byteLength;
|
|
181
|
+
if (client.bufferedAmount + size > MAX_PROXY_BYTES) { fail(1013, "连接繁忙,请重试"); return; }
|
|
182
|
+
client.send(data, { binary }, (error) => { if (error) host.terminate(); });
|
|
183
|
+
});
|
|
184
|
+
host.on("error", () => fail(1013, "本机服务未运行,请在本机服务中启动"));
|
|
185
|
+
host.on("close", () => {
|
|
186
|
+
upstreams.delete(host);
|
|
187
|
+
pending = [];
|
|
188
|
+
if (client.readyState === WebSocket.OPEN) client.close(1013, "本机服务已断开");
|
|
189
|
+
});
|
|
190
|
+
client.on("error", () => host.terminate());
|
|
191
|
+
client.on("close", () => { pending = []; host.terminate(); });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A capability-protected UI server owned by the native application's lifetime. */
|
|
195
|
+
export async function startDesktopWorkspace(options: { assets: string; home?: string; hostSocket?: string }): Promise<DesktopWorkspace> {
|
|
196
|
+
silenceSqliteWarning();
|
|
197
|
+
const home = options.home ?? process.env.LINKSHELL_HOME ?? join(homedir(), ".linkshell");
|
|
198
|
+
const assets = await loadAssets(options.assets);
|
|
199
|
+
const state = await DesktopClientState.open(home);
|
|
200
|
+
const hostSocket = options.hostSocket ?? (await import("@linkshell/host")).hostPaths(home).hostSocket;
|
|
201
|
+
const capability = randomBytes(32).toString("base64url");
|
|
202
|
+
const secret = Buffer.from(capability);
|
|
203
|
+
const hasToken = (value: string | undefined): boolean => {
|
|
204
|
+
const supplied = Buffer.from(value ?? "");
|
|
205
|
+
return supplied.length === secret.length && timingSafeEqual(supplied, secret);
|
|
206
|
+
};
|
|
207
|
+
let workspaceOrigin = "";
|
|
208
|
+
let workspaceHost = "";
|
|
209
|
+
let previewOrigin = "";
|
|
210
|
+
let previewHost = "";
|
|
211
|
+
let login: Promise<Awaited<ReturnType<typeof runLogin>>> | undefined;
|
|
212
|
+
let loginAbort: AbortController | undefined;
|
|
213
|
+
let stopping: Promise<void> | undefined;
|
|
214
|
+
const peers = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PROXY_BYTES });
|
|
215
|
+
const upstreams = new Set<WebSocket>();
|
|
216
|
+
const sockets = new Set<Socket>();
|
|
217
|
+
const accountAbort = new AbortController();
|
|
218
|
+
|
|
219
|
+
const serve = async (request: IncomingMessage, response: ServerResponse) => {
|
|
220
|
+
const host = request.headers.host;
|
|
221
|
+
if (host !== workspaceHost && host !== previewHost) throw new HttpError(403, "来源不受信任");
|
|
222
|
+
if (!request.url?.startsWith("/") || request.url.startsWith("//")) throw new HttpError(400, "无效的请求地址");
|
|
223
|
+
const url = new URL(request.url, workspaceOrigin);
|
|
224
|
+
const path = url.pathname;
|
|
225
|
+
if (host === previewHost) {
|
|
226
|
+
// A preview service worker can only ever own this other origin and /preview/ scope.
|
|
227
|
+
if (!path.startsWith("/preview/")) throw new HttpError(404, "未找到资源");
|
|
228
|
+
} else {
|
|
229
|
+
const origin = request.headers.origin;
|
|
230
|
+
if (origin !== undefined && origin !== workspaceOrigin) throw new HttpError(403, "来源不受信任");
|
|
231
|
+
if (path.startsWith("/desktop/")) {
|
|
232
|
+
if (!hasToken(request.headers.authorization?.replace(/^Bearer /, "")) ||
|
|
233
|
+
!request.headers.authorization?.startsWith("Bearer ")) throw new HttpError(401, "桌面连接已过期,请重新打开工作台");
|
|
234
|
+
// Same-origin GET fetches omit Origin. Mutations always send it in WebKit.
|
|
235
|
+
if (!["GET", "HEAD"].includes(request.method ?? "") && origin !== workspaceOrigin) throw new HttpError(403, "来源不受信任");
|
|
236
|
+
if (request.method === "GET" && path === "/desktop/bootstrap") {
|
|
237
|
+
json(response, 200, { ...state.snapshot(), localComputer: await localComputer(hostSocket, upstreams) }); return;
|
|
238
|
+
}
|
|
239
|
+
if (request.method === "GET" && path === "/desktop/local") {
|
|
240
|
+
json(response, 200, { computer: await localComputer(hostSocket, upstreams) }); return;
|
|
241
|
+
}
|
|
242
|
+
if (request.method === "GET" && path === "/desktop/account") {
|
|
243
|
+
json(response, 200, await account(accountAbort.signal)); return;
|
|
244
|
+
}
|
|
245
|
+
if (request.method === "PUT" && path === "/desktop/storage") {
|
|
246
|
+
const input = await body(request);
|
|
247
|
+
if (!validStorageKey(input.key) || !Object.hasOwn(input, "value")) throw new HttpError(400, "无效的偏好内容");
|
|
248
|
+
try { await state.set(input.key, input.value); }
|
|
249
|
+
catch { throw new HttpError(507, "桌面偏好保存失败,请检查可用空间或缩短内容"); }
|
|
250
|
+
json(response, 200, { ok: true }); return;
|
|
251
|
+
}
|
|
252
|
+
if (request.method === "POST" && path === "/desktop/login") {
|
|
253
|
+
if (!login) {
|
|
254
|
+
loginAbort = new AbortController();
|
|
255
|
+
login = runLogin({ signal: loginAbort.signal }).finally(() => { login = undefined; loginAbort = undefined; });
|
|
256
|
+
}
|
|
257
|
+
const result = await login;
|
|
258
|
+
if (!result?.success) throw new HttpError(409, "登录未完成,请重试");
|
|
259
|
+
json(response, 200, await account(accountAbort.signal)); return;
|
|
260
|
+
}
|
|
261
|
+
if (request.method === "POST" && path === "/desktop/logout") {
|
|
262
|
+
loginAbort?.abort();
|
|
263
|
+
await login;
|
|
264
|
+
await runLogout();
|
|
265
|
+
json(response, 200, {}); return;
|
|
266
|
+
}
|
|
267
|
+
throw new HttpError(404, "未找到操作");
|
|
268
|
+
}
|
|
269
|
+
// Never install a preview service worker in the identity-bearing workspace origin.
|
|
270
|
+
if (path.startsWith("/preview/")) throw new HttpError(404, "未找到资源");
|
|
271
|
+
}
|
|
272
|
+
if (!["GET", "HEAD"].includes(request.method ?? "")) throw new HttpError(405, "不支持此请求方法");
|
|
273
|
+
const assetPath = path === "/" ? "/index.html" : path;
|
|
274
|
+
const content = path === "/config.js" ? Buffer.from(`window.__LINKSHELL_CONFIG__ = ${JSON.stringify({ desktop: true, deployment: "official", previewOrigin })};\n`) : assets.get(assetPath);
|
|
275
|
+
if (!content) throw new HttpError(404, "未找到资源");
|
|
276
|
+
response.writeHead(200, {
|
|
277
|
+
"content-type": MIME[extname(assetPath)] ?? "application/octet-stream", "content-length": content.byteLength,
|
|
278
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
|
|
279
|
+
...(host === workspaceHost ? { "x-frame-options": "DENY", "content-security-policy": "frame-ancestors 'none'" } : {}),
|
|
280
|
+
});
|
|
281
|
+
response.end(request.method === "HEAD" ? undefined : content);
|
|
282
|
+
};
|
|
283
|
+
const server = createServer((request, response) => {
|
|
284
|
+
void serve(request, response).catch((error: unknown) => {
|
|
285
|
+
json(response, error instanceof HttpError ? error.status : 500,
|
|
286
|
+
{ error: error instanceof HttpError ? error.message : "桌面工作台暂时无法完成操作,请重试" });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
server.requestTimeout = 6 * 60_000;
|
|
290
|
+
server.headersTimeout = 10_000;
|
|
291
|
+
server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
292
|
+
server.on("upgrade", (request, socket, head) => {
|
|
293
|
+
let url: URL;
|
|
294
|
+
try { url = new URL(request.url ?? "", workspaceOrigin); }
|
|
295
|
+
catch { socket.destroy(); return; }
|
|
296
|
+
if (request.headers.host !== workspaceHost || request.headers.origin !== workspaceOrigin ||
|
|
297
|
+
!request.url?.startsWith("/") || request.url.startsWith("//") || url.pathname !== "/desktop/host" ||
|
|
298
|
+
!hasToken(url.searchParams.get("token") ?? undefined)) {
|
|
299
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); return;
|
|
300
|
+
}
|
|
301
|
+
peers.handleUpgrade(request, socket, head, (client) => proxyHost(client, hostSocket, upstreams));
|
|
302
|
+
});
|
|
303
|
+
await new Promise<void>((resolve, reject) => {
|
|
304
|
+
server.once("error", reject);
|
|
305
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
306
|
+
});
|
|
307
|
+
const address = server.address();
|
|
308
|
+
if (!address || typeof address === "string") throw new Error("桌面工作台无法启动");
|
|
309
|
+
workspaceHost = `127.0.0.1:${address.port}`;
|
|
310
|
+
previewHost = `localhost:${address.port}`;
|
|
311
|
+
workspaceOrigin = `http://${workspaceHost}`;
|
|
312
|
+
previewOrigin = `http://${previewHost}`;
|
|
313
|
+
return {
|
|
314
|
+
url: `${workspaceOrigin}/#desktop=${capability}`,
|
|
315
|
+
stop() {
|
|
316
|
+
return stopping ??= (async () => {
|
|
317
|
+
accountAbort.abort();
|
|
318
|
+
loginAbort?.abort();
|
|
319
|
+
for (const client of peers.clients) client.terminate();
|
|
320
|
+
for (const upstream of upstreams) upstream.terminate();
|
|
321
|
+
for (const socket of sockets) socket.destroy();
|
|
322
|
+
await Promise.all([
|
|
323
|
+
new Promise<void>((resolve) => server.close(() => resolve())),
|
|
324
|
+
new Promise<void>((resolve) => peers.close(() => resolve())),
|
|
325
|
+
state.flush(),
|
|
326
|
+
]);
|
|
327
|
+
})();
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function runDesktopWorkspace(assets: string, input: Readable = process.stdin): Promise<void> {
|
|
333
|
+
let close!: () => void;
|
|
334
|
+
const ended = new Promise<void>((resolve) => { close = resolve; });
|
|
335
|
+
input.once("end", close);
|
|
336
|
+
input.once("close", close);
|
|
337
|
+
input.once("error", close);
|
|
338
|
+
process.once("SIGINT", close);
|
|
339
|
+
process.once("SIGTERM", close);
|
|
340
|
+
input.resume();
|
|
341
|
+
if (input.readableEnded || input.destroyed) close();
|
|
342
|
+
let workspace: DesktopWorkspace | undefined;
|
|
343
|
+
try {
|
|
344
|
+
workspace = await startDesktopWorkspace({ assets });
|
|
345
|
+
process.stdout.write(`${JSON.stringify({ url: workspace.url })}\n`);
|
|
346
|
+
await ended;
|
|
347
|
+
} finally {
|
|
348
|
+
input.off("end", close); input.off("close", close); input.off("error", close);
|
|
349
|
+
input.pause();
|
|
350
|
+
process.off("SIGINT", close); process.off("SIGTERM", close);
|
|
351
|
+
await workspace?.stop();
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/commands/desktop.ts
CHANGED
|
@@ -10,17 +10,22 @@ import { prepareDesktopUpdate, recoverDesktopUpdate } from "./desktop-update.js"
|
|
|
10
10
|
import { sep } from "node:path";
|
|
11
11
|
import { ensureHostRunning, silenceSqliteWarning, withRunningHost } from "./host.js";
|
|
12
12
|
import { linkshellDir, readHostLaunch, readHostOwner, readPid, stopDaemonAndWait } from "../utils/daemon.js";
|
|
13
|
+
import { cachedMembership, redeemDesktopPro, refreshMembership } from "../pro-membership.js";
|
|
13
14
|
|
|
14
15
|
export function installationSource(path: string): "homebrew" | "npm" {
|
|
15
16
|
return path.includes(`${sep}Cellar${sep}linkshell${sep}`) ? "homebrew" : "npm";
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function desktopAccount() {
|
|
20
|
+
const auth = loadAuth();
|
|
21
|
+
return auth?.userId ? { state: "signed-in", email: auth.email, userId: auth.userId, ...cachedMembership(auth.userId) } : { state: "signed-out" };
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/** A versioned, read-only snapshot. Missing sections stay unknown, never healthy. */
|
|
19
25
|
export async function desktopSnapshot(version: string) {
|
|
20
26
|
let executable = process.argv[1]!;
|
|
21
27
|
try { executable = realpathSync(executable); } catch { /* Retain the launch path for diagnosis. */ }
|
|
22
|
-
const
|
|
23
|
-
const account = auth?.userId ? { state: "signed-in", email: auth.email, userId: auth.userId } : { state: "signed-out" };
|
|
28
|
+
const account = desktopAccount();
|
|
24
29
|
const base = { account, schema: 1, cliVersion: version, home: linkshellDir(), source: standaloneBundle() ? "standalone" : installationSource(executable), executable, hostOwner: readPid("host") ? readHostOwner(readPid("host")!) ?? "external" : "none", appBundle: standaloneBundle() };
|
|
25
30
|
const result = await withRunningHost(async (client) => {
|
|
26
31
|
const machine = await client.call("machine.info", {}, 5000);
|
|
@@ -33,10 +38,49 @@ export async function desktopSnapshot(version: string) {
|
|
|
33
38
|
return result ? { ...base, status: "running", ...result } : { ...base, status: readPid("host") ? "unreachable" : "stopped" };
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
async function redemptionInput(input: AsyncIterable<string | Buffer>): Promise<{ code: string; userId: string }> {
|
|
42
|
+
const chunks: Buffer[] = [];
|
|
43
|
+
let length = 0;
|
|
44
|
+
for await (const chunk of input) {
|
|
45
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
46
|
+
length += buffer.length;
|
|
47
|
+
if (length > 4096) throw new Error("激活码内容过长,请检查后重试");
|
|
48
|
+
chunks.push(buffer);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
52
|
+
if (typeof value?.code === "string" && typeof value?.userId === "string" && value.userId.trim()) return { code: value.code, userId: value.userId };
|
|
53
|
+
} catch { /* Never echo an input that contains the activation code. */ }
|
|
54
|
+
throw new Error("激活码请求无效,请刷新账号后重试");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runDesktop(version: string, action: string, input: AsyncIterable<string | Buffer> = process.stdin): Promise<void> {
|
|
37
58
|
silenceSqliteWarning();
|
|
38
59
|
try {
|
|
39
|
-
if (action === "
|
|
60
|
+
if (action === "redeem-pro") {
|
|
61
|
+
const { code, userId } = await redemptionInput(input);
|
|
62
|
+
const result = await redeemDesktopPro(code, userId);
|
|
63
|
+
if (loadAuth()?.userId === userId) {
|
|
64
|
+
try {
|
|
65
|
+
const gateway = await refreshGateway(15_000);
|
|
66
|
+
if (gateway && gateway.status !== "online" && gateway.status !== "off") result.accountWarning ??= "兑换已成功,网关尚未连接,请稍后重新连接";
|
|
67
|
+
} catch {
|
|
68
|
+
result.accountWarning ??= "兑换已成功,本机服务暂未刷新,请稍后重新连接网关";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// A host query failing cannot turn a confirmed redemption into a failed redemption.
|
|
72
|
+
const snapshot = await desktopSnapshot(version).catch(() => {
|
|
73
|
+
result.accountWarning ??= "兑换已成功,本机服务状态暂时无法读取,请稍后刷新";
|
|
74
|
+
return { schema: 1, cliVersion: version, status: "unknown", account: desktopAccount() };
|
|
75
|
+
});
|
|
76
|
+
snapshot.account = desktopAccount();
|
|
77
|
+
if (snapshot.account.state === "signed-in" && "userId" in snapshot.account && snapshot.account.userId === userId) Object.assign(snapshot.account, { plan: "pro", planExpiresAt: result.redemption.planExpiresAt });
|
|
78
|
+
else result.accountWarning = "兑换已成功,登录账号已变更,请切回兑换时的账号查看会员状态";
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ ...snapshot, ...result })}\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (action === "account-refresh") await refreshMembership();
|
|
83
|
+
else if (action === "pair") {
|
|
40
84
|
const result = await withRunningHost(async client => {
|
|
41
85
|
const gateway = await client.call("gateway.status", {});
|
|
42
86
|
if (gateway.status !== "online") throw new Error("请先连接网关。官方网关需要登录 Pro 账号;自建网关可以免登录配对。");
|
|
@@ -77,7 +121,7 @@ export async function runDesktop(version: string, action: string): Promise<void>
|
|
|
77
121
|
const env = { ...process.env, ...(pid ? readHostLaunch(pid) : {}) };
|
|
78
122
|
await stopDaemonAndWait("host", 30_000, pid ?? undefined);
|
|
79
123
|
if (action === "restart") await ensureHostRunning(env);
|
|
80
|
-
} else if (action !== "snapshot") throw new Error("Unknown desktop action");
|
|
124
|
+
} else if (action !== "snapshot" && action !== "account-refresh") throw new Error("Unknown desktop action");
|
|
81
125
|
process.stdout.write(`${JSON.stringify(await desktopSnapshot(version))}\n`);
|
|
82
126
|
} catch (error) {
|
|
83
127
|
process.stdout.write(`${JSON.stringify({ schema: 1, status: "unknown", error: error instanceof Error ? error.message : String(error) })}\n`);
|
package/src/commands/login.ts
CHANGED
|
@@ -2,6 +2,8 @@ import * as http from "node:http";
|
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
3
|
import { saveAuth, SUPABASE_URL, SUPABASE_ANON_KEY } from "../auth.js";
|
|
4
4
|
import type { AuthTokens } from "../auth.js";
|
|
5
|
+
import { cacheMembership, type DesktopMembership } from "../pro-membership.js";
|
|
6
|
+
import { fetchProMembership } from "../../../client-core/src/pro-membership.js";
|
|
5
7
|
|
|
6
8
|
const ITOOL_AUTH_URL = "https://itool.tech/en/auth/linkshell";
|
|
7
9
|
|
|
@@ -22,12 +24,27 @@ export interface LoginResult {
|
|
|
22
24
|
accessToken: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export async function runLogin(): Promise<LoginResult | null> {
|
|
27
|
+
export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<LoginResult | null> {
|
|
28
|
+
if (options.signal?.aborted) return null;
|
|
26
29
|
process.stderr.write("\n LinkShell Login\n\n");
|
|
27
30
|
|
|
28
31
|
return new Promise<LoginResult | null>((resolve) => {
|
|
29
32
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
let completion: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
let finished = false;
|
|
35
|
+
const finish = (result: LoginResult | null) => {
|
|
36
|
+
if (finished) return;
|
|
37
|
+
finished = true;
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
clearTimeout(completion);
|
|
40
|
+
options.signal?.removeEventListener("abort", abort);
|
|
41
|
+
server.closeAllConnections();
|
|
42
|
+
server.close();
|
|
43
|
+
resolve(result);
|
|
44
|
+
};
|
|
45
|
+
const abort = () => finish(null);
|
|
30
46
|
const server = http.createServer(async (req, res) => {
|
|
47
|
+
if (finished) { res.destroy(); return; }
|
|
31
48
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
32
49
|
|
|
33
50
|
if (url.pathname === "/callback") {
|
|
@@ -76,13 +93,14 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
76
93
|
let userId = "";
|
|
77
94
|
let email = "";
|
|
78
95
|
let plan = "free";
|
|
96
|
+
let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
|
|
79
97
|
try {
|
|
80
98
|
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
81
99
|
headers: {
|
|
82
100
|
Authorization: `Bearer ${accessToken}`,
|
|
83
101
|
apikey: SUPABASE_ANON_KEY,
|
|
84
102
|
},
|
|
85
|
-
signal: AbortSignal.timeout(10_000),
|
|
103
|
+
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
86
104
|
});
|
|
87
105
|
if (userRes.ok) {
|
|
88
106
|
const user = (await userRes.json()) as {
|
|
@@ -92,34 +110,14 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
92
110
|
userId = user.id;
|
|
93
111
|
email = user.email ?? "";
|
|
94
112
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
headers: {
|
|
100
|
-
Authorization: `Bearer ${accessToken}`,
|
|
101
|
-
apikey: SUPABASE_ANON_KEY,
|
|
102
|
-
},
|
|
103
|
-
signal: AbortSignal.timeout(5_000),
|
|
104
|
-
},
|
|
105
|
-
);
|
|
106
|
-
if (profileRes.ok) {
|
|
107
|
-
const profiles = (await profileRes.json()) as {
|
|
108
|
-
plan: string;
|
|
109
|
-
plan_expires_at: string | null;
|
|
110
|
-
}[];
|
|
111
|
-
if (
|
|
112
|
-
profiles.length > 0 &&
|
|
113
|
-
profiles[0]!.plan === "pro" &&
|
|
114
|
-
profiles[0]!.plan_expires_at &&
|
|
115
|
-
new Date(profiles[0]!.plan_expires_at) > new Date()
|
|
116
|
-
) {
|
|
117
|
-
plan = "pro";
|
|
118
|
-
}
|
|
119
|
-
}
|
|
113
|
+
const profile = await fetchProMembership({ userId, accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY, signal: options.signal });
|
|
114
|
+
plan = profile.plan;
|
|
115
|
+
membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
|
|
120
116
|
}
|
|
121
117
|
} catch {}
|
|
122
118
|
|
|
119
|
+
if (finished || options.signal?.aborted) { res.destroy(); return; }
|
|
120
|
+
|
|
123
121
|
if (!userId) {
|
|
124
122
|
res.writeHead(401);
|
|
125
123
|
res.end("无法验证账号,请重新登录。");
|
|
@@ -134,6 +132,7 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
134
132
|
email,
|
|
135
133
|
};
|
|
136
134
|
saveAuth(tokens);
|
|
135
|
+
try { cacheMembership(userId, membership); } catch { /* A cache write cannot invalidate a completed login. */ }
|
|
137
136
|
|
|
138
137
|
res.writeHead(200);
|
|
139
138
|
res.end("ok");
|
|
@@ -145,9 +144,8 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
145
144
|
|
|
146
145
|
// Left pending, the five-minute limit would keep the command alive long after the login.
|
|
147
146
|
clearTimeout(timeout);
|
|
148
|
-
setTimeout(() => {
|
|
149
|
-
|
|
150
|
-
resolve({
|
|
147
|
+
completion = setTimeout(() => {
|
|
148
|
+
finish({
|
|
151
149
|
success: true,
|
|
152
150
|
plan: plan as "pro" | "free",
|
|
153
151
|
email,
|
|
@@ -162,15 +160,17 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
162
160
|
res.end("Not found");
|
|
163
161
|
});
|
|
164
162
|
|
|
165
|
-
server.on("error", () =>
|
|
163
|
+
server.on("error", () => finish(null));
|
|
164
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
166
165
|
|
|
167
166
|
server.listen(0, "127.0.0.1", () => {
|
|
167
|
+
if (finished) return;
|
|
168
168
|
const addr = server.address();
|
|
169
169
|
if (!addr || typeof addr === "string") {
|
|
170
170
|
process.stderr.write(
|
|
171
171
|
" \x1b[31m✗\x1b[0m Failed to start local server\n\n",
|
|
172
172
|
);
|
|
173
|
-
|
|
173
|
+
finish(null);
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -195,8 +195,7 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
195
195
|
// Timeout after 5 minutes
|
|
196
196
|
timeout = setTimeout(() => {
|
|
197
197
|
process.stderr.write(" Login timed out.\n\n");
|
|
198
|
-
|
|
199
|
-
resolve(null);
|
|
198
|
+
finish(null);
|
|
200
199
|
}, 5 * 60 * 1000);
|
|
201
200
|
});
|
|
202
201
|
});
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,22 @@ program.command("desktop", { hidden: true })
|
|
|
32
32
|
.option("--action <action>", "snapshot, start, stop or restart", "snapshot")
|
|
33
33
|
.action(async (options) => { const { runDesktop } = await import("./commands/desktop.js"); await runDesktop(pkg.version, options.action); });
|
|
34
34
|
|
|
35
|
+
program.command("desktop-workspace", { hidden: true })
|
|
36
|
+
.requiredOption("--assets <directory>", "Absolute path to the bundled workspace assets")
|
|
37
|
+
.action(async (options) => {
|
|
38
|
+
const { runDesktopWorkspace } = await import("./commands/desktop-workspace.js");
|
|
39
|
+
try {
|
|
40
|
+
await runDesktopWorkspace(options.assets);
|
|
41
|
+
// This owned command has flushed persistent writes and closed its servers. Legacy
|
|
42
|
+
// account/Host probes may still hold sockets; none should outlive the desktop UI.
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
process.stderr.write("桌面工作台无法启动,请检查安装资源与本地存储后重试。\n");
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
35
51
|
// ── host ────────────────────────────────────────────────────────────
|
|
36
52
|
|
|
37
53
|
const hostCmd = program
|