feat(remote): WeChat iLink adapter with one-step QR pairing (#1188) - #1404
Conversation
Second of three flat PRs re-landing mobile-companion Wave 1: a WeChat channel for the remote-control bridge, over Tencent's official iLink Bot API (bot_type=3, the WeChat ClawBot slot). Pure adapter against the post-#1390 PlatformPairer contract — no engine/supervisor/gateway change. Protocol verified live against the real iLink service (a bot_type=3 login plus a getupdates/sendmessage round-trip), which corrected three things a prior spike got wrong: qrcode_img_content is a liteapp URL, not an image, so the main process QR-encodes it (new qrcode dep); get_qrcode_status long-polls ~30s, so its client timeout is 40s; and the confirm response returns ilink_user_id (== inbound from_user_id), so pairing is one step — the scan + confirm in WeChat IS the binding, no "message the bot" round-trip. - remote-bridge: wechat/{client,login,platform}.ts iLink long-poll adapter - desktop: WeChatPairer (one-step), RemoteAccount/isAccount wechat variant, qr pairing-event phase, main-process QR encoding via qrcode - app: WeChat channel row + mark, QR sign-in connect dialog, en/zh copy - tests: real telegram+wechat multi-channel (replaces the test-only probe), wechat credential round-trip; snap covers the WeChat row + QR dialog Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (6)
📝 WalkthroughWalkthroughAdds WeChat as a second remote platform alongside Telegram. Introduces a new iLink API client ( ChangesWeChat Remote Platform
Sequence Diagram(s)sequenceDiagram
participant App as DialogConnectRemote
participant Bridge as RemoteBridge
participant Pairer as WeChatPairer
participant Client as WeChatClient
participant iLink as iLink API
App->>Bridge: startPairing("wechat")
Bridge->>Pairer: pair(signal, onProgress)
Pairer->>Client: getBotQrcode()
Client->>iLink: GET /getbotqrcode
iLink-->>Client: qrcode, qrcodeUrl
Pairer->>Pairer: qrDataUrl(qrcode)
Pairer->>Bridge: onProgress phase=qr, image
Bridge->>App: onPairing event phase=qr
Note over App: display QR image
loop until confirmed or expired
Pairer->>Client: getQrcodeStatus(qrcode)
Client->>iLink: long-poll GET
iLink-->>Client: waiting | expired | confirmed
end
Pairer->>Bridge: resolve RemoteAccount
Bridge->>App: onPairing event phase=captured
Note over App: WeChat auto-allow, no manual confirm
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Suggested priority: P2 (includes user-path files (packages/app/src/desktop-api-contract.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/remote/platform-marks.tsx, packages/app/src/pages/remote/remote-connect-dialog.tsx, packages/app/src/pages/remote/remote-surface.tsx, packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.test.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/main/remote-pairers.ts)).
P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.
iLink has no short "delivery window" — confirmed against Tencent's official @tencent-weixin/openclaw-weixin SDK and the Kun reference client, which send a plain FINISH reply minutes after the inbound message and still have it land. The real reason replies after the first per connection were silently dropped was missing protocol fields the server uses to consider the bot online: - iLink-App-Id / iLink-App-ClientVersion headers on every request - base_info on every POST (previously only on getupdates) - notifyStart before the first poll; notifyStop on stop - from_user_id + a fresh client_id per message; always message_state FINISH Removes the GENERATING placeholder-bubble streaming, which was built on the mistaken delivery-window theory and left a stuck "…" bubble in the chat. Verified live: short and long replies both deliver, including the second message in a connection (the case that previously failed). Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
修复:微信回复投递(真机端到端已验证)真机测试暴露的 bug:能发消息,但一轮连接里只有第一条消息能收到回复,之后的回复(尤其慢/长回复)被静默丢弃(send 返回 ret=0 但收不到)。 根因(之前的「投递窗口」假设是错的)对照腾讯官方包 iLink 根本没有「投递窗口」。官方/Kun 都是 agent 跑完(可达几分钟)再直接发一条 真正的原因是缺了几个协议件,服务器据此把 bot 当「未上线」,于是每轮连接只投第一条回复:
改动
验证
仍 deferred重启 app 后保存的 botToken 失效(login-session 绑定, |
notifyStart is what marks the bot online; iLink delivers only the first reply
per connection to a bot it hasn't seen it from. The previous best-effort
`.catch(() => {})` meant a failed notifyStart silently reproduced the
delivery-drop bug while the UI still reported "connected". Fold notifyStart into
the poll loop's existing discipline: a fatal token error rejects start(), a
transient one backs off and retries, and the ready/"connected" signal fires only
once the bot is actually online. notifyStop stays best-effort.
Tests lock the ordering (notifyStart precedes the first poll, notifyStop on stop)
and that a fatal notifyStart rejects start() without polling.
Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The connect dialog auto-confirmed pairing for any non-Telegram platform (`isQr = platform !== "telegram"`), which also gated skipping the token field. A future third platform would silently inherit auto-approve — granting a remote connection with no human vetting. Switch to an explicit `isWeChat = platform === "wechat"` so a new platform defaults to the safe manual-Allow path until it opts in here. No capability abstraction for two platforms; explicitness is the guard. Adds a render test locking the asymmetry: a `captured` event auto-confirms once for WeChat and never for Telegram. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The fields that fixed reply delivery were untested (the prior assertions used a lenient toMatchObject). Lock them so a regression can't silently re-drop replies: iLink-App-Id / iLink-App-ClientVersion headers and base_info on every POST, a send envelope with from_user_id and message_state FINISH, a fresh client_id per send, and notifyStart/notifyStop hitting their endpoints with base_info. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
pollWeChatLogin treated every non-API error as "pending", so a real defect would spin the QR forever with no signal. Keep the long-poll's expected exits (abort / client-side timeout) silent, surface API errors, and warn on anything else while still retrying. Adds login.ts unit tests (the previously untested pairing primitive): QR mint + empty/unreachable failures, and poll mapping for confirmed / expired / waiting / API error / timeout. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
Review response (P2 / P3a / P3b + a fresh delivery-path catch)Ran the review items past two independent reviewers (a fresh-eye agent + codex) plus my own read. Consensus, and what shipped (4 atomic commits on top of the delivery fix): New finding (codex, beyond the original review) — P2 — auto-approve test: DONE. Both reviewers agreed it's the highest-value gap (the P3b — explicit allowlist: DONE. P3a — delete login.ts: SKIPPED (kept), as both reviewers + I concluded. It's the structural twin of Telegram's Protocol regression tests (codex's "missed" note): DONE. The fields that fixed delivery had no assertions (lenient Effect architecture — evaluated, not adopted here. remote-bridge has zero Verification: remote-bridge 160 unit tests + app dialog render test pass; remote-bridge / desktop-electron / app typecheck clean; eslint clean on changed app files. |
A throwing MessageHandler previously propagated out of the dispatch loop and killed the poll, silently ending the channel after one bad message. Wrap each dispatch in try/catch, mirroring TelegramPoller. Also lock the reconnect semantics: a transient getUpdates blip backs off and retries without re-sending notifyStart (the `started` flag stays set), so "online" is asserted once per connection, not per poll. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
getQrcodeStatus accepted a "confirmed" payload as soon as a bot token was present, even if ilink_user_id was empty. That empty id becomes allowFrom — a saved account that accepts no one, persisted before the bridge starts. Require both fields; an incomplete confirm reports "waiting" so the poll keeps going. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
Lock the pairer's control flow: mint a QR, re-mint on expiry rather than dead-end, resolve to the scanned account on confirm, surface a login error as a throw, and return null on abort. Also note why the WeChat account carries no userName (iLink hands back only the user id, so identity() shows the raw id). Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
第二轮双重评审 + 落地按"是否最简洁、最优雅、最安心、最彻底"再评审了一轮(fresh-eye 子代理 + 自核),抓到 2 个真问题 + 2 处测试缺口,已全部修复。每个改动一个原子提交。 真问题1. handler 抛异常会拖垮整个 poll 循环 ( 2. confirm 缺 user id 仍被当成功 ( 测试补强 (
|
Several connected providers read better as distinct cards than as a packed hairline list. Each channel is now its own boxed row (logo · name · status pill · paired identity · action), stacked with a gap; the logo is enlarged and status moves into a colored pill (green connected, red degraded, outlined otherwise) to match the Integrations status idiom. PlatformMark takes an optional size class (default unchanged, so the connect dialog stays as-is). The snap's headline shot now drives two channels at once (Telegram connected + WeChat degraded) so the multi-provider layout is what the preview grid captures. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The bridge rebuilds on any channel change: connecting or disconnecting a *different* platform tears every channel down and back up. WeChat's stop() sent a notifyStop with no signal, so it flew detached from the abort (up to 15s) while stopBridge waited only 3s before the rebuild's notifyStart. An old "offline" could land after the new "online" and silently re-mark the bot offline — the same silent-drop the delivery fix removed, reintroduced from the other side. notifyStart is the only required lifecycle call (iLink treats a bot it has not seen start from as inactive); notifyStop is mere courtesy — iLink drops a bot that stops polling on its own. So stop() now just stops polling, and notifyStop is gone from the client, the transport interface, and the tests. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
login.ts was a thin wrapper pair (start/poll) over WeChatClient — a status rename plus long-poll error triage. Unlike Telegram's captureFirstSender (a stateful backlog-drain + capture primitive), it carried no state worth its own module, so it's removed and its logic lands where it belongs: - WeChatClient owns the protocol detail: getBotQrcode rejects an empty QR, and getQrcodeStatus maps its own long-poll TimeoutError to "waiting" (no state change yet) while real API/HTTP errors and caller aborts propagate. - WeChatPairer.pair owns the orchestration loop (confirm → account, expired → re-mint, API error → surface, transient → keep polling), mirroring how the Telegram pairer inlines captureFirstSender + its error wrap. Tests move with the logic: the empty-QR and timeout-as-waiting cases join client.test.ts; the loop/error/abort cases are covered by remote-pairers.test.ts (now spying WeChatClient's two network methods). login.test.ts is deleted. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
代码评审处置(逐条核查后)核查了四条,两条修复、一条已在前一轮修复、一条证据不成立 push back。 P1-a:notifyStop 反杀新连接 — 成立,已修(
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
packages/app/src/desktop-api-contract.ts (1)
116-117: ⚡ Quick winNarrow phase/platform combinations in
RemotePairingEvent.These variants currently allow impossible combinations at type level. Make phase-specific platform literals so invalid event shapes are rejected at compile time.
Proposed type tightening
export type RemotePairingEvent = - | { phase: "qr"; platform: RemotePlatform; image: string } - | { phase: "awaitingBind"; platform: RemotePlatform; hint: "message" } + | { phase: "qr"; platform: "wechat"; image: string } + | { phase: "awaitingBind"; platform: "telegram"; hint: "message" } | { phase: "captured"; platform: RemotePlatform; identity: { id: string; name: string } } | { phase: "error"; platform: RemotePlatform; message: string } | { phase: "cancelled"; platform: RemotePlatform }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/src/desktop-api-contract.ts` around lines 116 - 117, The RemotePairingEvent type variants are using a generic RemotePlatform type that allows impossible phase/platform combinations at compile time. For each variant in RemotePairingEvent (such as the "qr" phase and "awaitingBind" phase shown in the diff), replace the RemotePlatform type annotation with phase-specific platform literal types to restrict which platforms are valid for each phase, ensuring only valid event shapes can be created.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app/src/pages/remote/remote-connect-dialog.tsx`:
- Around line 83-85: The fire-and-forget call to api.startPairing(platform) in
the WeChat auto-start path does not handle promise rejections, which causes the
dialog to remain stuck in the "starting" phase if the IPC call fails. Add proper
error handling to catch any rejection from startPairing and set the phase state
to "error" when a failure occurs. This applies to the immediate WeChat flow
(isWeChat condition at line 83-85) and also to the other startPairing invocation
mentioned in the comment (lines 127-130).
In `@packages/desktop-electron/src/main/remote-pairers.ts`:
- Around line 89-93: The baseURL returned from the Tencent iLink API status (on
line 92 in the "wechat" platform handler) is accepted and persisted without
validation, which could allow malicious or compromised responses to inject
non-HTTPS URLs. Before returning the object with baseURL in the platform
"wechat" case, validate that status.baseURL is a properly formatted HTTPS URL
(check it starts with https:// and is a valid URL format). If validation fails,
reject the response or throw an error rather than persisting the invalid
baseURL.
In `@packages/remote-bridge/src/platforms/wechat/client.ts`:
- Around line 253-259: The parse method currently catches JSON parsing failures
and defaults to an empty object, which masks API failures when iLink returns
non-JSON 2xx responses and causes silent failures in polling logic. Instead of
catching the error from res.json() and returning an empty object, allow the
parse error to propagate or throw a descriptive WeChatApiError when JSON parsing
fails. This ensures that malformed responses are treated as actual API failures
rather than successful empty responses.
---
Nitpick comments:
In `@packages/app/src/desktop-api-contract.ts`:
- Around line 116-117: The RemotePairingEvent type variants are using a generic
RemotePlatform type that allows impossible phase/platform combinations at
compile time. For each variant in RemotePairingEvent (such as the "qr" phase and
"awaitingBind" phase shown in the diff), replace the RemotePlatform type
annotation with phase-specific platform literal types to restrict which
platforms are valid for each phase, ensuring only valid event shapes can be
created.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 4c91c361-6c8c-451e-92fa-8a59655a4185
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (20)
packages/app/e2e/snap/remote-surface.snap.tspackages/app/e2e/snap/wechat-qr.fixture.txtpackages/app/src/desktop-api-contract.tspackages/app/src/i18n/en.tspackages/app/src/i18n/zh.tspackages/app/src/pages/remote/platform-marks.tsxpackages/app/src/pages/remote/remote-connect-dialog.test.tspackages/app/src/pages/remote/remote-connect-dialog.tsxpackages/app/src/pages/remote/remote-surface.tsxpackages/desktop-electron/package.jsonpackages/desktop-electron/src/main/remote-bridge.test.tspackages/desktop-electron/src/main/remote-bridge.tspackages/desktop-electron/src/main/remote-credentials.test.tspackages/desktop-electron/src/main/remote-credentials.tspackages/desktop-electron/src/main/remote-pairers.test.tspackages/desktop-electron/src/main/remote-pairers.tspackages/remote-bridge/src/platforms/wechat/client.test.tspackages/remote-bridge/src/platforms/wechat/client.tspackages/remote-bridge/src/platforms/wechat/platform.test.tspackages/remote-bridge/src/platforms/wechat/platform.ts
Adding or removing one channel restarts the shared event stream for every channel, so startBridge re-marked all of them "connecting" — making every already-connected channel blink "connecting" when the user merely connected a second platform. It reads as "everything broke" even though nothing did. Keep a channel that is already connected as-is across a rebuild: skip it in the pre-mark, and suppress a supervisor "connecting" while it is connected. It updates only when it is serving again (confirmed) or degraded (a real failure), so adding one channel never makes the others look broken. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The zh strings called the product "你的智能体" (your agent) — vague, and off-brand next to the rest of the app, which says 爪印 (the brand test bans a standalone PawWork in zh). Name it: connect toast, page description, capabilities, the bind note, and the confirm body now say 爪印. Also smoothed two awkward lines and fixed a half-width comma in the bind body. English keeps its own "your agent" voice. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
操控 collided with the feature name 远程控制 and read oddly. Match the feature:
控制. The toast drops the redundant 远程 ("从 {{platform}} 控制" already implies it).
Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
Three ways a malformed response slipped through:
- parse() did res.json().catch(() => ({})), so a 2xx that isn't JSON (a proxy
login page, an HTML error) passed as an empty success. It now throws
"invalid JSON response"; non-2xx still throws with httpStatus so a 401/403 stays
fatal.
- A "confirmed" status missing the bot token or user id fell through to "waiting",
so the scanning user waited forever on a confirm that won't improve by polling.
It now throws — confirmed is terminal, so an incomplete one is an error.
- The confirmed baseurl (the host every later call trusts, then persisted) was
taken as-is. It's now required to be a well-formed https origin, rejecting
http/javascript:/malformed hosts centrally in the client.
Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The WeChat connect flow void-ed startPairing on mount/retry, so an IPC reject or main-side throw left the dialog stuck on "Preparing…" with the rejection unhandled. Funnel all three call sites through a beginPairing helper that awaits and lands a rejection on the error step. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
…ccess parse() accepted anything `typeof "object"`, but a JSON array satisfies that while not being the keyed response body the iLink API returns — it slipped through as a fake empty success. Exclude arrays so they fall to the same "invalid JSON response" throw as any other non-object body. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
…guards The render shim is one-shot, so the test asserts the real regression — a rejected startPairing is caught, not leaked into an unhandled rejection that hangs the dialog — rather than the reactive error-phase transition the harness can't observe. Rename to match. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
第四 + 第五轮评审处置逐条核查后的结论与改动(无 inline 线程,集中回这条): P1 — Windows advisory「失败」:push back(无关的瞬时安装错误)本分支 P2-a/b/c — 校验 iLink 响应再信任(已修
|
…t retry isFatalWeChatError only flagged HTTP 401/403, so a body-level session- expired ret (iLink's -14, returned 200) was retried forever — the channel either sat on "connecting" or kept showing "connected" while dead, with no prompt to re-scan. Classify -14 as fatal so the poll loop rejects start() and the supervisor degrades the channel. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
isHttpsUrl checked the scheme but kept the value verbatim, so a confirmed baseurl carrying embedded credentials or an extra path/query/fragment would be stored and concatenated with the bot token's request path — a redirect of authenticated calls. Reduce it to url.origin (rejecting userinfo / path / query / fragment) at confirm, and re-check the persisted value in makePlatform so a tampered credentials file is refused too. Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
第六轮评审处置P1 — 会话失效被当瞬时错误无限重试(已修
|
第七轮(待验证项)已满足
评审项已全部处置(P1 会话失效转 fatal、P2 baseURL 规范化为真问题已修;suppression / UI 卡片化 / 事件 union 收窄已 push back)。自动重登 + 渠道增量增删(不再整桥重启)为 Wave 2 #1414 单独跟进。 |
…ing every channel) (#1454) Connecting or disconnecting one remote channel no longer restarts the shared PawWork event stream or the other channels — only the affected channel starts or stops. Per-channel lifecycle becomes a first-class operation across the supervisor, gateway, and desktop runtime, and #1404's interim UI flap-suppression is removed. Prepare-first / swap-after-success on re-pair and disconnect, a beforeCommit credential-commit hook, a BridgeClosedError teardown/liveness contract, and best-effort session-pointer cleanup close the commit-order seams surfaced across review. Closes #1414. Part of #1188. Claude-Session: https://claude.ai/code/session_01Y3Z6Hbny6bzGoZJg8Xrjjr
Summary
Adds WeChat as a remote-control channel for the mobile-companion bridge, over Tencent's official iLink Bot API (
bot_type=3, the WeChat ClawBot slot) — a NAT-friendly HTTP long-poll, structurally like the Telegram adapter (no SDK, no public IP, no relay we operate). It is a pure adapter against the post-#1390PlatformPairercontract: no engine/supervisor/gateway change.platforms/wechat/):client.ts(iLink long-poll),login.ts(QR login → bot token + base URL + paired user id),platform.ts(thePlatform; noreconstructReplyCtx— iLink has no proactive push, so a restored push is logged and skipped).WeChatPairer(one-step QR pairing, main-process QR encoding viaqrcode),RemoteAccount/isAccountwechat variant, widenedPairingProgress.qrpairing-event phase; auto-approves on confirm because the in-WeChat tap already authorized), en/zh copy.Why
Second of three flat PRs re-landing mobile-companion Wave 1, after the #1390 multi-channel foundation. The iLink wire contract was verified live against the real service first (a
bot_type=3QR login + agetupdates/sendmessageround-trip with a real WeChat account), which corrected three things a prior spike got wrong:qrcode_img_contentis an imageliteapp.weixin.qq.comURLqrcodedep)get_qrcode_statuslong-polls ~30silink_user_id(== inboundfrom_user_id)Confirmed correct as-is: all field names,
channel_version=1.0.2, statusconfirmed, themessage_type/message_state/item enums, and thecontext_tokenreply mechanism.Related Issue
Part of #1188 (mobile companion). Follows #1390.
Human Review Status
Pending
Review Focus
remote-connect-dialog.tsx: WeChat auto-approves on thecapturedevent (the scan + in-WeChat confirm already authorized), where Telegram keeps the explicit approval step.qrRemotePairingEventphase and the widenedPairingProgress; main-process QR encoding so the renderer stays a dumb<img>.wechat/client.ts(40s status long-poll timeout,channel_version=1.0.2) and theexpired→ re-mint loop inWeChatPairer.qrcodedependency (see Risk Notes).Risk Notes
qrcode(+@types/qrcode) indesktop-electron. Required because iLink returns a login URL, not an image, so the QR must be encoded client-side; encoded in the main process so the renderer needs no new dep. Pure JS, no native module; it is also the lib the Feishu path (PR3) will reuse. This overturns the earlier "no new deps" expectation for this PR.qrcodebundles into the Electron main process (electron-vite); no native bindings, so no per-OS packaging concern. ThesafeStoragecredential store gains awechataccount variant — encryption already required on both OSes, unchanged.ret:-14session-expiry re-login path is implemented defensively but could not be live-tested (no real expiry observed).dev:desktopend-to-end in the real Electron app (click Connect → scan → confirm → message) is not yet run — it needs a live WeChat scan. Recommended before merge. Everything below it (renderer, runtime, credentials) is covered by snap + unit tests, and the iLink protocol itself was proven live.How To Verify
Screenshots or Recordings
Checklist
bug,enhancement,task,documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.app,ui,platform,harness,ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.P0,P1,P2,P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.Pending,Approved by @<reviewer>, orNot required: <reason>(default isPending; "not required" is restricted to bot-authored low-risk PRs).dev, and my PR title and commit messages use Conventional Commits in English.Summary by CodeRabbit
Release Notes
New Features
UI/UX Improvements
Bug Fixes