linkshell-cli 0.10.45 → 0.10.46
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.js +4 -0
- package/dist/cli/src/auth.js.map +1 -1
- 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.js +10 -17
- package/dist/cli/src/commands/login.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 +17 -0
- package/dist/client-core/src/pro-membership.js +107 -0
- package/dist/client-core/src/pro-membership.js.map +1 -0
- package/package.json +3 -3
- package/src/auth.ts +3 -0
- package/src/commands/desktop.ts +49 -5
- package/src/commands/login.ts +7 -25
- package/src/pro-membership.ts +82 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ProMembership {
|
|
2
|
+
plan: "free" | "pro";
|
|
3
|
+
expiresAt?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ProMembershipOptions {
|
|
6
|
+
supabaseUrl: string;
|
|
7
|
+
anonKey: string;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const PRO_CODE_SHOP_URL = "https://pay.ldxp.cn/item/7lgv1s";
|
|
12
|
+
/** The profile is read with the user's token; only the server can grant Pro. */
|
|
13
|
+
export declare function fetchProMembership(options: ProMembershipOptions): Promise<ProMembership>;
|
|
14
|
+
export declare function redeemProCode(accessToken: string, code: string): Promise<{
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
idempotent: boolean;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const PRO_CODE_SHOP_URL = "https://pay.ldxp.cn/item/7lgv1s";
|
|
2
|
+
const REDEEM_URL = "https://itool.tech/api/pro/redeem";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
|
+
const LOGIN_REQUIRED = "登录已失效,请重新登录后再试";
|
|
5
|
+
function record(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
function timestamp(value) {
|
|
9
|
+
return typeof value === "string" ? Date.parse(value) : NaN;
|
|
10
|
+
}
|
|
11
|
+
async function request(url, init) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(url, { ...init, credentials: "omit", signal: controller.signal });
|
|
16
|
+
let body;
|
|
17
|
+
try {
|
|
18
|
+
body = await response.json();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (controller.signal.aborted || !(error instanceof SyntaxError))
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return { response, body };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(controller.signal.aborted ? "请求超时,请稍后重试" : "网络连接失败,请检查网络后重试");
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The profile is read with the user's token; only the server can grant Pro. */
|
|
34
|
+
export async function fetchProMembership(options) {
|
|
35
|
+
if (!options.accessToken.trim() || !options.userId.trim())
|
|
36
|
+
throw new Error(LOGIN_REQUIRED);
|
|
37
|
+
const url = `${options.supabaseUrl.replace(/\/$/, "")}/rest/v1/profiles?select=plan,plan_expires_at&id=eq.${encodeURIComponent(options.userId)}&limit=1`;
|
|
38
|
+
const { response, body } = await request(url, {
|
|
39
|
+
headers: { apikey: options.anonKey, Authorization: `Bearer ${options.accessToken}` },
|
|
40
|
+
});
|
|
41
|
+
if (response.status === 401 || response.status === 403)
|
|
42
|
+
throw new Error(LOGIN_REQUIRED);
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error("暂时无法获取会员状态,请稍后重试");
|
|
45
|
+
if (!Array.isArray(body))
|
|
46
|
+
throw new Error("会员状态返回异常,请稍后重试");
|
|
47
|
+
if (body.length === 0)
|
|
48
|
+
return { plan: "free" };
|
|
49
|
+
const profile = record(body[0]);
|
|
50
|
+
if (!profile || typeof profile.plan !== "string")
|
|
51
|
+
throw new Error("会员状态返回异常,请稍后重试");
|
|
52
|
+
const expiresAt = profile.plan_expires_at;
|
|
53
|
+
const expires = timestamp(expiresAt);
|
|
54
|
+
const serverTime = timestamp(response.headers.get("date"));
|
|
55
|
+
const now = Number.isFinite(serverTime) ? serverTime : Date.now();
|
|
56
|
+
return profile.plan === "pro" && Number.isFinite(expires) && expires > now
|
|
57
|
+
? { plan: "pro", expiresAt: expiresAt }
|
|
58
|
+
: { plan: "free" };
|
|
59
|
+
}
|
|
60
|
+
const redeemErrors = {
|
|
61
|
+
malformed_request: "激活码格式不正确,请检查后重试",
|
|
62
|
+
login_required: LOGIN_REQUIRED,
|
|
63
|
+
request_too_large: "激活码内容过长,请检查后重试",
|
|
64
|
+
invalid_or_unavailable: "激活码无效或已不可用,请检查后重试",
|
|
65
|
+
rate_limited: "操作太频繁,请稍后重试",
|
|
66
|
+
temporarily_unavailable: "兑换服务暂时不可用,请稍后重试",
|
|
67
|
+
};
|
|
68
|
+
const redeemStatusCodes = {
|
|
69
|
+
400: "malformed_request",
|
|
70
|
+
401: "login_required",
|
|
71
|
+
413: "request_too_large",
|
|
72
|
+
422: "invalid_or_unavailable",
|
|
73
|
+
429: "rate_limited",
|
|
74
|
+
503: "temporarily_unavailable",
|
|
75
|
+
};
|
|
76
|
+
export async function redeemProCode(accessToken, code) {
|
|
77
|
+
if (!accessToken.trim())
|
|
78
|
+
throw new Error(LOGIN_REQUIRED);
|
|
79
|
+
const normalizedCode = code.trim();
|
|
80
|
+
if (!normalizedCode)
|
|
81
|
+
throw new Error("请输入激活码");
|
|
82
|
+
const { response, body: responseBody } = await request(REDEEM_URL, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ code: normalizedCode }),
|
|
86
|
+
});
|
|
87
|
+
const body = record(responseBody);
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const code = body?.code ?? record(body?.error)?.code;
|
|
90
|
+
const knownCode = typeof code === "string" && Object.hasOwn(redeemErrors, code) ? code : redeemStatusCodes[response.status];
|
|
91
|
+
throw new Error(knownCode ? redeemErrors[knownCode] : "兑换失败,请稍后重试");
|
|
92
|
+
}
|
|
93
|
+
const expires = timestamp(body?.plan_expires_at);
|
|
94
|
+
const serverTime = timestamp(body?.server_time);
|
|
95
|
+
if (body?.ok !== true || body.valid !== true || typeof body.idempotent !== "boolean" ||
|
|
96
|
+
typeof body.months !== "number" || !Number.isFinite(body.months) || body.months < 0 ||
|
|
97
|
+
!Number.isFinite(expires) || !Number.isFinite(serverTime)) {
|
|
98
|
+
throw new Error("兑换结果尚未确认,请刷新会员状态后重试");
|
|
99
|
+
}
|
|
100
|
+
if (expires <= serverTime) {
|
|
101
|
+
throw new Error(body.idempotent
|
|
102
|
+
? "此激活码已兑换且权益已到期,请使用新的激活码"
|
|
103
|
+
: "兑换结果尚未确认,请刷新会员状态后重试");
|
|
104
|
+
}
|
|
105
|
+
return { expiresAt: body.plan_expires_at, idempotent: body.idempotent };
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=pro-membership.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pro-membership.js","sourceRoot":"","sources":["../../../../client-core/src/pro-membership.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkshell-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.46",
|
|
4
4
|
"description": "Follow, steer and approve the coding agents on your computer (Claude Code, Codex, Gemini, Copilot and more) from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli/src/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"commander": "^13.1.0",
|
|
34
34
|
"node-pty": "^1.1.0",
|
|
35
35
|
"qrcode-terminal": "^0.12.0",
|
|
36
|
-
"@linkshell/
|
|
37
|
-
"@linkshell/
|
|
36
|
+
"@linkshell/gateway": "0.6.23",
|
|
37
|
+
"@linkshell/host": "0.4.44",
|
|
38
38
|
"@linkshell/wire": "0.2.16"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/auth.ts
CHANGED
|
@@ -91,6 +91,9 @@ export async function refreshAccessToken(): Promise<AuthTokens | null> {
|
|
|
91
91
|
userId: body.user.id,
|
|
92
92
|
email: body.user.email ?? auth.email,
|
|
93
93
|
};
|
|
94
|
+
// A login or logout may finish while the refresh is in flight.
|
|
95
|
+
const current = loadAuth();
|
|
96
|
+
if (tokens.userId !== auth.userId || current?.userId !== auth.userId || current.refreshToken !== auth.refreshToken) return null;
|
|
94
97
|
saveAuth(tokens);
|
|
95
98
|
return tokens;
|
|
96
99
|
} catch {
|
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
|
|
|
@@ -76,6 +78,7 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
76
78
|
let userId = "";
|
|
77
79
|
let email = "";
|
|
78
80
|
let plan = "free";
|
|
81
|
+
let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
|
|
79
82
|
try {
|
|
80
83
|
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
81
84
|
headers: {
|
|
@@ -92,31 +95,9 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
92
95
|
userId = user.id;
|
|
93
96
|
email = user.email ?? "";
|
|
94
97
|
|
|
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
|
-
}
|
|
98
|
+
const profile = await fetchProMembership({ userId, accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
|
|
99
|
+
plan = profile.plan;
|
|
100
|
+
membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
|
|
120
101
|
}
|
|
121
102
|
} catch {}
|
|
122
103
|
|
|
@@ -134,6 +115,7 @@ export async function runLogin(): Promise<LoginResult | null> {
|
|
|
134
115
|
email,
|
|
135
116
|
};
|
|
136
117
|
saveAuth(tokens);
|
|
118
|
+
try { cacheMembership(userId, membership); } catch { /* A cache write cannot invalidate a completed login. */ }
|
|
137
119
|
|
|
138
120
|
res.writeHead(200);
|
|
139
121
|
res.end("ok");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fetchProMembership, redeemProCode } from "../../client-core/src/pro-membership.js";
|
|
4
|
+
import { getValidToken, loadAuth, SUPABASE_ANON_KEY, SUPABASE_URL } from "./auth.js";
|
|
5
|
+
import { linkshellDir } from "./utils/daemon.js";
|
|
6
|
+
|
|
7
|
+
export interface DesktopMembership {
|
|
8
|
+
plan: "pro" | "free" | "unknown";
|
|
9
|
+
planExpiresAt: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const unknownMembership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
|
|
13
|
+
const changedAccount = "登录账号已变更,请刷新账号后重试";
|
|
14
|
+
const cachePath = () => join(linkshellDir(), "account-pro.json");
|
|
15
|
+
|
|
16
|
+
/** Snapshot polling stays offline; the dashboard refreshes this account-scoped cache separately. */
|
|
17
|
+
export function cachedMembership(userId: string): DesktopMembership {
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
20
|
+
if (value.userId !== userId || !["pro", "free", "unknown"].includes(value.plan)) return { ...unknownMembership };
|
|
21
|
+
const expiry = typeof value.planExpiresAt === "string" && Number.isFinite(Date.parse(value.planExpiresAt)) ? value.planExpiresAt : null;
|
|
22
|
+
if (value.plan === "pro" && !expiry) return { ...unknownMembership };
|
|
23
|
+
return { plan: value.plan === "pro" ? expiry && Date.parse(expiry) > Date.now() ? "pro" : "free" : value.plan, planExpiresAt: expiry };
|
|
24
|
+
} catch {
|
|
25
|
+
return { ...unknownMembership };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cacheMembership(userId: string, membership: DesktopMembership, observedAt = Date.now()): void {
|
|
30
|
+
try {
|
|
31
|
+
const previous = JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
32
|
+
// A profile request begun before redemption cannot replace its confirmed result.
|
|
33
|
+
if (previous.userId === userId && previous.observedAt > observedAt) return;
|
|
34
|
+
} catch { /* There may not be a prior account observation. */ }
|
|
35
|
+
mkdirSync(linkshellDir(), { recursive: true });
|
|
36
|
+
writeFileSync(cachePath(), JSON.stringify({ userId, ...membership, observedAt }) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function accountToken(expectedUserId?: string): Promise<{ userId: string; accessToken: string }> {
|
|
40
|
+
const auth = loadAuth();
|
|
41
|
+
if (!auth?.userId) throw new Error("请先登录账号");
|
|
42
|
+
if (expectedUserId !== undefined && auth.userId !== expectedUserId) throw new Error(changedAccount);
|
|
43
|
+
const accessToken = await getValidToken();
|
|
44
|
+
const current = loadAuth();
|
|
45
|
+
if (current?.userId !== auth.userId) throw new Error(changedAccount);
|
|
46
|
+
if (!accessToken || current.accessToken !== accessToken) throw new Error("登录已失效,请重新登录后再试");
|
|
47
|
+
return { userId: auth.userId, accessToken };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function refreshMembership(): Promise<DesktopMembership> {
|
|
51
|
+
const userId = loadAuth()?.userId;
|
|
52
|
+
const observedAt = Date.now();
|
|
53
|
+
try {
|
|
54
|
+
const account = await accountToken(userId);
|
|
55
|
+
const membership = await fetchProMembership({ ...account, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
|
|
56
|
+
if (loadAuth()?.userId !== account.userId) throw new Error(changedAccount);
|
|
57
|
+
const result: DesktopMembership = { plan: membership.plan, planExpiresAt: membership.expiresAt ?? null };
|
|
58
|
+
cacheMembership(account.userId, result, observedAt);
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (userId && loadAuth()?.userId === userId) {
|
|
62
|
+
try { cacheMembership(userId, unknownMembership, observedAt); } catch { /* Preserve the request's error. */ }
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function redeemDesktopPro(code: string, userId: string) {
|
|
69
|
+
if (!code.trim()) throw new Error("请输入激活码");
|
|
70
|
+
if (Buffer.byteLength(code, "utf8") > 512) throw new Error("激活码内容过长,请检查后重试");
|
|
71
|
+
const account = await accountToken(userId);
|
|
72
|
+
const redeemed = await redeemProCode(account.accessToken, code);
|
|
73
|
+
const redemption = { userId, idempotent: redeemed.idempotent, planExpiresAt: redeemed.expiresAt };
|
|
74
|
+
let accountWarning: string | undefined;
|
|
75
|
+
try {
|
|
76
|
+
if (loadAuth()?.userId === userId) cacheMembership(userId, { plan: "pro", planExpiresAt: redeemed.expiresAt });
|
|
77
|
+
} catch {
|
|
78
|
+
accountWarning = "兑换已成功,会员状态暂未保存,请刷新会员状态";
|
|
79
|
+
}
|
|
80
|
+
if (loadAuth()?.userId !== userId) accountWarning = "兑换已成功,登录账号已变更,请切回兑换时的账号查看会员状态";
|
|
81
|
+
return { redemption, accountWarning };
|
|
82
|
+
}
|