LeagueAkari 客户端连接管线全拆解:多开环境下如何发现、接管并切换 LCU
【免费下载链接】League-ToolkitAn all-in-one toolkit for LeagueClient. Gathering power 🚀.项目地址: https://gitcode.com/gh_mirrors/le/League-Toolkit
LeagueAkari(仓库名 League-Toolkit)是一套面向英雄联盟玩家的 All-in-One 工具箱,从自动 Ban/Pick 到战绩分析都依赖同一个底座:与本地 League Client 建立安全、稳定、可感知状态变化的连接。这篇文章不铺开讲功能清单,而是沿着真实源码走一遍"客户端连接管理"这条管线——从进程命令行里挖出连接凭据,到建立 HTTPS/WebSocket 双通道,再到多开场景下的发现与切换。你会发现,所谓"多客户端支持",在 LeagueAkari 里是一套克制而巧妙的设计:全量发现、单连接、随时切换。
先说背景:LCU 到底是"谁",凭据藏在哪
英雄联盟客户端的现代版本中,游戏本体(LeagueClient.exe)与交互界面(LeagueClientUx.exe)是分离的。Ux 进程会在本机启动一个本地服务,暴露一套 REST API 和 WebSocket 接口,这就是常说的 LCU(League Client Update/Ux)接口。工具要接管客户端,本质上要做三件事:
- 发现:找到正在运行的
LeagueClientUx.exe进程; - 取凭据:拿到访问本地服务的端口、认证令牌;
- 建连:用 HTTPS 发请求、用 WSS 订阅事件。
难点在于第 2 步:端口和令牌不会写进配置文件,而是作为启动参数直接传给进程。也就是说,工具与客户端之间的第一道关卡,是解析另一个进程的命令行。LeagueAkari 用两个 shard 模块分工解决这些问题:LeagueClientUxMain负责进程侦察,LeagueClientMain负责建连与通信。
第一道关卡:进程侦察与命令行解析
先看LeagueClientUxMain的职责。它维护一个定时器,每 2 秒调用一次update(),把扫描结果写入状态:
// src/main/shards/league-client-ux/index.ts async update() { try { this.state.setLaunchedClients(await this._commandLineReader.read()) // ... 重置定时器 } catch (error) { this._logger.error(`Failed to get Ux command line`, error) } }setLaunchedClients写入的是一个数组——所有已启动的客户端实例都会被记录,这是多开支持的数据基础。真正的侦察动作发生在LeagueClientUxCommandLineReader里,它按平台和权限采用两套策略(src/main/shards/league-client-ux/ux-command-line-reader.ts):
- native 模式:通过 Windows 原生 addon(
getCommandLine1(pid))直接读取进程命令行,不依赖外部工具; - shell/WMI 模式:通过 PowerShell 查询,需要应用以管理员权限运行,适合 native 查询失败的场景。
代码里还有一个耐人寻味的细节:如果扫描到进程却连续 5 次拿不到命令行,就会置位hasClientButNoCommandLine状态,提示用户"有客户端但读不到凭据"——这通常是权限不足的信号,项目甚至提供了重建 WMI 的工具函数(rebuildWmi)。这套"降级 + 提示"的设计,是为了应对不同系统环境下命令行查询可能失败的现实。
拿到原始命令行后,解析逻辑非常直白,用一组正则从参数里抠出关键信息:
// src/main/shards/league-client-ux/ux-command-line-parser.ts const portRegex = /--app-port=([0-9]+)/ const remotingAuth = /--remoting-auth-token=([\w-_]+)/ const pidRegex = /--app-pid=([0-9]+)/ // Some clients use `--rso_platform_id`, others use `--rso-platform-id`. const rsoPlatformIdRegex = /--rso[_-]platform[_-]id=([\w-_]+)/i const regionRegex = /--region=([\w-_]+)/ export function parseCommandLine(s: string): UxCommandLine | null { const [, port] = s.match(portRegex) || [] const [, password] = s.match(remotingAuth) || [] const [, pid] = s.match(pidRegex) || [] if (!port || !password || !pid) { return null } return { port: Number(port), pid: Number(pid), authToken: password, rsoPlatformId, region, certificate: RIOT_CERTIFICATE, // ... } }值得注意的两点:一是rsoPlatformIdRegex用[_-]兼容了两种历史写法,说明这套解析在真实环境里被反复打磨过;二是返回结构里带了一个硬编码的 Riot 根证书(RIOT_CERTIFICATE),用于后续 TLS 握手。
第二道关卡:状态机驱动的双通道安全建连
凭据到手,接下来是连接管理。LeagueClientMain用一个三态状态机描述连接生命周期:disconnected→connecting→connected(src/main/shards/league-client/state.ts),并用一个connectingClient字段表示"当前想连谁"。连接入口是connect(auth),它把目标写进状态,随后由_doConnectingLoop接管:
// src/main/shards/league-client/index.ts private async _doConnectingLoop() { while (true) { // 连接途中,目标丢失,停止连接 if (!this.state.connectingClient) { break } // 目标已不在启动列表中,停止连接 if ( !this._shouldHaveOneAttempt && !this._leagueClientUx.state.launchedClients.find( (c) => c.pid === this.state.connectingClient?.pid ) ) { this.state.setConnectingClient(null) break } try { await this._connectToLcu(this.state.connectingClient) this.state.setConnectingClient(null) // finished connecting! break } catch (error) { if ((error as any).code !== 'ECONNREFUSED') { // 非"连接被拒",说明是硬错误,直接放弃 break } } await sleep(LeagueClientMain.CONNECT_TO_LC_RETRY_INTERVAL) // 2s } }这个循环的精妙之处在于它的终止条件:除了连接成功,还处理了"目标消失"和"非 ECONNREFUSED 硬错误"两种情况。客户端在启动过程中端口尚未就绪时,会反复抛ECONNREFUSED,此时按 2 秒间隔重试;一旦客户端退出或发生真正的异常,立即止损,不会空转。
真正的建连动作_connectToLcu同时打通两条通道:
通道一:WSS WebSocket,负责实时事件。使用wss://riot:${authToken}@127.0.0.1:${port}建立连接,请求头里带上 Basic Auth,rejectUnauthorized: false配合前面拿到的 Riot 证书完成 TLS 握手。连接建立后,立即向服务端发送订阅指令:
// src/main/shards/league-client/index.ts this._webSocket = await this._wsPromisified( `wss://riot:${cmd.authToken}@127.0.0.1:${cmd.port}`, { headers: { Authorization: `Basic ${Buffer.from(`riot:${cmd.authToken}`).toString('base64')}` }, rejectUnauthorized: false } ) for (const endpoint of SUBSCRIBED_LCU_ENDPOINTS) { this._webSocket.send(JSON.stringify([5, endpoint])) } this._webSocket.on('message', (msg) => { try { const data = JSON.parse(msg.toString()) this._eventBus.emit(data[2].uri, data[2]) // 按 URI 分发到事件总线 } catch {} })事件被解析后按uri(如/lol-gameflow/v1/gameflow-phase)分发到一个RadixEventEmitter事件总线,任何模块都能监听,渲染进程也能通过subscribeLcuEndpoint按需订阅。
通道二:HTTPS 客户端,负责命令下发。用 axios 创建一个指向https://127.0.0.1:${port}的实例,同样的 Basic Auth,并开启axiosRetry做两次自动重试。建连后先请求/riotclient/auth-token做一次 PING 验证,成功才认为连接真正建立,最后把凭据写入存储供断线恢复使用:
// src/main/shards/league-client/index.ts private async _initHttpInstance(auth: UxCommandLine) { this._httpClient = axios.create({ baseURL: `https://127.0.0.1:${auth.port}`, headers: { Authorization: `Basic ${Buffer.from(`riot:${auth.authToken}`).toString('base64')}` }, httpsAgent: new https.Agent({ rejectUnauthorized: false }), timeout: LeagueClientMain.REQUEST_TIMEOUT_MS, // 17.5s proxy: false }) axiosRetry(this._httpClient, { retries: 2 }) await this._httpClient.get(LeagueClientMain.HTTP_PING_URL) this._leagueClientApi = new LeagueClientHttpApiAxiosHelper(this._httpClient) }多开场景的真正落点:全量发现、单连接、随时切换
现在回到"多客户端支持"这个标题本身。参考同类工具常见的"为每个客户端实例维护独立连接"的做法,LeagueAkari 的选择更克制:同时只维护一条活跃连接,但始终感知所有已启动的客户端。这套逻辑在_watchConnection中体现得很清楚:
// src/main/shards/league-client/index.ts // 当客户端唯一时,自动连接到该 LeagueClient this._mobxUtils.reaction( () => [ this.settings.autoConnect, this._leagueClientUx.state.launchedClients, this.state.connectionState ] as const, async ([s, c, conn]) => { if (conn === 'connected') { return } if (s) { if (c.length === 1) { if (!this._manuallyDisconnected) { this.state.setConnectingClient(c[0]) } } else { this.state.setConnectingClient(null) } } }, { fireImmediately: true } )翻译成人话就是:开了多个客户端时不自动连(避免抢焦点),只开一个时自动连上。多开时,玩家在启动面板里能看到"其他客户端"列表,点谁连谁。这个列表还做了体验优化——用peekClient在正式连接前就请求对方的召唤师信息和头像,让切换界面直接显示账号身份:
// src/main/shards/league-client/index.ts async peekClient(auth: UxCommandLine) { const c = axios.create({ /* ...同款 axios 配置... */ }) const { data: summoner } = await c.get<SummonerInfo>('/lol-summoner/v1/current-summoner') const { data: profileIcon } = await c.get( `/lol-game-data/assets/v1/profile-icons/${summoner.profileIconId}.jpg`, { responseType: 'arraybuffer' } ) // 返回 summoner 信息 + base64 头像 }渲染层对应的 UI 在src/renderer/src-main-window/views/player-tabs/components/StartupPane.vue:列表项显示头像、PID、服务器、游戏名,点击即调用连接逻辑,正在连接的项会转 spinner。整个"切换"的体验是:断开当前连接 → 连上目标客户端 → 所有自动化模块随状态机自动跟随。
多开之外,还有两个细节值得单独说。
自适应轮询间隔。LeagueClientUxMain默认每 2 秒扫描一次进程;一旦连接成功,就把间隔拉长到 60 秒,断开时立即恢复 2 秒并立刻补扫一次(src/main/shards/league-client/index.ts中_watchConnection的 reaction)。已连接状态下不需要高频侦察,这套动态降频既保实时性又省资源。
断线恢复。存在一种常见场景:玩家只关了 Ux 界面,游戏进程还活着。_tryResumeConnection把上次成功连接的凭据缓存到存储,启动时若发现"没有 Ux 但有一个 LeagueClient.exe",就用缓存凭据做一次 one-shot 重连尝试(_shouldHaveOneAttempt = true),避免必须重启游戏才能恢复工具。
把稳定性做进细节:限流、超时与事件转发
连接管线是工具所有功能的地基,LeagueAkari 在稳定性上做了几层防御,这些细节常常被忽略但很值得学习。
资源请求限流。大量游戏素材(头像、皮肤图)走同一个 HTTP 通道,如果并发无上限,容易拖垮客户端本地服务。request方法对lol-game-data/assets前缀的请求单独走一个PQueue限流器,并发上限 8:
// src/main/shards/league-client/index.ts private _assetLimiter = new PQueue({ concurrency: 8 }) async request<T = any, D = any>(config: AxiosRequestConfig<D>) { if (!this._httpClient) { throw new LeagueClientLcuUninitializedError() } if (config.url && config.url.startsWith('lol-game-data/assets')) { return this._limitedRequest(config, this._assetLimiter) } else { return this.http.request<T>(config) } }超时与重试分层。HTTP 请求超时 17.5 秒 +axiosRetry2 次;WebSocket 建连同样有 17.5 秒超时(_wsPromisified里的setTimeout),并处理unexpected-response等异常路径。未连接时任何请求都会抛出明确的LeagueClientLcuUninitializedError,而不是静默失败。
跨进程事件转发。渲染进程不能直接持有 WebSocket。subscribeLcuEndpoint返回一个自增 ID,在事件总线注册监听并把事件通过 IPC 推到渲染层;unsubscribeLcuEndpoint负责回收。这条"订阅-转发-注销"链路,把主进程的单一连接复用到所有窗口。
主进程还通过akari-protocol注册了league-client域,渲染进程可以用 HTTP 语义直呼 LCU 接口,请求会被转发到 axios 实例,并透传取消信号(AKARI_PROXY_REQUEST_ID_HEADER),实现请求级别的取消能力。
这条管线如何驱动整座自动化大厦
理解连接管线后,再看整个项目就豁然开朗了:LeagueClientMain是所有自动化 shard 的"心脏",通过构造函数注入到各个模块——AutoSelectMain靠它读写 Ban/Pick 状态,AutoGameflowMain靠它推进对局流程,对局内消息、战绩同步、装备推荐(writeItemSetsToDisk直接向安装目录写推荐配置)全都是这条连接上的乘客。
例如对局内自动消息这类功能,正是"连接 + 事件 + 命令"三件套的产物:WebSocket 订阅对局阶段事件 → 状态机判断当前处于 ARAM 选人/进游戏等节点 → 通过 HTTP 通道调用聊天接口发送预设消息:
图:连接成功后,自动化模块依据 LCU 事件自动向游戏内聊天发送阵营提示
二次开发启发:写一个自己的 shard
如果你想基于这条管线扩展新功能,最顺手的路径是仿照现有 shard 的结构。核心套路只有三步:
- 注册模块:用
@Shard(id)装饰器声明,实现IAkariShardInitDispose; - 注入依赖:构造函数里声明
private readonly _leagueClient: LeagueClientMain,框架的 DI 容器会自动注入,你的模块立刻拥有http、api、events、data四个入口; - 响应状态:用
this._mobxUtils.reaction监听connectionState和 LCU 事件,状态变化时执行自动化逻辑。
一个实用的组合是:reaction监听connectionState做初始化/清理,_leagueClient.events.on('/lol-gameflow/v1/gameflow-phase')监听对局阶段,再通过_leagueClient.http下发指令。连接切换时,你的模块只需要跟着状态机重置内部状态即可,不用关心底层连接细节。
上手体验与源码引导
想亲自验证这套管线的行为,最快的方式是本地跑起来:
git clone https://gitcode.com/gh_mirrors/le/League-Toolkit cd League-Toolkit yarn install yarn dev想深入源码,建议按这条路径阅读:
- 进程侦察与命令行解析:src/main/shards/league-client-ux/
- 连接状态机、双通道建连与重试逻辑:src/main/shards/league-client/index.ts
- 连接状态定义:src/main/shards/league-client/state.ts
- 多开切换界面:src/renderer/src-main-window/views/player-tabs/components/StartupPane.vue
- 订阅的 LCU 事件清单:src/shared/constants/subscribed-lcu-endpoints.ts
总结
LeagueAkari 的客户端连接管理,本质上是一套"侦察 → 取凭据 → 双通道建连 → 状态驱动切换"的完整管线。它没有追求"同时连接 N 个客户端"的激进形态,而是用全量发现 + 单连接 + 随时切换的设计,在实现复杂度、资源占用和用户体验之间取了平衡;再配合自适应轮询、断线恢复、限流与超时分层,把稳定性打磨到了生产级。对想理解 LCU 工具底层原理的开发者来说,这是一份非常值得通读的参考实现——代码量不大,但每个分支都来自真实环境的问题。
【免费下载链接】League-ToolkitAn all-in-one toolkit for LeagueClient. Gathering power 🚀.项目地址: https://gitcode.com/gh_mirrors/le/League-Toolkit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考