dsh-plugin-subscriptions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +51 -0
- package/lib/auth/rpc.js +83 -0
- package/lib/auth/store.d.ts +90 -0
- package/lib/auth/store.js +137 -0
- package/lib/client/SubscriptionsSection.d.ts +30 -0
- package/lib/client/SubscriptionsSection.js +290 -0
- package/lib/client/index.d.ts +31 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.d.ts +45 -0
- package/lib/client/locales.js +43 -0
- package/lib/client.js +546 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +2932 -0
- package/lib/providers/claude.d.ts +60 -0
- package/lib/providers/claude.js +243 -0
- package/lib/providers/codex.d.ts +96 -0
- package/lib/providers/codex.js +391 -0
- package/lib/providers/common.d.ts +185 -0
- package/lib/providers/common.js +302 -0
- package/lib/providers/grok.d.ts +90 -0
- package/lib/providers/grok.js +337 -0
- package/lib/tools/image-generate.d.ts +60 -0
- package/lib/tools/image-generate.js +142 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +195 -0
- package/lib/translate/anthropic.d.ts +120 -0
- package/lib/translate/anthropic.js +370 -0
- package/lib/translate/resolved.d.ts +35 -0
- package/lib/translate/resolved.js +40 -0
- package/lib/translate/responses.d.ts +127 -0
- package/lib/translate/responses.js +352 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +83 -0
package/lib/auth/rpc.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/subscriptions-auth` host RPC channel the web Settings page drives. The
|
|
3
|
+
* channel is registered only when a host `connection` service exists (the web
|
|
4
|
+
* profile); headless compositions load the plugin without it. All business
|
|
5
|
+
* outcomes are returned as RpcResult values; handlers never throw.
|
|
6
|
+
*/
|
|
7
|
+
import { PROVIDER_IDS } from './store.js';
|
|
8
|
+
/** The RPC channel this plugin registers on the host connection. */
|
|
9
|
+
export const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
|
|
10
|
+
/** Payload carried no usable provider id — an RPC client bug, not a server failure. */
|
|
11
|
+
class BadRequest extends Error {
|
|
12
|
+
}
|
|
13
|
+
function ok(value) {
|
|
14
|
+
return { ok: true, value };
|
|
15
|
+
}
|
|
16
|
+
function failure(error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
if (error instanceof BadRequest) {
|
|
19
|
+
// The issues array is zod-shaped upstream; this channel validates by hand.
|
|
20
|
+
return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } };
|
|
21
|
+
}
|
|
22
|
+
return { ok: false, error: { code: 'internal', message, details: {} } };
|
|
23
|
+
}
|
|
24
|
+
function readProvider(payload) {
|
|
25
|
+
if (typeof payload !== 'object' || payload === null)
|
|
26
|
+
throw new BadRequest('payload must be an object');
|
|
27
|
+
const provider = payload.provider;
|
|
28
|
+
if (typeof provider !== 'string' || !PROVIDER_IDS.includes(provider)) {
|
|
29
|
+
throw new BadRequest(`payload.provider must be one of ${PROVIDER_IDS.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
return provider;
|
|
32
|
+
}
|
|
33
|
+
function readString(payload, field) {
|
|
34
|
+
const value = payload[field];
|
|
35
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
36
|
+
throw new BadRequest(`payload.${field} must be a non-empty string`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
async function dispatch(controller, endpoint, payload) {
|
|
41
|
+
switch (endpoint) {
|
|
42
|
+
case 'status': {
|
|
43
|
+
const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
|
|
44
|
+
return ok({ providers: Object.fromEntries(entries) });
|
|
45
|
+
}
|
|
46
|
+
case 'login':
|
|
47
|
+
return ok(await controller.login(readProvider(payload)));
|
|
48
|
+
case 'manual': {
|
|
49
|
+
const provider = readProvider(payload);
|
|
50
|
+
await controller.manual(provider, readString(payload, 'input'));
|
|
51
|
+
return ok({ ok: true });
|
|
52
|
+
}
|
|
53
|
+
case 'cancel':
|
|
54
|
+
await controller.cancel(readProvider(payload));
|
|
55
|
+
return ok({ ok: true });
|
|
56
|
+
case 'logout':
|
|
57
|
+
await controller.logout(readProvider(payload));
|
|
58
|
+
return ok({ ok: true });
|
|
59
|
+
default:
|
|
60
|
+
throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Register the `/subscriptions-auth` RPC channel when a host connection exists.
|
|
65
|
+
* @param ctx - the plugin context (headless profiles have no `connection`).
|
|
66
|
+
* @param controller - the auth operations backing the endpoints.
|
|
67
|
+
*/
|
|
68
|
+
export function registerAuthRpc(ctx, controller) {
|
|
69
|
+
// `connection` is not in this plugin's inject list (headless compositions
|
|
70
|
+
// lack it), so its startup order is unconstrained: defer registration until
|
|
71
|
+
// the service exists instead of probing once at apply time.
|
|
72
|
+
ctx.inject(['connection'], (ctx) => {
|
|
73
|
+
const connection = ctx.get('connection');
|
|
74
|
+
ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload) => {
|
|
75
|
+
try {
|
|
76
|
+
return await dispatch(controller, endpoint, payload);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return failure(error);
|
|
80
|
+
}
|
|
81
|
+
}, { authority: 'loopback' }), 'dsh-plugin-subscriptions: /subscriptions-auth rpc channel');
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
|
|
3
|
+
*
|
|
4
|
+
* The file is a JSON object keyed by provider id. Writes are atomic
|
|
5
|
+
* (tmp file + rename) with mode 0600 because they carry bearer tokens.
|
|
6
|
+
* Session shapes live here (not in the provider modules) because this file
|
|
7
|
+
* owns the durable format.
|
|
8
|
+
*/
|
|
9
|
+
/** Provider routes this plugin can serve. */
|
|
10
|
+
export type ProviderId = 'codex' | 'claude' | 'grok';
|
|
11
|
+
/** Every provider route, in display order. */
|
|
12
|
+
export declare const PROVIDER_IDS: readonly ProviderId[];
|
|
13
|
+
/** Stored ChatGPT/Codex subscription session. */
|
|
14
|
+
export interface CodexSession {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
refreshToken: string;
|
|
17
|
+
/** Epoch milliseconds at which the access token expires. */
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
/** `chatgpt_account_id` claim from the id token; sent as the `chatgpt-account-id` header. */
|
|
20
|
+
accountId: string;
|
|
21
|
+
idToken?: string;
|
|
22
|
+
/** User email from the id token, when the token carried it. */
|
|
23
|
+
emailAddress?: string;
|
|
24
|
+
/** `chatgpt_plan_type` claim from the id token (e.g. `plus`, `pro`), when present. */
|
|
25
|
+
planType?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Stored Claude Pro/Max subscription session. */
|
|
28
|
+
export interface ClaudeSession {
|
|
29
|
+
accessToken: string;
|
|
30
|
+
refreshToken: string;
|
|
31
|
+
/** Epoch milliseconds at which the access token expires. */
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
/** Scope string the tokens were issued with; echoed on refresh. */
|
|
34
|
+
scopes: string;
|
|
35
|
+
emailAddress?: string;
|
|
36
|
+
subscriptionType?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Stored Grok (X Premium / xAI) subscription session. */
|
|
39
|
+
export interface GrokSession {
|
|
40
|
+
accessToken: string;
|
|
41
|
+
refreshToken: string;
|
|
42
|
+
/** Epoch milliseconds at which the access token expires. */
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
/** Token endpoint from OIDC discovery; retained for refreshes. */
|
|
45
|
+
tokenEndpoint: string;
|
|
46
|
+
scopes?: string;
|
|
47
|
+
/** Display account: email, username, or subject claim from the id token. */
|
|
48
|
+
account?: string;
|
|
49
|
+
}
|
|
50
|
+
/** The durable store shape: one optional session per provider. */
|
|
51
|
+
export interface SessionMap {
|
|
52
|
+
codex?: CodexSession;
|
|
53
|
+
claude?: ClaudeSession;
|
|
54
|
+
grok?: GrokSession;
|
|
55
|
+
}
|
|
56
|
+
/** Any stored session, for provider-agnostic plumbing. */
|
|
57
|
+
export type StoredSession = CodexSession | ClaudeSession | GrokSession;
|
|
58
|
+
/**
|
|
59
|
+
* Absolute path of the auth store file.
|
|
60
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function authFilePath(): string;
|
|
63
|
+
/**
|
|
64
|
+
* Read the whole store. A missing file is an empty store; malformed JSON or a
|
|
65
|
+
* malformed entry throws, because silently discarding tokens would strand the
|
|
66
|
+
* user without a diagnosis.
|
|
67
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
68
|
+
* @returns the parsed session map.
|
|
69
|
+
*/
|
|
70
|
+
export declare function loadStore(path?: string): Promise<SessionMap>;
|
|
71
|
+
/**
|
|
72
|
+
* Read one provider's session.
|
|
73
|
+
* @param provider - the provider route.
|
|
74
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
75
|
+
* @returns the stored session, or `undefined` when logged out.
|
|
76
|
+
*/
|
|
77
|
+
export declare function getSession<K extends ProviderId>(provider: K, path?: string): Promise<SessionMap[K] | undefined>;
|
|
78
|
+
/**
|
|
79
|
+
* Write one provider's session, preserving the others.
|
|
80
|
+
* @param provider - the provider route.
|
|
81
|
+
* @param session - the fresh session from a login or refresh.
|
|
82
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
83
|
+
*/
|
|
84
|
+
export declare function saveSession<K extends ProviderId>(provider: K, session: NonNullable<SessionMap[K]>, path?: string): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Delete one provider's session (logout).
|
|
87
|
+
* @param provider - the provider route.
|
|
88
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
89
|
+
*/
|
|
90
|
+
export declare function deleteSession(provider: ProviderId, path?: string): Promise<void>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
|
|
3
|
+
*
|
|
4
|
+
* The file is a JSON object keyed by provider id. Writes are atomic
|
|
5
|
+
* (tmp file + rename) with mode 0600 because they carry bearer tokens.
|
|
6
|
+
* Session shapes live here (not in the provider modules) because this file
|
|
7
|
+
* owns the durable format.
|
|
8
|
+
*/
|
|
9
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
12
|
+
/** Every provider route, in display order. */
|
|
13
|
+
export const PROVIDER_IDS = ['codex', 'claude', 'grok'];
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path of the auth store file.
|
|
16
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
17
|
+
*/
|
|
18
|
+
export function authFilePath() {
|
|
19
|
+
return dshHomePath('plugins', 'subscriptions', 'auth.json');
|
|
20
|
+
}
|
|
21
|
+
/** Store location used before the plugin was renamed; migrated on first read. */
|
|
22
|
+
function legacyAuthFilePath() {
|
|
23
|
+
return dshHomePath('plugins', 'router', 'auth.json');
|
|
24
|
+
}
|
|
25
|
+
/** Check that one durable entry carries the fields every session needs. */
|
|
26
|
+
function assertSessionShape(provider, value) {
|
|
27
|
+
if (typeof value !== 'object' || value === null) {
|
|
28
|
+
throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
|
|
29
|
+
}
|
|
30
|
+
const entry = value;
|
|
31
|
+
if (typeof entry.accessToken !== 'string' || entry.accessToken.length === 0
|
|
32
|
+
|| typeof entry.refreshToken !== 'string' || entry.refreshToken.length === 0
|
|
33
|
+
|| typeof entry.expiresAt !== 'number' || !Number.isFinite(entry.expiresAt)) {
|
|
34
|
+
throw new Error(`subscriptions auth store: entry "${provider}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the whole store. A missing file is an empty store; malformed JSON or a
|
|
39
|
+
* malformed entry throws, because silently discarding tokens would strand the
|
|
40
|
+
* user without a diagnosis.
|
|
41
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
42
|
+
* @returns the parsed session map.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadStore(path = authFilePath()) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = await readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== 'ENOENT')
|
|
51
|
+
throw error;
|
|
52
|
+
// Migrate the pre-rename store once, preserving existing logins.
|
|
53
|
+
if (path !== authFilePath())
|
|
54
|
+
return {};
|
|
55
|
+
try {
|
|
56
|
+
text = await readFile(legacyAuthFilePath(), 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch (legacyError) {
|
|
59
|
+
if (legacyError.code === 'ENOENT')
|
|
60
|
+
return {};
|
|
61
|
+
throw legacyError;
|
|
62
|
+
}
|
|
63
|
+
const migrated = parseStore(text, legacyAuthFilePath());
|
|
64
|
+
await writeStore(migrated, path);
|
|
65
|
+
await rm(legacyAuthFilePath(), { force: true });
|
|
66
|
+
return migrated;
|
|
67
|
+
}
|
|
68
|
+
return parseStore(text, path);
|
|
69
|
+
}
|
|
70
|
+
/** Parse and validate store JSON read from `path`. */
|
|
71
|
+
function parseStore(text, path) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
|
|
78
|
+
}
|
|
79
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
80
|
+
throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
|
|
81
|
+
}
|
|
82
|
+
const store = parsed;
|
|
83
|
+
for (const provider of PROVIDER_IDS) {
|
|
84
|
+
const entry = store[provider];
|
|
85
|
+
if (entry !== undefined)
|
|
86
|
+
assertSessionShape(provider, entry);
|
|
87
|
+
}
|
|
88
|
+
return store;
|
|
89
|
+
}
|
|
90
|
+
/** Persist the whole store atomically with owner-only permissions. */
|
|
91
|
+
async function writeStore(store, path) {
|
|
92
|
+
await mkdir(dirname(path), { recursive: true });
|
|
93
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
94
|
+
try {
|
|
95
|
+
await writeFile(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
96
|
+
// An existing destination keeps its old mode through rename on some
|
|
97
|
+
// filesystems; enforce 0600 on the source before the swap.
|
|
98
|
+
await chmod(tmp, 0o600);
|
|
99
|
+
await rename(tmp, path);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
await rm(tmp, { force: true });
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read one provider's session.
|
|
108
|
+
* @param provider - the provider route.
|
|
109
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
110
|
+
* @returns the stored session, or `undefined` when logged out.
|
|
111
|
+
*/
|
|
112
|
+
export async function getSession(provider, path = authFilePath()) {
|
|
113
|
+
return (await loadStore(path))[provider];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Write one provider's session, preserving the others.
|
|
117
|
+
* @param provider - the provider route.
|
|
118
|
+
* @param session - the fresh session from a login or refresh.
|
|
119
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
120
|
+
*/
|
|
121
|
+
export async function saveSession(provider, session, path = authFilePath()) {
|
|
122
|
+
const store = await loadStore(path);
|
|
123
|
+
store[provider] = session;
|
|
124
|
+
await writeStore(store, path);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Delete one provider's session (logout).
|
|
128
|
+
* @param provider - the provider route.
|
|
129
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
130
|
+
*/
|
|
131
|
+
export async function deleteSession(provider, path = authFilePath()) {
|
|
132
|
+
const store = await loadStore(path);
|
|
133
|
+
if (store[provider] === undefined)
|
|
134
|
+
return;
|
|
135
|
+
delete store[provider];
|
|
136
|
+
await writeStore(store, path);
|
|
137
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
|
+
import type { SubscriptionsKey } from './locales.js';
|
|
3
|
+
/** Subscription provider ids, fixed by the node half's OAuth adapters. */
|
|
4
|
+
export type SubscriptionProvider = 'codex' | 'claude' | 'grok';
|
|
5
|
+
/** One provider's login state as answered by the `status` endpoint. */
|
|
6
|
+
export interface ProviderStatus {
|
|
7
|
+
loggedIn: boolean;
|
|
8
|
+
busy: boolean;
|
|
9
|
+
expiresAt?: number;
|
|
10
|
+
account?: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Injected dependencies of {@link SubscriptionsSection} (slot `inject`). */
|
|
14
|
+
export interface SubscriptionsSectionInjected {
|
|
15
|
+
/** Generic logical-RPC caller over the Connection transport. */
|
|
16
|
+
rpc: ConnectionHandle['rpc'];
|
|
17
|
+
/** Section copy: translate a 'settings.subscriptions' key with `{name}` template params. */
|
|
18
|
+
t: (key: SubscriptionsKey, params?: Record<string, unknown>) => string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Props delivered by the slot outlet: the inject face spread flat (the
|
|
22
|
+
* renderer erases the share boundary at the render call).
|
|
23
|
+
*/
|
|
24
|
+
export type SubscriptionsSectionProps = Partial<SubscriptionsSectionInjected>;
|
|
25
|
+
/**
|
|
26
|
+
* The Subscriptions settings page component.
|
|
27
|
+
* @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
|
|
28
|
+
* @returns the section body, or a notice while the RPC face is absent.
|
|
29
|
+
*/
|
|
30
|
+
export declare function SubscriptionsSection(props: SubscriptionsSectionProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Subscriptions settings section: one card per subscription provider with an
|
|
4
|
+
* OAuth login/logout flow driven by the node half's `/subscriptions-auth` RPC
|
|
5
|
+
* channel. Login state lives server-side; the page polls `status` only while
|
|
6
|
+
* a login attempt is busy, so an idle page never polls. All state is local
|
|
7
|
+
* React state — the page has no store.
|
|
8
|
+
*
|
|
9
|
+
* Every color resolves through a `--dsw-alias-*` design token (the ui-theme
|
|
10
|
+
* design-platform.css values flip under `body[data-ds-dark-theme]`), and
|
|
11
|
+
* every user-visible string goes through the locale-bound `t` of the
|
|
12
|
+
* 'settings.subscriptions' namespace. Buttons and inputs take the
|
|
13
|
+
* ModelsSection vocabulary minus hover rules, which inline styles cannot
|
|
14
|
+
* express.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { en } from './locales.js';
|
|
18
|
+
/** Logical RPC channel served by the node half of this plugin. */
|
|
19
|
+
const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
|
|
20
|
+
/** Poll cadence while a provider login attempt is busy. */
|
|
21
|
+
const POLL_INTERVAL_MS = 2000;
|
|
22
|
+
/** Card display metadata, in page order (names are brand names, not translated). */
|
|
23
|
+
const PROVIDERS = [
|
|
24
|
+
{ id: 'codex', name: 'Codex (ChatGPT)' },
|
|
25
|
+
{ id: 'claude', name: 'Claude' },
|
|
26
|
+
{ id: 'grok', name: 'Grok (X Premium)' },
|
|
27
|
+
];
|
|
28
|
+
/** Business error returned by the `/subscriptions-auth` channel (error branch message). */
|
|
29
|
+
class SubscriptionsAuthError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Call one `/subscriptions-auth` endpoint and unwrap the business result.
|
|
33
|
+
* @param rpc - Connection RPC caller.
|
|
34
|
+
* @param endpoint - channel-relative endpoint.
|
|
35
|
+
* @param payload - channel-owned request payload.
|
|
36
|
+
* @returns the success value, cast by the caller to the endpoint's shape.
|
|
37
|
+
*/
|
|
38
|
+
async function callSubscriptionsAuth(rpc, endpoint, payload) {
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = await rpc.call(SUBSCRIPTIONS_AUTH_CHANNEL, endpoint, payload);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// The transport rejected rather than answering; surface the same way.
|
|
45
|
+
throw new SubscriptionsAuthError(error instanceof Error ? error.message : String(error));
|
|
46
|
+
}
|
|
47
|
+
if (!result.ok)
|
|
48
|
+
throw new SubscriptionsAuthError(result.error.message);
|
|
49
|
+
return result.value;
|
|
50
|
+
}
|
|
51
|
+
/** Human text of an action failure, SubscriptionsAuthError or not. */
|
|
52
|
+
function messageOf(error) {
|
|
53
|
+
return error instanceof Error ? error.message : String(error);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* English-dictionary fallback for a missing inject `t` (standalone renders);
|
|
57
|
+
* the slot inject always supplies the locale-bound one.
|
|
58
|
+
* @param key - dictionary key.
|
|
59
|
+
* @param params - `{name}` template params.
|
|
60
|
+
* @returns the template with params substituted.
|
|
61
|
+
*/
|
|
62
|
+
function fallbackTranslate(key, params) {
|
|
63
|
+
let text = en[key];
|
|
64
|
+
for (const [name, value] of Object.entries(params ?? {})) {
|
|
65
|
+
text = text.replaceAll(`{${name}}`, String(value));
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
const styles = {
|
|
70
|
+
section: {
|
|
71
|
+
display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 560,
|
|
72
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
73
|
+
},
|
|
74
|
+
intro: { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, lineHeight: '22px' },
|
|
75
|
+
card: {
|
|
76
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 12,
|
|
77
|
+
padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 6,
|
|
78
|
+
},
|
|
79
|
+
cardHeader: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
80
|
+
dot: { width: 8, height: 8, borderRadius: '50%', flexShrink: 0 },
|
|
81
|
+
name: { fontWeight: 500, fontSize: 14, lineHeight: '22px', color: 'var(--dsw-alias-label-primary)' },
|
|
82
|
+
statusLine: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
83
|
+
errorLine: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' },
|
|
84
|
+
actions: { display: 'flex', gap: 8, marginTop: 4, alignItems: 'center', flexWrap: 'wrap' },
|
|
85
|
+
button: {
|
|
86
|
+
boxSizing: 'border-box', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
87
|
+
height: 28, padding: '0 10px', borderRadius: 14,
|
|
88
|
+
border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent',
|
|
89
|
+
color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: 12, lineHeight: '18px',
|
|
90
|
+
cursor: 'pointer',
|
|
91
|
+
},
|
|
92
|
+
manual: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' },
|
|
93
|
+
manualRow: { display: 'flex', gap: 8, marginTop: 6 },
|
|
94
|
+
manualInput: {
|
|
95
|
+
flex: 1, height: 32, boxSizing: 'border-box',
|
|
96
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
|
|
97
|
+
padding: '0 10px', font: 'inherit', fontSize: 14, lineHeight: '22px',
|
|
98
|
+
background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
/** Status dot color for one provider state. */
|
|
102
|
+
function dotColor(status) {
|
|
103
|
+
if (status?.busy === true)
|
|
104
|
+
return 'var(--dsw-alias-state-warn-label)';
|
|
105
|
+
if (status?.loggedIn === true)
|
|
106
|
+
return 'var(--dsw-alias-state-success-primary)';
|
|
107
|
+
return 'var(--dsw-alias-label-dimmed)';
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One-line status text for one provider state.
|
|
111
|
+
* @param t - section translate.
|
|
112
|
+
* @param status - the provider's last reported state.
|
|
113
|
+
* @returns the localized status line.
|
|
114
|
+
*/
|
|
115
|
+
function statusText(t, status) {
|
|
116
|
+
if (status === undefined)
|
|
117
|
+
return t('checking');
|
|
118
|
+
if (status.busy)
|
|
119
|
+
return t('loginInProgress');
|
|
120
|
+
if (status.loggedIn) {
|
|
121
|
+
const params = {};
|
|
122
|
+
if (status.account !== undefined)
|
|
123
|
+
params.account = status.account;
|
|
124
|
+
if (status.expiresAt !== undefined)
|
|
125
|
+
params.date = new Date(status.expiresAt).toLocaleString();
|
|
126
|
+
if (params.account !== undefined && params.date !== undefined)
|
|
127
|
+
return t('loggedInAccountExpires', params);
|
|
128
|
+
if (params.account !== undefined)
|
|
129
|
+
return t('loggedInAccount', params);
|
|
130
|
+
if (params.date !== undefined)
|
|
131
|
+
return t('loggedInExpires', params);
|
|
132
|
+
return t('loggedIn');
|
|
133
|
+
}
|
|
134
|
+
return t('notLoggedIn');
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The Subscriptions settings page component.
|
|
138
|
+
* @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
|
|
139
|
+
* @returns the section body, or a notice while the RPC face is absent.
|
|
140
|
+
*/
|
|
141
|
+
export function SubscriptionsSection(props) {
|
|
142
|
+
const { rpc } = props;
|
|
143
|
+
const t = props.t ?? fallbackTranslate;
|
|
144
|
+
const [statuses, setStatuses] = useState({});
|
|
145
|
+
const [errors, setErrors] = useState({});
|
|
146
|
+
const [manualDrafts, setManualDrafts] = useState({
|
|
147
|
+
codex: '', claude: '', grok: '',
|
|
148
|
+
});
|
|
149
|
+
const mountedRef = useRef(true);
|
|
150
|
+
const pollersRef = useRef(new Map());
|
|
151
|
+
const setProviderError = useCallback((provider, message) => {
|
|
152
|
+
if (!mountedRef.current)
|
|
153
|
+
return;
|
|
154
|
+
setErrors((prev) => {
|
|
155
|
+
const next = { ...prev };
|
|
156
|
+
if (message === undefined)
|
|
157
|
+
delete next[provider];
|
|
158
|
+
else
|
|
159
|
+
next[provider] = message;
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
}, []);
|
|
163
|
+
const stopPolling = useCallback((provider) => {
|
|
164
|
+
const poller = pollersRef.current.get(provider);
|
|
165
|
+
if (poller !== undefined) {
|
|
166
|
+
clearInterval(poller);
|
|
167
|
+
pollersRef.current.delete(provider);
|
|
168
|
+
}
|
|
169
|
+
}, []);
|
|
170
|
+
/** Refetch every provider's status; stop a provider's poller once its attempt settles. */
|
|
171
|
+
const refresh = useCallback(async () => {
|
|
172
|
+
if (rpc === undefined)
|
|
173
|
+
return;
|
|
174
|
+
let response;
|
|
175
|
+
try {
|
|
176
|
+
response = await callSubscriptionsAuth(rpc, 'status', {});
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// A failed poll must not kill the page; busy providers keep polling and
|
|
180
|
+
// the action paths report their own errors.
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!mountedRef.current)
|
|
184
|
+
return;
|
|
185
|
+
setStatuses(response.providers);
|
|
186
|
+
for (const { id } of PROVIDERS) {
|
|
187
|
+
const status = response.providers[id];
|
|
188
|
+
if (status.loggedIn || !status.busy)
|
|
189
|
+
stopPolling(id);
|
|
190
|
+
}
|
|
191
|
+
}, [rpc, stopPolling]);
|
|
192
|
+
const startPolling = useCallback((provider) => {
|
|
193
|
+
if (pollersRef.current.has(provider))
|
|
194
|
+
return;
|
|
195
|
+
pollersRef.current.set(provider, setInterval(() => { void refresh(); }, POLL_INTERVAL_MS));
|
|
196
|
+
}, [refresh]);
|
|
197
|
+
// Initial load; every busy provider (e.g. an attempt started before a page
|
|
198
|
+
// reload) resumes polling. Teardown clears pollers and the mounted guard.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
mountedRef.current = true;
|
|
201
|
+
void refresh().then(() => {
|
|
202
|
+
if (!mountedRef.current)
|
|
203
|
+
return;
|
|
204
|
+
setStatuses((current) => {
|
|
205
|
+
for (const { id } of PROVIDERS) {
|
|
206
|
+
if (current[id]?.busy === true)
|
|
207
|
+
startPolling(id);
|
|
208
|
+
}
|
|
209
|
+
return current;
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
return () => {
|
|
213
|
+
mountedRef.current = false;
|
|
214
|
+
for (const poller of pollersRef.current.values())
|
|
215
|
+
clearInterval(poller);
|
|
216
|
+
pollersRef.current.clear();
|
|
217
|
+
};
|
|
218
|
+
}, [refresh, startPolling]);
|
|
219
|
+
const login = useCallback(async (provider) => {
|
|
220
|
+
if (rpc === undefined)
|
|
221
|
+
return;
|
|
222
|
+
setProviderError(provider, undefined);
|
|
223
|
+
try {
|
|
224
|
+
const response = await callSubscriptionsAuth(rpc, 'login', { provider });
|
|
225
|
+
if (typeof response.authorizeUrl !== 'string' || response.authorizeUrl === '') {
|
|
226
|
+
throw new SubscriptionsAuthError(t('loginMissingUrl'));
|
|
227
|
+
}
|
|
228
|
+
window.open(response.authorizeUrl, '_blank', 'noopener');
|
|
229
|
+
if (!mountedRef.current)
|
|
230
|
+
return;
|
|
231
|
+
// Optimistic busy so Cancel and the manual fallback appear before the first poll tick.
|
|
232
|
+
setStatuses(prev => ({ ...prev, [provider]: { ...prev[provider], busy: true, loggedIn: false } }));
|
|
233
|
+
startPolling(provider);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
setProviderError(provider, messageOf(error));
|
|
237
|
+
}
|
|
238
|
+
}, [rpc, t, setProviderError, startPolling]);
|
|
239
|
+
const cancel = useCallback(async (provider) => {
|
|
240
|
+
if (rpc === undefined)
|
|
241
|
+
return;
|
|
242
|
+
stopPolling(provider);
|
|
243
|
+
try {
|
|
244
|
+
await callSubscriptionsAuth(rpc, 'cancel', { provider });
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
setProviderError(provider, messageOf(error));
|
|
248
|
+
}
|
|
249
|
+
await refresh();
|
|
250
|
+
}, [rpc, stopPolling, setProviderError, refresh]);
|
|
251
|
+
const submitManual = useCallback(async (provider) => {
|
|
252
|
+
if (rpc === undefined)
|
|
253
|
+
return;
|
|
254
|
+
const input = manualDrafts[provider].trim();
|
|
255
|
+
if (input === '')
|
|
256
|
+
return;
|
|
257
|
+
setProviderError(provider, undefined);
|
|
258
|
+
try {
|
|
259
|
+
await callSubscriptionsAuth(rpc, 'manual', { provider, input });
|
|
260
|
+
if (mountedRef.current)
|
|
261
|
+
setManualDrafts(prev => ({ ...prev, [provider]: '' }));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
setProviderError(provider, messageOf(error));
|
|
265
|
+
}
|
|
266
|
+
await refresh();
|
|
267
|
+
}, [rpc, manualDrafts, setProviderError, refresh]);
|
|
268
|
+
const logout = useCallback(async (provider, name) => {
|
|
269
|
+
if (rpc === undefined)
|
|
270
|
+
return;
|
|
271
|
+
if (!window.confirm(t('logoutConfirm', { provider: name })))
|
|
272
|
+
return;
|
|
273
|
+
setProviderError(provider, undefined);
|
|
274
|
+
try {
|
|
275
|
+
await callSubscriptionsAuth(rpc, 'logout', { provider });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
setProviderError(provider, messageOf(error));
|
|
279
|
+
}
|
|
280
|
+
await refresh();
|
|
281
|
+
}, [rpc, t, setProviderError, refresh]);
|
|
282
|
+
if (rpc === undefined) {
|
|
283
|
+
return _jsx("p", { style: styles.intro, children: t('unavailable') });
|
|
284
|
+
}
|
|
285
|
+
return (_jsxs("div", { style: styles.section, children: [_jsx("p", { style: styles.intro, children: t('intro') }), PROVIDERS.map(({ id, name }) => {
|
|
286
|
+
const status = statuses[id];
|
|
287
|
+
const busy = status?.busy === true;
|
|
288
|
+
return (_jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.cardHeader, children: [_jsx("span", { style: { ...styles.dot, background: dotColor(status) } }), _jsx("span", { style: styles.name, children: name })] }), _jsx("p", { style: styles.statusLine, children: statusText(t, status) }), status?.detail !== undefined && status.detail !== '' && (_jsx("p", { style: styles.statusLine, children: status.detail })), errors[id] !== undefined && _jsx("p", { style: styles.errorLine, children: errors[id] }), _jsxs("div", { style: styles.actions, children: [!busy && status?.loggedIn !== true && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('login') })), busy && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void cancel(id); }, children: t('cancel') })), status?.loggedIn === true && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void logout(id, name); }, children: t('logout') }))] }), busy && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
|
|
289
|
+
})] }));
|
|
290
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription OAuth login page, browser half. Registers the Subscriptions
|
|
3
|
+
* settings section; every login state fact arrives through the node half's
|
|
4
|
+
* `/subscriptions-auth` RPC channel — this plugin holds no credential state of its
|
|
5
|
+
* own. Section copy rides the client locale service: one 'settings.subscriptions'
|
|
6
|
+
* namespace with zh/en dictionaries, rebound per read so the nav label and
|
|
7
|
+
* page text follow the active locale.
|
|
8
|
+
*/
|
|
9
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
10
|
+
import type { SubscriptionsKey } from './locales.js';
|
|
11
|
+
export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './SubscriptionsSection.js';
|
|
12
|
+
export type { SubscriptionsKey } from './locales.js';
|
|
13
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
14
|
+
interface LocaleNamespaceMap {
|
|
15
|
+
/** The Subscriptions settings page copy. */
|
|
16
|
+
'settings.subscriptions': SubscriptionsKey;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Required services (cordis fiber inject): `slots` carries the registration
|
|
21
|
+
* seat, `connection` the `/subscriptions-auth` RPC caller, and `locale` the copy
|
|
22
|
+
* dictionaries.
|
|
23
|
+
*/
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Register the Subscriptions section once the `settings.section` declaration
|
|
27
|
+
* is on the ledger (the shell's apply order relative to this one is NOT
|
|
28
|
+
* constrained; registration depends on the slot through `slots.inject()`).
|
|
29
|
+
* @param ctx - client root context.
|
|
30
|
+
*/
|
|
31
|
+
export declare function apply(ctx: ClientContext): void;
|