@iasiv5/dsh-quota-watch 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +407 -0
- package/package.json +60 -0
- package/src/access.mjs +67 -0
- package/src/client.mjs +369 -0
- package/src/core/adapters.mjs +163 -0
- package/src/host.mjs +311 -0
- package/src/shared.mjs +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iasiv5
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# DSH Quota Watch
|
|
2
|
+
|
|
3
|
+
[English summary](#english-summary)
|
|
4
|
+
|
|
5
|
+
个人版 DSH Web 侧栏配额监控插件,集中显示 GLM Coding Plan 窗口与 GitHub Copilot 的 GitHub 返回额度快照。它不统计 DSH 会话 token、不保存历史,也不需要在浏览器输入任何 provider 密钥。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 在侧栏 Settings 行上方显示一个可折叠卡片;没有单独的设置分区。
|
|
10
|
+
- GLM Coding Plan:展示套餐名、5 小时 / 每周 / 每月窗口、已用百分比、进度条和重置时间;兼容 Token / Credit 套餐,忽略 MCP `TIME_LIMIT`。
|
|
11
|
+
- GitHub Copilot:读取 `premium_interactions` quota snapshot;只有 GitHub 返回有限、有效的 `remaining` 时才显示可用额度和百分比。若余额字段无效或账号被标记为 unlimited,则显示“余额暂不可用”;`credits_used` 如存在会单独标为已用量,绝不用于推算余额。
|
|
12
|
+
- 宿主默认每 60 秒探测;profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停;刷新按钮可立即请求宿主探测。
|
|
13
|
+
- 探测失败时保留最后一次成功数据并标记可能过期;缺少凭据时显示未配置状态。
|
|
14
|
+
|
|
15
|
+
## 兼容与配置
|
|
16
|
+
|
|
17
|
+
- 要求 DSH `0.1.7-rc.2` 或更高。
|
|
18
|
+
- GLM 从 `llm-pi-ai` profile 中配置的 provider `apiKeyEnv` credential reference 解析当前 key。支持 `zai-coding-cn`(国内)与 `zai` / `zai-coding`(国际)路由。每次探测都重新解析引用,用户轮换凭据后下一轮读取新值。
|
|
19
|
+
- Copilot 使用内置 `llm-pi-ai/github-copilot` OAuth grant 的 `payload.refresh` 访问用户配额快照;不使用给模型请求的短期 `payload.access`,不使用 `COPILOT_GITHUB_TOKEN` 环境引用。每轮重新读取 grant,以支持用户重新授权或轮换。
|
|
20
|
+
- 如需修改宿主周期,在 profile 的 `quota-watch` row 配置 `pollIntervalSec`;禁用插件请使用 DSH 插件管理器。
|
|
21
|
+
|
|
22
|
+
## 数据来源与语义
|
|
23
|
+
|
|
24
|
+
- GLM 查询 `https://open.bigmodel.cn/api/monitor/usage/quota/limit` 或 `https://api.z.ai/api/monitor/usage/quota/limit`。该接口按 raw API key 鉴权,不加 Bearer 前缀。
|
|
25
|
+
- Copilot 查询 `https://api.github.com/copilot_internal/user`。这是 GitHub Copilot 内部、非公开稳定契约的端点,字段可能变更;插件在无法确认额度含义时显示不可用,不猜测或计算余额。
|
|
26
|
+
- 对企业 / 组织管理的 Copilot seat,GitHub AI Credits 可能属于共享计费池。侧栏呈现的是 GitHub 用户 quota snapshot 中的可用额度,不保证等同组织账单的总余额。
|
|
27
|
+
- 凭据只在 DSH 宿主进程解析并用于 HTTPS 请求;浏览器只接收规范化后的额度字段。插件不把 key、OAuth grant 或原始 provider 响应写入磁盘,也不保存额度历史。
|
|
28
|
+
|
|
29
|
+
## 安全
|
|
30
|
+
|
|
31
|
+
- API 路由只允许 loopback 或已配对的浏览器访问;响应禁用缓存。
|
|
32
|
+
- 本仓库、测试 fixtures 与 npm 包不得包含任何真实 key、OAuth grant、临时 token、原始账号响应或个人余额。测试仅使用合成响应。
|
|
33
|
+
- npm 包采用显式 `files` 白名单;`npm run security:check` 会扫描工作树和实际 pack 文件,诊断只报告路径和规则名,不打印匹配值。
|
|
34
|
+
- 首次 bootstrap 发布真实插件 `0.0.1` 后配置 npm Trusted Publishing;后续 tag release 使用 GitHub Actions OIDC 与 provenance,不保存长期 npm token。
|
|
35
|
+
|
|
36
|
+
## 安装
|
|
37
|
+
|
|
38
|
+
安装到 `web` profile:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
dsh plugin --profile web add @iasiv5/dsh-quota-watch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
插件管理器会应用包内 `cordis.patch.yml`。宿主半区的插件变更需要重启 `dsh web` 后生效;客户端 bundle 随插件加载。禁用插件请使用 DSH 插件管理器。默认探测周期为 60 秒;需要调整时,在 profile 的 `quota-watch` Cordis row 设置 `config.pollIntervalSec`(30–3600)。
|
|
45
|
+
|
|
46
|
+
## English summary
|
|
47
|
+
|
|
48
|
+
DSH Quota Watch is a personal DSH Web sidebar card for GLM Coding Plan windows and GitHub Copilot's provider-reported quota snapshot. It resolves credentials on every host-side probe, sends only normalized values to the browser, keeps no usage history, and never infers a Copilot balance from credits used. The Copilot endpoint is undocumented and may change; invalid or unlimited quota snapshots are shown as unavailable rather than guessed.
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@iasiv5/dsh-quota-watch",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name2 in all)
|
|
13
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
24
|
+
|
|
25
|
+
// src/client.mjs
|
|
26
|
+
var client_exports = {};
|
|
27
|
+
__export(client_exports, {
|
|
28
|
+
apply: () => apply,
|
|
29
|
+
inject: () => inject,
|
|
30
|
+
mountQuotaCard: () => mountQuotaCard,
|
|
31
|
+
name: () => name
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(client_exports);
|
|
34
|
+
|
|
35
|
+
// src/shared.mjs
|
|
36
|
+
var API_PREFIX = "/api/dsh-quota-watch";
|
|
37
|
+
var ROUTES = Object.freeze({
|
|
38
|
+
overview: `${API_PREFIX}/overview`,
|
|
39
|
+
refresh: `${API_PREFIX}/refresh`
|
|
40
|
+
});
|
|
41
|
+
var CLIENT_ROUTES = Object.freeze({
|
|
42
|
+
overview: `${API_PREFIX.slice(1)}/overview`,
|
|
43
|
+
refresh: `${API_PREFIX.slice(1)}/refresh`
|
|
44
|
+
});
|
|
45
|
+
var PROVIDER_IDS = Object.freeze({
|
|
46
|
+
glmDomestic: "zai-coding-cn",
|
|
47
|
+
glmInternational: "zai",
|
|
48
|
+
glmInternationalLegacy: "zai-coding",
|
|
49
|
+
copilot: "github-copilot"
|
|
50
|
+
});
|
|
51
|
+
var CLIENT_POLL_INTERVAL_MS = 3e4;
|
|
52
|
+
|
|
53
|
+
// src/client.mjs
|
|
54
|
+
var name = "quota-watch-client";
|
|
55
|
+
var inject = [];
|
|
56
|
+
var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
|
|
57
|
+
var COLLAPSE_KEY = "dsh-quota-watch:collapsed";
|
|
58
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
59
|
+
var COPY = {
|
|
60
|
+
zh: {
|
|
61
|
+
title: "\u5957\u9910\u76D1\u63A7",
|
|
62
|
+
refresh: "\u5237\u65B0",
|
|
63
|
+
collapse: "\u6536\u8D77",
|
|
64
|
+
expand: "\u5C55\u5F00",
|
|
65
|
+
loading: "\u8BFB\u53D6\u4E2D",
|
|
66
|
+
updated: "\u66F4\u65B0",
|
|
67
|
+
notConfigured: "\u672A\u914D\u7F6E",
|
|
68
|
+
noPlan: "\u6682\u65E0\u5957\u9910\u7A97\u53E3",
|
|
69
|
+
failed: "\u67E5\u8BE2\u5931\u8D25",
|
|
70
|
+
stale: "\u6570\u636E\u53EF\u80FD\u5DF2\u8FC7\u671F",
|
|
71
|
+
requestFailed: "\u5237\u65B0\u5931\u8D25\uFF0C\u663E\u793A\u4E0A\u6B21\u6210\u529F\u6570\u636E",
|
|
72
|
+
available: "\u53EF\u7528\u989D\u5EA6",
|
|
73
|
+
used: "\u672C\u671F\u5DF2\u7528",
|
|
74
|
+
balanceUnavailable: "\u4F59\u989D\u6682\u4E0D\u53EF\u7528",
|
|
75
|
+
reset: "\u91CD\u7F6E",
|
|
76
|
+
windows: { "5h": "5 \u5C0F\u65F6", week: "\u6BCF\u5468", month: "\u6BCF\u6708" }
|
|
77
|
+
},
|
|
78
|
+
en: {
|
|
79
|
+
title: "Quota Watch",
|
|
80
|
+
refresh: "Refresh",
|
|
81
|
+
collapse: "Collapse",
|
|
82
|
+
expand: "Expand",
|
|
83
|
+
loading: "Loading",
|
|
84
|
+
updated: "Updated",
|
|
85
|
+
notConfigured: "Not configured",
|
|
86
|
+
noPlan: "No plan windows",
|
|
87
|
+
failed: "Query failed",
|
|
88
|
+
stale: "Data may be stale",
|
|
89
|
+
requestFailed: "Refresh failed; showing last successful data",
|
|
90
|
+
available: "Available credits",
|
|
91
|
+
used: "Used this cycle",
|
|
92
|
+
balanceUnavailable: "Balance unavailable",
|
|
93
|
+
reset: "Resets",
|
|
94
|
+
windows: { "5h": "5 hours", week: "Weekly", month: "Monthly" }
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var STYLE_TEXT = `
|
|
98
|
+
[data-dsh-quota-watch-card] { box-sizing: border-box; width: 100%; margin: 0 0 8px; color: var(--dsw-alias-label-primary, inherit); font: inherit; }
|
|
99
|
+
[data-dsh-quota-watch-card] *, [data-dsh-quota-watch-card] *::before, [data-dsh-quota-watch-card] *::after { box-sizing: border-box; }
|
|
100
|
+
.dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 7px; }
|
|
101
|
+
.dqw-head { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
|
102
|
+
.dqw-title { flex: 1; min-width: 0; margin: 0; font-size: 12px; font-weight: 600; line-height: 18px; }
|
|
103
|
+
.dqw-button { border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11px; line-height: 18px; padding: 1px 4px; }
|
|
104
|
+
.dqw-button:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.16)); }
|
|
105
|
+
.dqw-button:disabled { cursor: wait; opacity: .6; }
|
|
106
|
+
.dqw-meta { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
|
|
107
|
+
.dqw-provider { border-top: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 4px; }
|
|
108
|
+
.dqw-provider-title { margin: 0; font-size: 11px; font-weight: 600; line-height: 16px; }
|
|
109
|
+
.dqw-row { display: flex; flex-direction: column; gap: 2px; }
|
|
110
|
+
.dqw-row-head { display: flex; justify-content: space-between; gap: 5px; font-size: 10px; line-height: 14px; }
|
|
111
|
+
.dqw-bar { display: block; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
|
|
112
|
+
.dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
|
|
113
|
+
.dqw-muted { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
|
|
114
|
+
.dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
|
|
115
|
+
.dqw-credit { margin: 0; font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
|
|
116
|
+
`;
|
|
117
|
+
function localeFor(win) {
|
|
118
|
+
return String(win?.navigator?.language ?? "zh").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
119
|
+
}
|
|
120
|
+
function formatNumber(value, locale) {
|
|
121
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "";
|
|
122
|
+
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US", { maximumFractionDigits: 1 }).format(value);
|
|
123
|
+
}
|
|
124
|
+
function formatPercent(value, locale) {
|
|
125
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "";
|
|
126
|
+
const clamped = Math.max(0, Math.min(100, value));
|
|
127
|
+
const rounded = clamped >= 10 ? Math.round(clamped) : Number(clamped.toFixed(1));
|
|
128
|
+
return `${formatNumber(rounded, locale)}%`;
|
|
129
|
+
}
|
|
130
|
+
function formatTime(value, locale) {
|
|
131
|
+
if (typeof value !== "string" || value === "") return "";
|
|
132
|
+
const date = new Date(value);
|
|
133
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
134
|
+
return new Intl.DateTimeFormat(locale === "zh" ? "zh-CN" : "en-US", {
|
|
135
|
+
dateStyle: "short",
|
|
136
|
+
timeStyle: "short"
|
|
137
|
+
}).format(date);
|
|
138
|
+
}
|
|
139
|
+
function text(doc, tag, className, value) {
|
|
140
|
+
const node = doc.createElement(tag);
|
|
141
|
+
if (className) node.className = className;
|
|
142
|
+
node.textContent = String(value ?? "");
|
|
143
|
+
return node;
|
|
144
|
+
}
|
|
145
|
+
function progress(doc, label, percent, locale) {
|
|
146
|
+
const row = doc.createElement("div");
|
|
147
|
+
row.className = "dqw-row";
|
|
148
|
+
const head = doc.createElement("div");
|
|
149
|
+
head.className = "dqw-row-head";
|
|
150
|
+
head.append(text(doc, "span", "", label), text(doc, "span", "", formatPercent(percent, locale)));
|
|
151
|
+
const bar = doc.createElement("span");
|
|
152
|
+
bar.className = "dqw-bar";
|
|
153
|
+
bar.setAttribute("role", "progressbar");
|
|
154
|
+
bar.setAttribute("aria-valuemin", "0");
|
|
155
|
+
bar.setAttribute("aria-valuemax", "100");
|
|
156
|
+
bar.setAttribute("aria-valuenow", String(Math.max(0, Math.min(100, percent))));
|
|
157
|
+
const fill = doc.createElement("span");
|
|
158
|
+
fill.className = "dqw-bar-fill";
|
|
159
|
+
fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
160
|
+
bar.append(fill);
|
|
161
|
+
row.append(head, bar);
|
|
162
|
+
return row;
|
|
163
|
+
}
|
|
164
|
+
function providerSection(doc, key, heading) {
|
|
165
|
+
const section = doc.createElement("section");
|
|
166
|
+
section.className = "dqw-provider";
|
|
167
|
+
section.dataset.provider = key;
|
|
168
|
+
section.append(text(doc, "h4", "dqw-provider-title", heading));
|
|
169
|
+
const content = doc.createElement("div");
|
|
170
|
+
content.dataset.role = "content";
|
|
171
|
+
content.className = "dqw-content";
|
|
172
|
+
section.append(content);
|
|
173
|
+
return section;
|
|
174
|
+
}
|
|
175
|
+
function appendStaleError(doc, content, provider, copy) {
|
|
176
|
+
if (provider.status === "stale") content.append(text(doc, "p", "dqw-error", `${copy.stale}: ${provider.error ?? ""}`));
|
|
177
|
+
else if (provider.status === "error") content.append(text(doc, "p", "dqw-error", provider.error ?? copy.failed));
|
|
178
|
+
}
|
|
179
|
+
function renderGlm(doc, section, provider, copy, locale) {
|
|
180
|
+
const content = section.querySelector('[data-role="content"]');
|
|
181
|
+
content.replaceChildren();
|
|
182
|
+
appendStaleError(doc, content, provider, copy);
|
|
183
|
+
if (provider.status === "missing") {
|
|
184
|
+
content.append(text(doc, "p", "dqw-muted", copy.notConfigured));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (provider.status === "loading") {
|
|
188
|
+
content.append(text(doc, "p", "dqw-muted", copy.loading));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!provider.plan?.windows?.length) {
|
|
192
|
+
if (provider.status !== "error") content.append(text(doc, "p", "dqw-muted", copy.noPlan));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const planName = provider.plan.planName;
|
|
196
|
+
if (planName) content.append(text(doc, "p", "dqw-muted", planName));
|
|
197
|
+
for (const window of provider.plan.windows) {
|
|
198
|
+
const label = window.name || copy.windows[window.key] || window.key;
|
|
199
|
+
if (typeof window.percent === "number" && Number.isFinite(window.percent)) {
|
|
200
|
+
content.append(progress(doc, label, window.percent, locale));
|
|
201
|
+
} else {
|
|
202
|
+
content.append(text(doc, "p", "dqw-muted", label));
|
|
203
|
+
}
|
|
204
|
+
const reset = formatTime(window.resetsAt, locale);
|
|
205
|
+
if (reset) content.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function renderCopilot(doc, section, provider, copy, locale) {
|
|
209
|
+
const content = section.querySelector('[data-role="content"]');
|
|
210
|
+
content.replaceChildren();
|
|
211
|
+
appendStaleError(doc, content, provider, copy);
|
|
212
|
+
if (provider.status === "missing") {
|
|
213
|
+
content.append(text(doc, "p", "dqw-muted", copy.notConfigured));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (provider.status === "loading") {
|
|
217
|
+
content.append(text(doc, "p", "dqw-muted", copy.loading));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const quota = provider.quota;
|
|
221
|
+
if (quota?.planName) content.append(text(doc, "p", "dqw-muted", quota.planName));
|
|
222
|
+
if (quota?.balanceAvailable === true) {
|
|
223
|
+
content.append(text(doc, "p", "dqw-credit", `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`));
|
|
224
|
+
if (typeof quota.percentRemaining === "number") {
|
|
225
|
+
content.append(progress(doc, copy.available, quota.percentRemaining, locale));
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
content.append(text(doc, "p", "dqw-muted", copy.balanceUnavailable));
|
|
229
|
+
}
|
|
230
|
+
if (typeof quota?.creditsUsed === "number") {
|
|
231
|
+
content.append(text(doc, "p", "dqw-muted", `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`));
|
|
232
|
+
}
|
|
233
|
+
const reset = formatTime(quota?.resetsAt, locale);
|
|
234
|
+
if (reset) content.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
|
|
235
|
+
}
|
|
236
|
+
function footArea(doc) {
|
|
237
|
+
const sidebar = doc.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
|
|
238
|
+
return sidebar?.querySelector('[class*="footArea"]') ?? void 0;
|
|
239
|
+
}
|
|
240
|
+
function readCollapsed(win) {
|
|
241
|
+
try {
|
|
242
|
+
return win.localStorage.getItem(COLLAPSE_KEY) === "1";
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function writeCollapsed(win, value) {
|
|
248
|
+
try {
|
|
249
|
+
win.localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0");
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function fetchJson(fetchImpl, path, method) {
|
|
254
|
+
const response = await fetchImpl(path, {
|
|
255
|
+
...method === "POST" ? { method: "POST" } : {},
|
|
256
|
+
cache: "no-store",
|
|
257
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
258
|
+
});
|
|
259
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
260
|
+
return response.json();
|
|
261
|
+
}
|
|
262
|
+
function mountQuotaCard({
|
|
263
|
+
doc = globalThis.document,
|
|
264
|
+
win = globalThis.window,
|
|
265
|
+
fetchImpl = globalThis.fetch,
|
|
266
|
+
pollIntervalMs = CLIENT_POLL_INTERVAL_MS
|
|
267
|
+
} = {}) {
|
|
268
|
+
if (!doc?.body || typeof fetchImpl !== "function") return () => {
|
|
269
|
+
};
|
|
270
|
+
if (doc.querySelector(CARD_SELECTOR)) return () => {
|
|
271
|
+
};
|
|
272
|
+
const locale = localeFor(win);
|
|
273
|
+
const copy = COPY[locale];
|
|
274
|
+
const container = doc.createElement("div");
|
|
275
|
+
container.dataset.dshQuotaWatchCard = "";
|
|
276
|
+
container.dataset.dshPlugin = "quota-watch";
|
|
277
|
+
container.dataset.dshPart = "sidebar-card";
|
|
278
|
+
const style = text(doc, "style", "", STYLE_TEXT);
|
|
279
|
+
const card = doc.createElement("section");
|
|
280
|
+
card.className = "dqw-card";
|
|
281
|
+
card.setAttribute("aria-label", copy.title);
|
|
282
|
+
const header = doc.createElement("header");
|
|
283
|
+
header.className = "dqw-head";
|
|
284
|
+
header.append(text(doc, "h3", "dqw-title", copy.title));
|
|
285
|
+
const refreshButton = text(doc, "button", "dqw-button", "\u21BB");
|
|
286
|
+
refreshButton.type = "button";
|
|
287
|
+
refreshButton.title = copy.refresh;
|
|
288
|
+
refreshButton.setAttribute("aria-label", copy.refresh);
|
|
289
|
+
refreshButton.dataset.action = "refresh";
|
|
290
|
+
const collapseButton = text(doc, "button", "dqw-button", "\u2212");
|
|
291
|
+
collapseButton.type = "button";
|
|
292
|
+
collapseButton.dataset.action = "collapse";
|
|
293
|
+
const body = doc.createElement("div");
|
|
294
|
+
body.className = "dqw-body";
|
|
295
|
+
const lastUpdated = text(doc, "p", "dqw-meta", copy.loading);
|
|
296
|
+
lastUpdated.dataset.role = "updated";
|
|
297
|
+
const glmSection = providerSection(doc, "glm", "GLM Coding Plan");
|
|
298
|
+
const copilotSection = providerSection(doc, "copilot", "GitHub Copilot");
|
|
299
|
+
body.append(lastUpdated, glmSection, copilotSection);
|
|
300
|
+
header.append(refreshButton, collapseButton);
|
|
301
|
+
card.append(header, body);
|
|
302
|
+
container.append(style, card);
|
|
303
|
+
let collapsed = readCollapsed(win);
|
|
304
|
+
let snapshot;
|
|
305
|
+
let requestSequence = 0;
|
|
306
|
+
let timer;
|
|
307
|
+
let disposed = false;
|
|
308
|
+
let queuedPlace = false;
|
|
309
|
+
const applyCollapsed = () => {
|
|
310
|
+
body.hidden = collapsed;
|
|
311
|
+
collapseButton.textContent = collapsed ? "+" : "\u2212";
|
|
312
|
+
collapseButton.title = collapsed ? copy.expand : copy.collapse;
|
|
313
|
+
collapseButton.setAttribute("aria-label", collapseButton.title);
|
|
314
|
+
collapseButton.setAttribute("aria-expanded", String(!collapsed));
|
|
315
|
+
};
|
|
316
|
+
const render = (next) => {
|
|
317
|
+
snapshot = next;
|
|
318
|
+
const glm = next?.providers?.find((provider) => provider.key === "glm");
|
|
319
|
+
const copilot = next?.providers?.find((provider) => provider.key === "copilot");
|
|
320
|
+
if (glm) renderGlm(doc, glmSection, glm, copy, locale);
|
|
321
|
+
if (copilot) renderCopilot(doc, copilotSection, copilot, copy, locale);
|
|
322
|
+
const updated = formatTime(next?.updatedAt ? new Date(next.updatedAt).toISOString() : "", locale);
|
|
323
|
+
lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading;
|
|
324
|
+
};
|
|
325
|
+
const showTransportError = () => {
|
|
326
|
+
if (snapshot) lastUpdated.textContent = copy.requestFailed;
|
|
327
|
+
else lastUpdated.textContent = copy.failed;
|
|
328
|
+
};
|
|
329
|
+
const poll = async (force) => {
|
|
330
|
+
if (disposed) return;
|
|
331
|
+
const sequence = ++requestSequence;
|
|
332
|
+
refreshButton.disabled = force === true;
|
|
333
|
+
try {
|
|
334
|
+
const next = await fetchJson(fetchImpl, force ? CLIENT_ROUTES.refresh : CLIENT_ROUTES.overview, force ? "POST" : "GET");
|
|
335
|
+
if (sequence === requestSequence && !disposed) render(next);
|
|
336
|
+
} catch {
|
|
337
|
+
if (sequence === requestSequence && !disposed) showTransportError();
|
|
338
|
+
} finally {
|
|
339
|
+
if (!disposed && sequence === requestSequence) refreshButton.disabled = false;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
const place = () => {
|
|
343
|
+
const foot = footArea(doc);
|
|
344
|
+
if (!foot) return;
|
|
345
|
+
const settings = foot.querySelector('[class*="settingsArea"]');
|
|
346
|
+
if (settings) {
|
|
347
|
+
if (container.parentElement !== foot || container.nextElementSibling !== settings) foot.insertBefore(container, settings);
|
|
348
|
+
} else if (container.parentElement !== foot || foot.lastElementChild !== container) {
|
|
349
|
+
foot.append(container);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const schedulePlace = () => {
|
|
353
|
+
if (queuedPlace || disposed) return;
|
|
354
|
+
queuedPlace = true;
|
|
355
|
+
queueMicrotask(() => {
|
|
356
|
+
queuedPlace = false;
|
|
357
|
+
if (!disposed) place();
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
const observer = new win.MutationObserver(schedulePlace);
|
|
361
|
+
observer.observe(doc.body, { childList: true, subtree: true });
|
|
362
|
+
place();
|
|
363
|
+
applyCollapsed();
|
|
364
|
+
const startPolling = () => {
|
|
365
|
+
if (timer !== void 0 || doc.visibilityState === "hidden" || disposed) return;
|
|
366
|
+
void poll(false);
|
|
367
|
+
timer = win.setInterval(() => {
|
|
368
|
+
void poll(false);
|
|
369
|
+
}, pollIntervalMs);
|
|
370
|
+
};
|
|
371
|
+
const stopPolling = () => {
|
|
372
|
+
if (timer !== void 0) win.clearInterval(timer);
|
|
373
|
+
timer = void 0;
|
|
374
|
+
};
|
|
375
|
+
const onVisibilityChange = () => {
|
|
376
|
+
if (doc.visibilityState === "hidden") stopPolling();
|
|
377
|
+
else startPolling();
|
|
378
|
+
};
|
|
379
|
+
const onRefresh = () => {
|
|
380
|
+
void poll(true);
|
|
381
|
+
};
|
|
382
|
+
const onCollapse = () => {
|
|
383
|
+
collapsed = !collapsed;
|
|
384
|
+
writeCollapsed(win, collapsed);
|
|
385
|
+
applyCollapsed();
|
|
386
|
+
};
|
|
387
|
+
refreshButton.addEventListener("click", onRefresh);
|
|
388
|
+
collapseButton.addEventListener("click", onCollapse);
|
|
389
|
+
doc.addEventListener("visibilitychange", onVisibilityChange);
|
|
390
|
+
startPolling();
|
|
391
|
+
return () => {
|
|
392
|
+
disposed = true;
|
|
393
|
+
stopPolling();
|
|
394
|
+
observer.disconnect();
|
|
395
|
+
doc.removeEventListener("visibilitychange", onVisibilityChange);
|
|
396
|
+
refreshButton.removeEventListener("click", onRefresh);
|
|
397
|
+
collapseButton.removeEventListener("click", onCollapse);
|
|
398
|
+
container.remove();
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function apply(ctx) {
|
|
402
|
+
ctx.effect(() => mountQuotaCard(), "dsh-quota-watch: sidebar");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return module.exports;
|
|
406
|
+
}
|
|
407
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iasiv5/dsh-quota-watch",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/host.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/host.mjs",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/**/*.mjs",
|
|
14
|
+
"lib/client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/iasiv5/dsh-quota-watch"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"registry": "https://registry.npmjs.org/"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@deepseek-ai/dsh-credentials": "0.1.7-rc.2"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"esbuild": "^0.28.2",
|
|
39
|
+
"jsdom": "^29.1.1",
|
|
40
|
+
"yaml": "^2.5.0"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"engines": {
|
|
44
|
+
"dsh": ">=0.1.7-rc.2"
|
|
45
|
+
},
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"inject": []
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "node scripts/build-client.mjs",
|
|
56
|
+
"test": "node --test",
|
|
57
|
+
"security:check": "node scripts/check-public-package.mjs",
|
|
58
|
+
"prepack": "npm run build && npm run security:check"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/access.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function isIPv4Loopback(address) {
|
|
2
|
+
const parts = address.split('.')
|
|
3
|
+
return parts.length === 4
|
|
4
|
+
&& parts[0] === '127'
|
|
5
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isLoopbackAddress(address) {
|
|
9
|
+
if (typeof address !== 'string') return false
|
|
10
|
+
const normalized = address.toLowerCase()
|
|
11
|
+
if (normalized === '::1') return true
|
|
12
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
13
|
+
return isIPv4Loopback(normalized)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isLoopbackHostname(hostname) {
|
|
17
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
18
|
+
return isIPv4Loopback(hostname)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSameOriginRequest(request) {
|
|
22
|
+
if (request.headers?.['sec-fetch-site'] === 'cross-site') return false
|
|
23
|
+
const origin = request.headers?.origin
|
|
24
|
+
if (origin === undefined || origin === null || origin === '') return true
|
|
25
|
+
const host = request.headers?.host
|
|
26
|
+
if (typeof host !== 'string' || host === '') return false
|
|
27
|
+
try {
|
|
28
|
+
return new URL(origin).host === host
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isLoopbackRequest(request) {
|
|
35
|
+
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
|
|
36
|
+
const host = request.headers?.host
|
|
37
|
+
if (typeof host !== 'string' || host === '') return false
|
|
38
|
+
let hostname
|
|
39
|
+
try {
|
|
40
|
+
hostname = new URL(`http://${host}`).hostname
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
return isLoopbackHostname(hostname) && isSameOriginRequest(request)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pairingService(ctx) {
|
|
48
|
+
try {
|
|
49
|
+
const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
|
|
50
|
+
if (fromGet && typeof fromGet.isPairedDevice === 'function') return fromGet
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing optional pairing service stays loopback-only.
|
|
53
|
+
}
|
|
54
|
+
const direct = ctx.remoteWebUiPairing
|
|
55
|
+
return direct && typeof direct.isPairedDevice === 'function' ? direct : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Allow the local desktop or a browser session explicitly paired by the host. */
|
|
59
|
+
export function isPairedOrLoopbackAllowed(ctx, request) {
|
|
60
|
+
if (isLoopbackRequest(request)) return true
|
|
61
|
+
if (!isSameOriginRequest(request)) return false
|
|
62
|
+
try {
|
|
63
|
+
return pairingService(ctx)?.isPairedDevice(request) === true
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|