dsh-market-watching 0.1.0 → 0.2.2
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.en.md +22 -19
- package/README.md +22 -21
- package/client.js +455 -2
- package/icon.svg +11 -0
- package/index.js +178 -0
- package/package.json +4 -2
package/README.en.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
A market card for the DSH sidebar: crypto, A-shares / HK / US equities, global indices, CME futures continuous contracts, FX and spot metals — live prices, daily change, plus position P&L against your own cost basis. Pick the symbols, their order and the refresh cadence in **DSH Settings**. **No login, no API key.** The UI copy follows the DSH interface language (English / Chinese).
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
+
<img src="icon.svg" alt="dsh-market-watching" width="72" height="72">
|
|
7
|
+
<br>
|
|
6
8
|
<a href="README.md"><img src="docs/badges/lang-zh-switch.svg" alt="简体中文" height="20"></a>
|
|
7
9
|
|
|
8
10
|
<img src="docs/badges/lang-en-current.svg" alt="English" height="20">
|
|
@@ -65,29 +67,23 @@ All of these are public quote endpoints — no account, no token, no auth. Every
|
|
|
65
67
|
|
|
66
68
|
## Install
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
Use the DSH plugin command. It forwards its arguments verbatim to pnpm inside the profile directory, so one line does the whole install.
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
Desktop (`desktop` profile):
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
```powershell
|
|
75
|
+
dsh plugin --profile desktop add dsh-market-watching
|
|
76
|
+
```
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
Web (`web` profile):
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
```powershell
|
|
81
|
+
dsh plugin --profile web add dsh-market-watching
|
|
82
|
+
```
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
Restart the process afterwards: close and reopen the DeepSeek Harness desktop app (the host half `index.js` is only assembled at process start), or restart `dsh web` for the web profile. The client half `client.js` is injected when the page loads, so a refresh picks it up.
|
|
83
85
|
|
|
84
|
-
```powershell
|
|
85
|
-
New-Item -ItemType Junction `
|
|
86
|
-
-Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-market-watching" `
|
|
87
|
-
-Target "E:\LLm\Project\dsh-market-watching"
|
|
88
|
-
```
|
|
89
86
|
|
|
90
|
-
4. **Restart the DeepSeek Harness desktop app.** The host half (`index.js`) is only assembled at process start; the client half (`client.js`) is injected when the page loads.
|
|
91
87
|
|
|
92
88
|
## Usage
|
|
93
89
|
|
|
@@ -142,12 +138,15 @@ The `config` block in `cordis.patch.yml` overrides these defaults (a profile pat
|
|
|
142
138
|
| `/api/market.ticker` | GET / POST | Quote snapshot for the current watchlist; `?refresh=1` bypasses the cache |
|
|
143
139
|
| `/api/market.settings` | GET / POST | Read and write settings |
|
|
144
140
|
| `/api/market.resolve` | POST | Code resolution and validation (returns candidates and official names) |
|
|
141
|
+
| `/api/market.notice` | GET / POST | Whether the thank-you card is due (GET) and what the operator chose (POST `thanks` / `issue` / `later`) |
|
|
145
142
|
|
|
146
143
|
Settings persist to `$DSH_HOME/dsh-market-watching.json` (default `%USERPROFILE%\.dsh\dsh-market-watching.json`). The plugin used to be called `dsh-plugin-market-ticker`; its old file `dsh-market-ticker.json` is adopted automatically on first start and left in place.
|
|
147
144
|
|
|
145
|
+
The card keeps its own counter in `$DSH_HOME/dsh-market-watching.notice.json` rather than inside the settings document: settings are normalized on every read and write, so an unknown field would be dropped the moment the watchlist is saved.
|
|
146
|
+
|
|
148
147
|
## How it works
|
|
149
148
|
|
|
150
|
-
- **Host side** (`index.js`): `inject = ['connection']`, and `ctx.connection.fetch.register` installs
|
|
149
|
+
- **Host side** (`index.js`): `inject = ['connection']`, and `ctx.connection.fetch.register` installs four exact routes. Inside, instruments are grouped by market: equities / indices / FX merge into one East Money `ulist` batch (40 secids per batch), crypto goes to Gate pair by pair, spot metals merge into one Sina batch. A `cacheMs` cache and `inflight` de-duplication sit in front, and when every upstream fails the response is `degraded: true` with the previous quotes from `lastGood` (`stale: true`), so the card never goes blank. Position maths runs on the host: `positionPnl(entry, price)` yields the percentage (long `(price - cost) / cost`, inverted for shorts) and `positionAmount(entry, qty, price)` yields the cash amount (price delta × quantity, inverted for shorts), shipped as `pnlPercent` / `pnlAmount`; the client only renders them. Error responses carry a `code` (`badJson` / `writeFailed` / `unknownCode` / `noQuote`) next to `message`, so the client can re-render the text in the current language.
|
|
151
150
|
- **Client side** (`client.js`): `inject = ['slots', 'locale']`, registering `sidebar.footer.action` (the card), `settings.section` (the settings page) and `settings.section.icon` (the nav icon). Styling uses `--dsw-alias-*` theme tokens only, so it follows light and dark themes.
|
|
152
151
|
|
|
153
152
|
## Language
|
|
@@ -175,7 +174,7 @@ node scripts/probe-ticker.mjs # hit the ticker route once, print real quotes and
|
|
|
175
174
|
|
|
176
175
|
## Troubleshooting
|
|
177
176
|
|
|
178
|
-
- **No card in the sidebar**: first check whether the master switch in settings is off; then that at least one instrument exists; then that
|
|
177
|
+
- **No card in the sidebar**: first check whether the master switch in settings is off; then that at least one instrument exists; then that the package is installed in the current profile (`dsh plugin --profile desktop why dsh-market-watching`); finally restart the process.
|
|
179
178
|
- **Card shows `--`**: upstreams are unreachable. Check that the machine can reach `push2.eastmoney.com`, `api.gateio.ws` and `hq.sinajs.cn` directly; behind a proxy, put the proxy address in `config.proxy` (the Sina path then uses the same client, so if it is blocked, drop the proxy or go direct).
|
|
180
179
|
- **A symbol is rejected**: the host could not get a quote for that code. Try another form (market prefix for A-shares, a full secid for indices).
|
|
181
180
|
- **All copy is English**: interface language follows DSH — switch it to Chinese in DSH's own language setting. Takes effect immediately, no restart.
|
|
@@ -183,7 +182,11 @@ node scripts/probe-ticker.mjs # hit the ticker route once, print real quotes and
|
|
|
183
182
|
|
|
184
183
|
## Uninstall
|
|
185
184
|
|
|
186
|
-
|
|
185
|
+
```powershell
|
|
186
|
+
dsh plugin --profile desktop remove dsh-market-watching
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
For the web profile, swap `--profile desktop` for `--profile web`. Restart the process afterwards. The settings file `%USERPROFILE%\.dsh\dsh-market-watching.json` can stay (it is reused on reinstall) or be deleted.
|
|
187
190
|
|
|
188
191
|
## License
|
|
189
192
|
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
DSH 侧栏行情卡片:加密货币、A股 / 港股 / 美股、国际指数、CME 期货连续合约、外汇、现货金银,实时价格与涨跌幅一览,还能按自己填的成本价算持仓盈亏。标的、顺序、刷新节奏全在 **DSH 设置页**里定,**无需登录、无需 API Key**,界面文案跟随 DSH 的界面语言(中文 / 英文)。
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
+
<img src="icon.svg" alt="dsh-market-watching" width="72" height="72">
|
|
7
|
+
<br>
|
|
6
8
|
<img src="docs/badges/lang-zh-current.svg" alt="简体中文" height="20">
|
|
7
9
|
|
|
8
10
|
<a href="README.en.md"><img src="docs/badges/lang-en-switch.svg" alt="English" height="20"></a>
|
|
@@ -78,29 +80,21 @@ English:
|
|
|
78
80
|
|
|
79
81
|
## 安装
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
用 DSH 自带的插件命令,一条就够——它把参数原样转发给 profile 目录里的 pnpm。
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
桌面版(`desktop` profile):
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
2. 同一个 `package.json` 的 `dsh.profile.bundles` 数组末尾增加:
|
|
90
|
-
|
|
91
|
-
```json
|
|
92
|
-
"dsh-market-watching"
|
|
93
|
-
```
|
|
87
|
+
```powershell
|
|
88
|
+
dsh plugin --profile desktop add dsh-market-watching
|
|
89
|
+
```
|
|
94
90
|
|
|
95
|
-
|
|
91
|
+
Web 版(`web` profile):
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-Target "E:\LLm\Project\dsh-market-watching"
|
|
101
|
-
```
|
|
93
|
+
```powershell
|
|
94
|
+
dsh plugin --profile web add dsh-market-watching
|
|
95
|
+
```
|
|
102
96
|
|
|
103
|
-
|
|
97
|
+
装完重启对应进程才算数:桌面版关掉重开 DeepSeek Harness(宿主 `index.js` 只在进程启动时装配),Web 版重启 `dsh web`。客户端 `client.js` 是页面加载时注入的,刷新一次就换。
|
|
104
98
|
|
|
105
99
|
## 使用
|
|
106
100
|
|
|
@@ -155,12 +149,15 @@ English:
|
|
|
155
149
|
| `/api/market.ticker` | GET / POST | 当前标的的报价快照;`?refresh=1` 强制绕过缓存 |
|
|
156
150
|
| `/api/market.settings` | GET / POST | 读写设置 |
|
|
157
151
|
| `/api/market.resolve` | POST | 代码解析与校验(返回候选与官方名称) |
|
|
152
|
+
| `/api/market.notice` | GET / POST | 是否该弹出道谢卡片(GET),以及记录用户的选择(POST `thanks` / `issue` / `later`) |
|
|
158
153
|
|
|
159
154
|
设置落盘在 `$DSH_HOME/dsh-market-watching.json`(默认 `%USERPROFILE%\.dsh\dsh-market-watching.json`)。插件改名前叫 `dsh-plugin-market-ticker`,旧文件 `dsh-market-ticker.json` 会在首次启动时被自动接管,原文件保留不删。
|
|
160
155
|
|
|
156
|
+
道谢卡片的计数单独落在 `$DSH_HOME/dsh-market-watching.notice.json`,不写进设置文件:设置每次读写都会被规范化,未知字段留不住,用户一存自选就会把计数洗掉。
|
|
157
|
+
|
|
161
158
|
## 工作方式
|
|
162
159
|
|
|
163
|
-
- **宿主侧**(`index.js`):`inject = ['connection']`,用 `ctx.connection.fetch.register`
|
|
160
|
+
- **宿主侧**(`index.js`):`inject = ['connection']`,用 `ctx.connection.fetch.register` 注册四条精确路由。内部把标的按市场分组,股票/指数/外汇合并成一次东财 `ulist` 批量请求(每批 40 个 secid),加密逐个走 Gate,现货金属合并成一次新浪批次请求。带 `cacheMs` 缓存与 `inflight` 去重,上游整体失败时返回 `degraded: true` 并沿用 `lastGood` 里的上一次报价(`stale: true`),不会把卡片打空。持仓盈亏由宿主算:`positionPnl(entry, price)` 出百分比(多头 `(现价-成本)/成本`,空头取反),`positionAmount(entry, qty, price)` 出金额(价格差 × 数量,空头取反),分别以 `pnlPercent` / `pnlAmount` 下发,客户端只负责显示。错误响应除 `message` 外还带 `code`(`badJson` / `writeFailed` / `unknownCode` / `noQuote`),供客户端按当前语言重新渲染。
|
|
164
161
|
- **客户端侧**(`client.js`):`inject = ['slots', 'locale']`,注册 `sidebar.footer.action`(卡片)、`settings.section`(设置页)、`settings.section.icon`(导航图标)。样式只用 `--dsw-alias-*` 主题 token,跟随深浅色主题。
|
|
165
162
|
|
|
166
163
|
## 语言
|
|
@@ -188,7 +185,7 @@ node scripts/probe-ticker.mjs # 打一次 ticker 路由,打印真实报价与
|
|
|
188
185
|
|
|
189
186
|
## 故障排查
|
|
190
187
|
|
|
191
|
-
-
|
|
188
|
+
- **侧栏没有卡片**:先看设置里的总开关是不是被关了;再确认至少有一个标的;再确认包装进了当前 profile(`dsh plugin --profile desktop why dsh-market-watching`);最后重启对应进程。
|
|
192
189
|
- **卡片显示 `--`**:上游不通。检查网络能否直连 `push2.eastmoney.com`、`api.gateio.ws` 与 `hq.sinajs.cn`;若走代理,在 `config.proxy` 填上代理地址(新浪那一路在配置代理后也走同一客户端,若被拦就把代理关掉或改用直连)。
|
|
193
190
|
- **添加标的被拒**:说明宿主用报价接口探测不到该代码,换个写法(A 股带市场前缀、指数写完整 secid)。
|
|
194
191
|
- **文案全是英文**:界面语言跟着 DSH 走,去 DSH 自己的语言设置里切成中文即可,改完即时生效,不用重启。
|
|
@@ -196,7 +193,11 @@ node scripts/probe-ticker.mjs # 打一次 ticker 路由,打印真实报价与
|
|
|
196
193
|
|
|
197
194
|
## 卸载
|
|
198
195
|
|
|
199
|
-
|
|
196
|
+
```powershell
|
|
197
|
+
dsh plugin --profile desktop remove dsh-market-watching
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Web 版就把 `--profile desktop` 换成 `--profile web`。卸完重启对应进程。设置文件 `%USERPROFILE%\.dsh\dsh-market-watching.json` 可以留着(重装后接着用),也可以直接删。
|
|
200
201
|
|
|
201
202
|
## License
|
|
202
203
|
|
package/client.js
CHANGED
|
@@ -27,6 +27,9 @@ window.__ModuleLoader__.load({
|
|
|
27
27
|
const SETTINGS_ENDPOINT = '/api/market.settings'
|
|
28
28
|
const RESOLVE_ENDPOINT = '/api/market.resolve'
|
|
29
29
|
|
|
30
|
+
/** Route that decides whether the post-install thank-you card is due. */
|
|
31
|
+
const NOTICE_ENDPOINT = '/api/market.notice'
|
|
32
|
+
|
|
30
33
|
/** Slot ids, spelled exactly as the UI packages declare them. */
|
|
31
34
|
const SIDEBAR_SLOT = 'sidebar.footer.action'
|
|
32
35
|
const SETTINGS_SLOT = 'settings.section'
|
|
@@ -35,6 +38,22 @@ window.__ModuleLoader__.load({
|
|
|
35
38
|
const CARD_ID = 'market-watching'
|
|
36
39
|
const SETTINGS_ID = 'market-watching-settings'
|
|
37
40
|
const STYLE_ID = 'dsh-market-watching-style'
|
|
41
|
+
const THANKS_ID = 'market-watching-thanks'
|
|
42
|
+
|
|
43
|
+
/** Where a reader can complain about a price or ask for a market. */
|
|
44
|
+
const REPO_URL = 'https://github.com/Leni-More/dsh-market-watching'
|
|
45
|
+
const ISSUES_URL = `${REPO_URL}/issues`
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The card waits for the shell to settle before it appears: long enough that
|
|
49
|
+
* it never races the sidebar's first paint, short enough to still feel like
|
|
50
|
+
* a greeting rather than a delayed pop-up.
|
|
51
|
+
*/
|
|
52
|
+
const THANKS_DELAY_MS = 2400
|
|
53
|
+
|
|
54
|
+
/** While the operator is typing, the card keeps its distance and tries again. */
|
|
55
|
+
const THANKS_IDLE_MS = 800
|
|
56
|
+
const THANKS_IDLE_TRIES = 12
|
|
38
57
|
|
|
39
58
|
/** Locale namespace this plugin owns; `apply` registers the dictionaries below. */
|
|
40
59
|
const LOCALE_NAMESPACE = 'marketTicker'
|
|
@@ -80,7 +99,7 @@ window.__ModuleLoader__.load({
|
|
|
80
99
|
'page.subtitle': '侧边栏底部显示的价格、涨跌幅与盈亏。标的、顺序与刷新间隔都在这里决定,改动立即生效。',
|
|
81
100
|
'page.loading': '正在读取自选列表…',
|
|
82
101
|
'page.failed': '读取自选列表失败,请重开设置页。',
|
|
83
|
-
'page.footer': '数据来自东方财富与 Gate.io
|
|
102
|
+
'page.footer': '数据来自东方财富与 Gate.io 的公开接口,无需账号与密钥;不同平台可能存在一定价差,可以根据实际的价差来调整成本价。',
|
|
84
103
|
'panel.master': '总开关',
|
|
85
104
|
'panel.masterHint': '关闭后侧边栏不再显示卡片,也不再请求行情',
|
|
86
105
|
'panel.add': '添加标的',
|
|
@@ -135,6 +154,14 @@ window.__ModuleLoader__.load({
|
|
|
135
154
|
'error.unknownCode': '认不出这个代码,换成 600519 / hk00700 / AAPL / USTEC / EURUSD / XAUUSD / BTC 试试。',
|
|
136
155
|
'error.noQuote': '上游这会儿没有这个标的的报价。',
|
|
137
156
|
'hint.setPriceFirst': '先在输入框填成本价,再点确定,然后就能切换买入/做空',
|
|
157
|
+
'thanks.title': '谢谢你把它留在侧边栏',
|
|
158
|
+
'thanks.body': '很开心它可以帮上你。功能优化,或者你想增加新的标的,可以随时提出来。要是它确实帮上了忙,去仓库点个 Star 就是最好的谢礼。',
|
|
159
|
+
'thanks.wish': '祝你持仓大赚,住上大平层,开上帕拉梅拉,享受苹果人生。',
|
|
160
|
+
'thanks.star': '点个 Star',
|
|
161
|
+
'thanks.ack': '知道了',
|
|
162
|
+
'thanks.close': '关闭',
|
|
163
|
+
'thanks.note': '有更好的建议?',
|
|
164
|
+
'thanks.repo': '去提一条',
|
|
138
165
|
},
|
|
139
166
|
en: {
|
|
140
167
|
'market.crypto': 'Crypto',
|
|
@@ -168,7 +195,7 @@ window.__ModuleLoader__.load({
|
|
|
168
195
|
'page.subtitle': 'Price, change and P&L at the bottom of the sidebar. Symbols, order and refresh rate are all decided here, and every change applies at once.',
|
|
169
196
|
'page.loading': 'Reading the watchlist…',
|
|
170
197
|
'page.failed': 'Could not read the watchlist. Reopen Settings.',
|
|
171
|
-
'page.footer': '
|
|
198
|
+
'page.footer': 'The data comes from the public endpoints of East Money and Gate.io, with no account or API key required. Price differences may exist across platforms, so the cost price can be adjusted based on the actual price difference.',
|
|
172
199
|
'panel.master': 'Master switch',
|
|
173
200
|
'panel.masterHint': 'When off the sidebar card disappears and no quotes are fetched',
|
|
174
201
|
'panel.add': 'Add a symbol',
|
|
@@ -223,6 +250,14 @@ window.__ModuleLoader__.load({
|
|
|
223
250
|
'error.unknownCode': 'Unrecognised code. Try 600519 / hk00700 / AAPL / USTEC / EURUSD / XAUUSD / BTC.',
|
|
224
251
|
'error.noQuote': 'The upstream has no quote for this symbol right now.',
|
|
225
252
|
'hint.setPriceFirst': 'Type a cost and press Save first, then the long/short switch works',
|
|
253
|
+
'thanks.title': 'Thank you for keeping it in the sidebar',
|
|
254
|
+
'thanks.body': 'Glad it could help. If you’d like feature improvements or want to add new tickers, feel free to reach out anytime. If it really did help, dropping a Star on the repo is the best thank-you.',
|
|
255
|
+
'thanks.wish': 'Wishing you big gains on your holdings, a fancy penthouse, a Panamera, and the Apple life.',
|
|
256
|
+
'thanks.star': 'Drop a Star',
|
|
257
|
+
'thanks.ack': 'Got it',
|
|
258
|
+
'thanks.close': 'Close',
|
|
259
|
+
'thanks.note': 'Any better suggestions?',
|
|
260
|
+
'thanks.repo': 'Open an issue',
|
|
226
261
|
},
|
|
227
262
|
}
|
|
228
263
|
|
|
@@ -485,6 +520,17 @@ window.__ModuleLoader__.load({
|
|
|
485
520
|
letter-spacing: 0.02em;
|
|
486
521
|
}
|
|
487
522
|
|
|
523
|
+
/* Brand mark: gives the card a face of its own before the title, and stays put when the
|
|
524
|
+
rail collapses the header to bare numbers and strips the written title away. */
|
|
525
|
+
.dmt-brand {
|
|
526
|
+
display: inline-flex;
|
|
527
|
+
align-items: center;
|
|
528
|
+
flex: none;
|
|
529
|
+
color: #22c55e;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.dmt-brand > svg { display: block; }
|
|
533
|
+
|
|
488
534
|
.dmt-mark {
|
|
489
535
|
display: inline-block;
|
|
490
536
|
width: 5px;
|
|
@@ -890,6 +936,162 @@ window.__ModuleLoader__.load({
|
|
|
890
936
|
|
|
891
937
|
.dmt-toggle[data-on="1"] { background: var(--dsw-alias-brand-primary, #4d7cfe); border-color: transparent; }
|
|
892
938
|
.dmt-toggle[data-on="1"]::after { transform: translateX(18px); background: #fff; }
|
|
939
|
+
|
|
940
|
+
/* ------------------------------------------------------------------ *
|
|
941
|
+
* thank-you card
|
|
942
|
+
* ------------------------------------------------------------------ */
|
|
943
|
+
|
|
944
|
+
.dmt-thanks-layer {
|
|
945
|
+
position: fixed;
|
|
946
|
+
inset: 0;
|
|
947
|
+
z-index: 200;
|
|
948
|
+
display: flex;
|
|
949
|
+
align-items: center;
|
|
950
|
+
justify-content: center;
|
|
951
|
+
padding: 20px;
|
|
952
|
+
background: rgba(16, 16, 20, 0.4);
|
|
953
|
+
animation: dmt-thanks-fade 200ms ease-out;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
.dmt-thanks {
|
|
957
|
+
position: relative;
|
|
958
|
+
box-sizing: border-box;
|
|
959
|
+
width: min(384px, 100%);
|
|
960
|
+
padding: 24px 24px 18px;
|
|
961
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.22));
|
|
962
|
+
border-radius: 14px;
|
|
963
|
+
background: var(--dsw-alias-bg-layer-2, #1c1c20);
|
|
964
|
+
color: var(--dsw-alias-label-primary, #f2f2f5);
|
|
965
|
+
box-shadow: 0 20px 52px rgba(0, 0, 0, 0.32);
|
|
966
|
+
font-size: 13px;
|
|
967
|
+
line-height: 1.62;
|
|
968
|
+
animation: dmt-thanks-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.dmt-thanks:focus { outline: none; }
|
|
972
|
+
|
|
973
|
+
.dmt-thanks-close {
|
|
974
|
+
position: absolute;
|
|
975
|
+
top: 8px;
|
|
976
|
+
right: 8px;
|
|
977
|
+
display: flex;
|
|
978
|
+
align-items: center;
|
|
979
|
+
justify-content: center;
|
|
980
|
+
width: 26px;
|
|
981
|
+
height: 26px;
|
|
982
|
+
padding: 0;
|
|
983
|
+
border: none;
|
|
984
|
+
border-radius: 7px;
|
|
985
|
+
background: transparent;
|
|
986
|
+
color: var(--dsw-alias-label-tertiary, rgba(160, 160, 170, 0.9));
|
|
987
|
+
font-size: 17px;
|
|
988
|
+
line-height: 1;
|
|
989
|
+
cursor: pointer;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
.dmt-thanks-close:hover {
|
|
993
|
+
background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
|
|
994
|
+
color: var(--dsw-alias-label-primary, #f2f2f5);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
.dmt-thanks-title {
|
|
998
|
+
margin: 0 22px 8px 0;
|
|
999
|
+
font-size: 15px;
|
|
1000
|
+
font-weight: 600;
|
|
1001
|
+
letter-spacing: 0.01em;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.dmt-thanks-body {
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
color: var(--dsw-alias-label-secondary, rgba(210, 210, 218, 0.92));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.dmt-thanks-wish {
|
|
1010
|
+
margin: 10px 0 0;
|
|
1011
|
+
color: var(--dsw-alias-state-success-primary, #30a46c);
|
|
1012
|
+
font-weight: 600;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.dmt-thanks-actions {
|
|
1016
|
+
display: flex;
|
|
1017
|
+
flex-wrap: wrap;
|
|
1018
|
+
gap: 8px;
|
|
1019
|
+
margin-top: 18px;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.dmt-thanks-cta,
|
|
1023
|
+
.dmt-thanks-ack {
|
|
1024
|
+
appearance: none;
|
|
1025
|
+
box-sizing: border-box;
|
|
1026
|
+
padding: 7px 14px;
|
|
1027
|
+
border-radius: 8px;
|
|
1028
|
+
font: inherit;
|
|
1029
|
+
font-weight: 600;
|
|
1030
|
+
text-decoration: none;
|
|
1031
|
+
cursor: pointer;
|
|
1032
|
+
transition: background 120ms ease, border-color 120ms ease;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* The ask is the one bright thing in the card: amber rather than the shell's blue, so it
|
|
1036
|
+
reads as an invitation instead of another system button. Dark ink keeps it legible. */
|
|
1037
|
+
.dmt-thanks-cta {
|
|
1038
|
+
border: 1px solid #F0B429;
|
|
1039
|
+
background: #FFC93C;
|
|
1040
|
+
color: #2B2000;
|
|
1041
|
+
box-shadow: 0 6px 16px -10px rgba(255, 201, 60, 0.9);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
.dmt-thanks-cta:hover {
|
|
1045
|
+
background: #FFD75E;
|
|
1046
|
+
border-color: #F7C948;
|
|
1047
|
+
color: #2B2000;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.dmt-thanks-ack {
|
|
1051
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.28));
|
|
1052
|
+
background: transparent;
|
|
1053
|
+
color: var(--dsw-alias-label-primary, #f2f2f5);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
.dmt-thanks-ack:hover { background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12)); }
|
|
1057
|
+
|
|
1058
|
+
.dmt-thanks-cta:focus-visible,
|
|
1059
|
+
.dmt-thanks-ack:focus-visible,
|
|
1060
|
+
.dmt-thanks-close:focus-visible,
|
|
1061
|
+
.dmt-thanks-link:focus-visible {
|
|
1062
|
+
outline: 2px solid var(--dsw-alias-brand-primary, #4d7cfe);
|
|
1063
|
+
outline-offset: 2px;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.dmt-thanks-foot {
|
|
1067
|
+
margin: 14px 0 0;
|
|
1068
|
+
padding-top: 10px;
|
|
1069
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.16));
|
|
1070
|
+
color: var(--dsw-alias-label-tertiary, rgba(160, 160, 170, 0.9));
|
|
1071
|
+
font-size: 12px;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.dmt-thanks-link {
|
|
1075
|
+
color: var(--dsw-alias-brand-primary, #4d7cfe);
|
|
1076
|
+
text-decoration: none;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.dmt-thanks-link:hover { text-decoration: underline; }
|
|
1080
|
+
|
|
1081
|
+
@keyframes dmt-thanks-fade {
|
|
1082
|
+
from { opacity: 0; }
|
|
1083
|
+
to { opacity: 1; }
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
@keyframes dmt-thanks-rise {
|
|
1087
|
+
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
|
1088
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1092
|
+
.dmt-thanks-layer,
|
|
1093
|
+
.dmt-thanks { animation: none; }
|
|
1094
|
+
}
|
|
893
1095
|
`
|
|
894
1096
|
|
|
895
1097
|
/** Inject the stylesheet once per document. */
|
|
@@ -1053,6 +1255,7 @@ window.__ModuleLoader__.load({
|
|
|
1053
1255
|
h(
|
|
1054
1256
|
'div',
|
|
1055
1257
|
{ className: 'dmt-head' },
|
|
1258
|
+
h('span', { className: 'dmt-brand', 'aria-hidden': 'true' }, h(BrandMark, { size: 14 })),
|
|
1056
1259
|
h('span', {
|
|
1057
1260
|
className: 'dmt-mark',
|
|
1058
1261
|
'data-stale': stale ? '1' : '0',
|
|
@@ -1698,6 +1901,248 @@ window.__ModuleLoader__.load({
|
|
|
1698
1901
|
)
|
|
1699
1902
|
}
|
|
1700
1903
|
|
|
1904
|
+
/**
|
|
1905
|
+
* Brand mark for the card header: the same rising line as the settings glyph, but
|
|
1906
|
+
* filled so it still reads at 14px against the sidebar. Colour comes from CSS, so
|
|
1907
|
+
* the mark can be darkened without editing the shape.
|
|
1908
|
+
*/
|
|
1909
|
+
function BrandMark(props) {
|
|
1910
|
+
const size = typeof props?.size === 'number' ? props.size : 14
|
|
1911
|
+
return h(
|
|
1912
|
+
'svg',
|
|
1913
|
+
{ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true' },
|
|
1914
|
+
h('rect', { width: 24, height: 24, rx: 6, fill: 'currentColor' }),
|
|
1915
|
+
h('path', {
|
|
1916
|
+
d: 'M5.4 15.6 9.5 11.5l2.9 2.9 6-6',
|
|
1917
|
+
stroke: '#FFFFFF',
|
|
1918
|
+
strokeWidth: 2.1,
|
|
1919
|
+
strokeLinecap: 'round',
|
|
1920
|
+
strokeLinejoin: 'round',
|
|
1921
|
+
}),
|
|
1922
|
+
h('path', {
|
|
1923
|
+
d: 'M14.7 8.4h3.7v3.7',
|
|
1924
|
+
stroke: '#FFFFFF',
|
|
1925
|
+
strokeWidth: 2.1,
|
|
1926
|
+
strokeLinecap: 'round',
|
|
1927
|
+
strokeLinejoin: 'round',
|
|
1928
|
+
}),
|
|
1929
|
+
)
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/* ---------------------------------------------------------------------- *
|
|
1933
|
+
* thank-you card
|
|
1934
|
+
* ---------------------------------------------------------------------- */
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* True while the operator is typing somewhere, so the card never takes the
|
|
1938
|
+
* caret out of a cost box that is half filled.
|
|
1939
|
+
* @returns whether an editable element has focus.
|
|
1940
|
+
*/
|
|
1941
|
+
function isEditing() {
|
|
1942
|
+
const active = document.activeElement
|
|
1943
|
+
if (active === null || active === undefined) return false
|
|
1944
|
+
const tag = String(active.tagName ?? '').toLowerCase()
|
|
1945
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
|
|
1946
|
+
return active.isContentEditable === true
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Ask the host whether the card is due. A host that cannot answer is a host
|
|
1951
|
+
* that shows nothing: this surface must never get in the way of the card.
|
|
1952
|
+
* @returns the decision payload, or undefined when the route did not answer.
|
|
1953
|
+
*/
|
|
1954
|
+
async function fetchThanks() {
|
|
1955
|
+
try {
|
|
1956
|
+
const body = await requestJson(NOTICE_ENDPOINT)
|
|
1957
|
+
return typeof body?.show === 'boolean' ? body : undefined
|
|
1958
|
+
} catch {
|
|
1959
|
+
return undefined
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Record one answer. `keepalive` matters for the issue link: the click also
|
|
1965
|
+
* opens a new tab, and the request has to survive that navigation.
|
|
1966
|
+
* @param action - `thanks`, `issue` or `later`.
|
|
1967
|
+
*/
|
|
1968
|
+
function answerThanks(action) {
|
|
1969
|
+
void fetch(apiUrl(NOTICE_ENDPOINT), {
|
|
1970
|
+
method: 'POST',
|
|
1971
|
+
headers: { 'content-type': 'application/json' },
|
|
1972
|
+
body: JSON.stringify({ action }),
|
|
1973
|
+
keepalive: true,
|
|
1974
|
+
}).catch(() => {})
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Build the card as plain DOM instead of a React tree: it lives directly on
|
|
1979
|
+
* `document.body`, outlives every panel switch, and must not depend on which
|
|
1980
|
+
* renderer version the shell happens to ship.
|
|
1981
|
+
* @param onClose - called with the action the operator chose.
|
|
1982
|
+
* @returns the layer plus the two hooks `mountThanks` needs.
|
|
1983
|
+
*/
|
|
1984
|
+
function buildThanks(onClose) {
|
|
1985
|
+
const titleId = `${THANKS_ID}-title`
|
|
1986
|
+
const bodyId = `${THANKS_ID}-body`
|
|
1987
|
+
|
|
1988
|
+
const layer = document.createElement('div')
|
|
1989
|
+
layer.className = 'dmt-thanks-layer'
|
|
1990
|
+
|
|
1991
|
+
const card = document.createElement('div')
|
|
1992
|
+
card.className = 'dmt-thanks'
|
|
1993
|
+
card.id = THANKS_ID
|
|
1994
|
+
card.tabIndex = -1
|
|
1995
|
+
card.setAttribute('role', 'dialog')
|
|
1996
|
+
card.setAttribute('aria-modal', 'true')
|
|
1997
|
+
card.setAttribute('aria-labelledby', titleId)
|
|
1998
|
+
card.setAttribute('aria-describedby', bodyId)
|
|
1999
|
+
|
|
2000
|
+
const close = document.createElement('button')
|
|
2001
|
+
close.type = 'button'
|
|
2002
|
+
close.className = 'dmt-thanks-close'
|
|
2003
|
+
close.textContent = '×'
|
|
2004
|
+
close.addEventListener('click', () => onClose('later'))
|
|
2005
|
+
|
|
2006
|
+
const title = document.createElement('h2')
|
|
2007
|
+
title.className = 'dmt-thanks-title'
|
|
2008
|
+
title.id = titleId
|
|
2009
|
+
|
|
2010
|
+
const body = document.createElement('p')
|
|
2011
|
+
body.className = 'dmt-thanks-body'
|
|
2012
|
+
body.id = bodyId
|
|
2013
|
+
|
|
2014
|
+
const wish = document.createElement('p')
|
|
2015
|
+
wish.className = 'dmt-thanks-wish'
|
|
2016
|
+
|
|
2017
|
+
const actions = document.createElement('div')
|
|
2018
|
+
actions.className = 'dmt-thanks-actions'
|
|
2019
|
+
|
|
2020
|
+
const cta = document.createElement('a')
|
|
2021
|
+
cta.className = 'dmt-thanks-cta'
|
|
2022
|
+
// The star lives on the repo landing page; no deep link into the widget needed.
|
|
2023
|
+
cta.href = REPO_URL
|
|
2024
|
+
cta.target = '_blank'
|
|
2025
|
+
cta.rel = 'noopener noreferrer'
|
|
2026
|
+
// Starring or saying thanks both mean "this landed" — neither should be asked twice.
|
|
2027
|
+
cta.addEventListener('click', () => onClose('thanks'))
|
|
2028
|
+
|
|
2029
|
+
const ack = document.createElement('button')
|
|
2030
|
+
ack.type = 'button'
|
|
2031
|
+
ack.className = 'dmt-thanks-ack'
|
|
2032
|
+
ack.addEventListener('click', () => onClose('thanks'))
|
|
2033
|
+
|
|
2034
|
+
const foot = document.createElement('p')
|
|
2035
|
+
foot.className = 'dmt-thanks-foot'
|
|
2036
|
+
|
|
2037
|
+
const note = document.createTextNode('')
|
|
2038
|
+
const feedback = document.createElement('a')
|
|
2039
|
+
feedback.className = 'dmt-thanks-link'
|
|
2040
|
+
feedback.href = ISSUES_URL
|
|
2041
|
+
feedback.target = '_blank'
|
|
2042
|
+
feedback.rel = 'noopener noreferrer'
|
|
2043
|
+
|
|
2044
|
+
actions.appendChild(cta)
|
|
2045
|
+
actions.appendChild(ack)
|
|
2046
|
+
foot.appendChild(note)
|
|
2047
|
+
foot.appendChild(document.createTextNode(' '))
|
|
2048
|
+
foot.appendChild(feedback)
|
|
2049
|
+
card.appendChild(close)
|
|
2050
|
+
card.appendChild(title)
|
|
2051
|
+
card.appendChild(body)
|
|
2052
|
+
card.appendChild(wish)
|
|
2053
|
+
card.appendChild(actions)
|
|
2054
|
+
card.appendChild(foot)
|
|
2055
|
+
layer.appendChild(card)
|
|
2056
|
+
|
|
2057
|
+
// Clicking the dimmed area, and only it, counts as "later".
|
|
2058
|
+
layer.addEventListener('click', (event) => { if (event.target === layer) onClose('later') })
|
|
2059
|
+
layer.addEventListener('keydown', (event) => {
|
|
2060
|
+
if (event.key !== 'Escape') return
|
|
2061
|
+
event.preventDefault()
|
|
2062
|
+
onClose('later')
|
|
2063
|
+
})
|
|
2064
|
+
|
|
2065
|
+
/** Repaint every label, so a language switch reaches the open card too. */
|
|
2066
|
+
const copy = () => {
|
|
2067
|
+
close.setAttribute('aria-label', t('thanks.close'))
|
|
2068
|
+
title.textContent = t('thanks.title')
|
|
2069
|
+
body.textContent = t('thanks.body')
|
|
2070
|
+
wish.textContent = t('thanks.wish')
|
|
2071
|
+
cta.textContent = t('thanks.star')
|
|
2072
|
+
ack.textContent = t('thanks.ack')
|
|
2073
|
+
note.textContent = t('thanks.note')
|
|
2074
|
+
feedback.textContent = t('thanks.repo')
|
|
2075
|
+
}
|
|
2076
|
+
copy()
|
|
2077
|
+
|
|
2078
|
+
return { layer, card, copy }
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* Show the thank-you card once, when the host says it is due.
|
|
2083
|
+
*
|
|
2084
|
+
* Everything that decides whether it appears lives on the host: the show
|
|
2085
|
+
* budget, the cooldown and the final answer all survive a restart, which a
|
|
2086
|
+
* browser flag could not, because the desktop shell binds a new port each
|
|
2087
|
+
* launch and takes `localStorage` with it.
|
|
2088
|
+
*/
|
|
2089
|
+
function mountThanks() {
|
|
2090
|
+
const host = typeof document === 'undefined' ? undefined : document.body
|
|
2091
|
+
// Test harnesses load this module without a DOM; there is nothing to
|
|
2092
|
+
// attach a floating card to, and nothing to prove by trying.
|
|
2093
|
+
if (host === undefined || host === null || typeof host.appendChild !== 'function') return
|
|
2094
|
+
if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return
|
|
2095
|
+
|
|
2096
|
+
/** The layer on screen, or null. */
|
|
2097
|
+
let layer = null
|
|
2098
|
+
/** Element that held focus before the card took it. */
|
|
2099
|
+
let previous = null
|
|
2100
|
+
let unsubscribe = null
|
|
2101
|
+
let tries = 0
|
|
2102
|
+
|
|
2103
|
+
const dismiss = (action) => {
|
|
2104
|
+
if (layer === null) return
|
|
2105
|
+
const node = layer
|
|
2106
|
+
layer = null
|
|
2107
|
+
node.remove()
|
|
2108
|
+
if (unsubscribe !== null) {
|
|
2109
|
+
unsubscribe()
|
|
2110
|
+
unsubscribe = null
|
|
2111
|
+
}
|
|
2112
|
+
answerThanks(action)
|
|
2113
|
+
if (previous !== null && typeof previous.focus === 'function') previous.focus()
|
|
2114
|
+
previous = null
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const reveal = () => {
|
|
2118
|
+
const built = buildThanks(dismiss)
|
|
2119
|
+
layer = built.layer
|
|
2120
|
+
previous = document.activeElement
|
|
2121
|
+
document.body.appendChild(layer)
|
|
2122
|
+
built.card.focus()
|
|
2123
|
+
if (localeService !== null && typeof localeService.subscribe === 'function') {
|
|
2124
|
+
unsubscribe = localeService.subscribe(built.copy)
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const attempt = async () => {
|
|
2129
|
+
if (layer !== null || document.hidden === true) return
|
|
2130
|
+
if (isEditing()) {
|
|
2131
|
+
// Give the operator room to finish the sentence; past the budget the
|
|
2132
|
+
// card is simply skipped for this page load.
|
|
2133
|
+
if (tries >= THANKS_IDLE_TRIES) return
|
|
2134
|
+
tries += 1
|
|
2135
|
+
window.setTimeout(() => { void attempt() }, THANKS_IDLE_MS)
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
const decision = await fetchThanks()
|
|
2139
|
+
if (decision?.show !== true || layer !== null || document.hidden === true) return
|
|
2140
|
+
reveal()
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
window.setTimeout(() => { void attempt() }, THANKS_DELAY_MS)
|
|
2144
|
+
}
|
|
2145
|
+
|
|
1701
2146
|
/**
|
|
1702
2147
|
* Register the dictionaries, then both surfaces. `slots` and `locale` are the two
|
|
1703
2148
|
* services these components need, so they are the only ones injected — injecting
|
|
@@ -1733,6 +2178,14 @@ window.__ModuleLoader__.load({
|
|
|
1733
2178
|
{ name: SETTINGS_ICON_SLOT, id: SETTINGS_ID },
|
|
1734
2179
|
MarketIcon,
|
|
1735
2180
|
))
|
|
2181
|
+
|
|
2182
|
+
try {
|
|
2183
|
+
mountThanks()
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
// A greeting is not worth a broken sidebar: the card may be absent, the
|
|
2186
|
+
// quote card and the editor must still mount.
|
|
2187
|
+
console.warn('[market-watching] thank-you card skipped', error)
|
|
2188
|
+
}
|
|
1736
2189
|
}
|
|
1737
2190
|
|
|
1738
2191
|
const inject = ['slots', 'locale']
|
package/icon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="dsh-market-watching">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="dmt-icon-fill" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#0E9F6E"/>
|
|
5
|
+
<stop offset="1" stop-color="#34D399"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="24" height="24" rx="6" fill="url(#dmt-icon-fill)"/>
|
|
9
|
+
<path d="M5.4 15.6 9.5 11.5l2.9 2.9 6-6" fill="none" stroke="#FFFFFF" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
|
|
10
|
+
<path d="M14.7 8.4h3.7v3.7" fill="none" stroke="#FFFFFF" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
</svg>
|
package/index.js
CHANGED
|
@@ -37,6 +37,9 @@ export const SETTINGS_PATH = '/api/market.settings'
|
|
|
37
37
|
/** Route that validates one operator-typed code before it joins the watchlist. */
|
|
38
38
|
export const RESOLVE_PATH = '/api/market.resolve'
|
|
39
39
|
|
|
40
|
+
/** Route that decides whether the post-install thank-you card is due. */
|
|
41
|
+
export const NOTICE_PATH = '/api/market.notice'
|
|
42
|
+
|
|
40
43
|
/** Connection owns the `/api` channel; its exact Fetch registry carries these routes. */
|
|
41
44
|
export const inject = ['connection']
|
|
42
45
|
|
|
@@ -54,6 +57,39 @@ const LEGACY_SETTINGS_FILENAME = 'dsh-market-ticker.json'
|
|
|
54
57
|
/** Bumped only when the stored shape changes incompatibly. */
|
|
55
58
|
const SETTINGS_VERSION = 1
|
|
56
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Thank-you card bookkeeping.
|
|
62
|
+
*
|
|
63
|
+
* This lives beside the settings document rather than inside it: settings are
|
|
64
|
+
* normalized on every read and write, so an unknown field would be dropped the
|
|
65
|
+
* moment the operator saved the watchlist.
|
|
66
|
+
*/
|
|
67
|
+
const NOTICE_FILENAME = 'dsh-market-watching.notice.json'
|
|
68
|
+
|
|
69
|
+
/** Bumped only when the stored notice shape changes incompatibly. */
|
|
70
|
+
const NOTICE_VERSION = 1
|
|
71
|
+
|
|
72
|
+
/** How many times one installation may ever see the card. */
|
|
73
|
+
const NOTICE_MAX_SHOWS = 3
|
|
74
|
+
|
|
75
|
+
/** Quiet window after a "later", so the card never becomes a nag. */
|
|
76
|
+
const NOTICE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The browser asks once per page load; a reload storm must not burn through the
|
|
80
|
+
* show budget, so two answers inside this window are served from memory.
|
|
81
|
+
*/
|
|
82
|
+
const NOTICE_THROTTLE_MS = 30 * 1000
|
|
83
|
+
|
|
84
|
+
/** An answer body is a single short action, never a payload. */
|
|
85
|
+
const NOTICE_MAX_BODY = 2048
|
|
86
|
+
|
|
87
|
+
/** Answers that mean "do not ask again", as opposed to "not now". */
|
|
88
|
+
const NOTICE_FINAL = new Set(['thanks', 'issue'])
|
|
89
|
+
|
|
90
|
+
/** Every answer the card can send back. */
|
|
91
|
+
const NOTICE_ACTIONS = new Set(['thanks', 'issue', 'later'])
|
|
92
|
+
|
|
57
93
|
/**
|
|
58
94
|
* Upper bound on the watchlist. The sidebar card is the constraint, not the
|
|
59
95
|
* upstreams: sixteen rows already overflow a short window.
|
|
@@ -531,6 +567,78 @@ function writeSettings(path, settings, log) {
|
|
|
531
567
|
}
|
|
532
568
|
}
|
|
533
569
|
|
|
570
|
+
/* -------------------------------------------------------------------------- *
|
|
571
|
+
* thank-you card
|
|
572
|
+
* -------------------------------------------------------------------------- */
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Shape written before anything has been shown, so the first decision is a
|
|
576
|
+
* plain `shows === 0` read instead of a special case.
|
|
577
|
+
* @returns a fresh notice document.
|
|
578
|
+
*/
|
|
579
|
+
function emptyNotice() {
|
|
580
|
+
return { version: NOTICE_VERSION, shows: 0, lastAt: 0, closed: null }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Read the notice document, tolerating anything on disk.
|
|
585
|
+
*
|
|
586
|
+
* The card is decoration: a corrupt or hand-edited file must degrade to "ask
|
|
587
|
+
* once more", never to a crash on startup.
|
|
588
|
+
* @param path - absolute path of the document.
|
|
589
|
+
* @param log - warning sink for an unreadable file.
|
|
590
|
+
* @returns always a complete notice document.
|
|
591
|
+
*/
|
|
592
|
+
function readNotice(path, log) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
595
|
+
const shows = Number.isInteger(parsed?.shows) && parsed.shows >= 0 ? Math.min(parsed.shows, NOTICE_MAX_SHOWS) : 0
|
|
596
|
+
const lastAt = Number.isFinite(parsed?.lastAt) && parsed.lastAt >= 0 ? parsed.lastAt : 0
|
|
597
|
+
const closed = NOTICE_FINAL.has(parsed?.closed) ? parsed.closed : null
|
|
598
|
+
return { version: NOTICE_VERSION, shows, lastAt, closed }
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (error.code !== 'ENOENT') log.warn(`${LOG_PREFIX} ignoring ${path}: ${String(error.message ?? error)}`)
|
|
601
|
+
return emptyNotice()
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Persist the notice document atomically, matching the settings writer.
|
|
607
|
+
* @param path - absolute path of the document.
|
|
608
|
+
* @param notice - already-normalized notice document.
|
|
609
|
+
* @param log - warning sink for a failed write.
|
|
610
|
+
* @returns true when the document reached disk.
|
|
611
|
+
*/
|
|
612
|
+
function writeNotice(path, notice, log) {
|
|
613
|
+
try {
|
|
614
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
615
|
+
const staged = `${path}.tmp`
|
|
616
|
+
writeFileSync(staged, `${JSON.stringify(notice, null, 2)}\n`)
|
|
617
|
+
renameSync(staged, path)
|
|
618
|
+
return true
|
|
619
|
+
} catch (error) {
|
|
620
|
+
log.warn(`${LOG_PREFIX} cannot write ${path}: ${String(error.message ?? error)}`)
|
|
621
|
+
return false
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Decide whether the card is due, and say why.
|
|
627
|
+
*
|
|
628
|
+
* The order matters: a closed card stays closed whatever the counters say, and
|
|
629
|
+
* a spent show budget outranks the cooldown so the reason reported to the
|
|
630
|
+
* browser is the one that will not change on the next reload.
|
|
631
|
+
* @param notice - stored notice document.
|
|
632
|
+
* @param now - clock, injected for tests.
|
|
633
|
+
* @returns `{ show, reason }` with a stable reason string.
|
|
634
|
+
*/
|
|
635
|
+
export function noticeDecision(notice, now = Date.now()) {
|
|
636
|
+
if (notice?.closed === 'thanks' || notice?.closed === 'issue') return { show: false, reason: 'answered' }
|
|
637
|
+
if ((notice?.shows ?? 0) >= NOTICE_MAX_SHOWS) return { show: false, reason: 'capped' }
|
|
638
|
+
if (notice?.lastAt > 0 && now - notice.lastAt < NOTICE_COOLDOWN_MS) return { show: false, reason: 'cooling' }
|
|
639
|
+
return { show: true, reason: (notice?.shows ?? 0) === 0 ? 'first' : 'again' }
|
|
640
|
+
}
|
|
641
|
+
|
|
534
642
|
/* -------------------------------------------------------------------------- *
|
|
535
643
|
* upstream
|
|
536
644
|
* -------------------------------------------------------------------------- */
|
|
@@ -742,6 +850,11 @@ export function apply(ctx, config = {}) {
|
|
|
742
850
|
const cacheMs = Number.isFinite(config.cacheMs) && config.cacheMs >= 0 ? config.cacheMs : DEFAULTS.cacheMs
|
|
743
851
|
const settingsPath = join(harnessHome(), SETTINGS_FILENAME)
|
|
744
852
|
const legacySettingsPath = join(harnessHome(), LEGACY_SETTINGS_FILENAME)
|
|
853
|
+
const noticePath = join(harnessHome(), NOTICE_FILENAME)
|
|
854
|
+
|
|
855
|
+
let notice = readNotice(noticePath, log)
|
|
856
|
+
/** Last decision served, so reloads inside the throttle window cannot spend two shows. */
|
|
857
|
+
let noticeAnswer
|
|
745
858
|
|
|
746
859
|
let settings = readSettings(settingsPath, log)
|
|
747
860
|
if (!existsSync(settingsPath) && existsSync(legacySettingsPath)) {
|
|
@@ -981,4 +1094,69 @@ export function apply(ctx, config = {}) {
|
|
|
981
1094
|
return jsonResponse({ ok: false, code: 'noQuote', message: '上游没有这个标的的报价' }, 404)
|
|
982
1095
|
},
|
|
983
1096
|
})
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The thank-you card. GET answers whether it is due and counts the showing;
|
|
1100
|
+
* POST records what the operator did with it.
|
|
1101
|
+
*
|
|
1102
|
+
* The host owns both halves on purpose: `localStorage` is origin-scoped, and
|
|
1103
|
+
* the desktop shell binds a fresh loopback port on every launch, so a browser
|
|
1104
|
+
* flag would bring the card back on each restart.
|
|
1105
|
+
*/
|
|
1106
|
+
ctx.connection.fetch.register({
|
|
1107
|
+
path: NOTICE_PATH,
|
|
1108
|
+
methods: ['GET', 'POST'],
|
|
1109
|
+
requestBody: 'buffered',
|
|
1110
|
+
fetch: async (request) => {
|
|
1111
|
+
if (request.method !== 'POST') {
|
|
1112
|
+
const now = Date.now()
|
|
1113
|
+
if (noticeAnswer !== undefined && now - noticeAnswer.at < NOTICE_THROTTLE_MS) {
|
|
1114
|
+
return jsonResponse(noticeAnswer.body)
|
|
1115
|
+
}
|
|
1116
|
+
const decision = noticeDecision(notice, now)
|
|
1117
|
+
let body
|
|
1118
|
+
if (decision.show) {
|
|
1119
|
+
notice = { ...notice, shows: notice.shows + 1, lastAt: now }
|
|
1120
|
+
writeNotice(noticePath, notice, log)
|
|
1121
|
+
body = { ok: true, show: true, reason: decision.reason, shows: notice.shows }
|
|
1122
|
+
} else {
|
|
1123
|
+
body = { ok: true, show: false, reason: decision.reason, shows: notice.shows }
|
|
1124
|
+
}
|
|
1125
|
+
noticeAnswer = { at: now, body }
|
|
1126
|
+
return jsonResponse(body)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// The body is one short action, so it is read as text and bounded before parsing.
|
|
1130
|
+
let text
|
|
1131
|
+
try {
|
|
1132
|
+
text = await request.text()
|
|
1133
|
+
} catch {
|
|
1134
|
+
return jsonResponse({ ok: false, code: 'badJson', message: '请求体不是合法的 JSON' }, 400)
|
|
1135
|
+
}
|
|
1136
|
+
if (text.length > NOTICE_MAX_BODY) {
|
|
1137
|
+
return jsonResponse({ ok: false, code: 'tooLarge', message: '请求体过大' }, 413)
|
|
1138
|
+
}
|
|
1139
|
+
let body
|
|
1140
|
+
try {
|
|
1141
|
+
body = JSON.parse(text)
|
|
1142
|
+
} catch {
|
|
1143
|
+
return jsonResponse({ ok: false, code: 'badJson', message: '请求体不是合法的 JSON' }, 400)
|
|
1144
|
+
}
|
|
1145
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
1146
|
+
if (!NOTICE_ACTIONS.has(action)) {
|
|
1147
|
+
return jsonResponse({ ok: false, code: 'badAction', message: 'action 必须是 thanks / issue / later' }, 400)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// "thanks" and "issue" close the card for good; "later" only restarts the cooldown.
|
|
1151
|
+
notice = action === 'later'
|
|
1152
|
+
? { ...notice, lastAt: Date.now() }
|
|
1153
|
+
: { ...notice, closed: action }
|
|
1154
|
+
if (!writeNotice(noticePath, notice, log)) {
|
|
1155
|
+
return jsonResponse({ ok: false, code: 'writeFailed', message: '状态写入失败,请检查 DSH_HOME 权限' }, 500)
|
|
1156
|
+
}
|
|
1157
|
+
// The cached answer would still say `show: true`, so it is dropped here.
|
|
1158
|
+
noticeAnswer = undefined
|
|
1159
|
+
return jsonResponse({ ok: true, closed: notice.closed })
|
|
1160
|
+
},
|
|
1161
|
+
})
|
|
984
1162
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-market-watching",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH 侧栏行情卡片:加密货币、A
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "DSH 侧栏行情卡片:加密货币、A股/港股/美股、黄金、外汇、指数的实时价格与涨幅,可设置买入/做空价格与数量,实时显示持仓盈亏金额;标的在 DSH 设置页里自选,无需登录。",
|
|
5
|
+
"icon": "./icon.svg",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"dsh",
|
|
7
8
|
"dsh-plugin",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"index.js",
|
|
35
36
|
"http.mjs",
|
|
36
37
|
"client.js",
|
|
38
|
+
"icon.svg",
|
|
37
39
|
"cordis.patch.yml",
|
|
38
40
|
"README.md",
|
|
39
41
|
"README.en.md",
|