/** * @file utils/TGClient.ts * @desc 负责米游社客户端的 callback 处理 * @since Beta v0.3.8 */ import { event, invoke } from "@tauri-apps/api"; import type { Event } from "@tauri-apps/api/event"; import { appWindow, WebviewWindow } from "@tauri-apps/api/window"; import { parseLink } from "./linkParser"; import { createPost } from "./TGWindow"; import { getDeviceInfo } from "./toolFunc"; import showSnackbar from "../components/func/snackbar"; import { useAppStore } from "../store/modules/app"; import { useUserStore } from "../store/modules/user"; import TGConstant from "../web/constant/TGConstant"; import TGRequest from "../web/request/TGRequest"; import { getDS4JS } from "../web/utils/getRequestHeader"; // 正常 arg 参数 interface NormalArg { method: string; payload: any; callback: string; } // invoke 参数 interface InvokeArg { func: string; } /** * @class TGClient * @since Beta v0.3.8 * @description 米游社客户端 */ class TGClient { /** * @private 监听实例 * @since Beta v0.3.3 * @type {EventEmitter} * @memberof TGClient */ private listener: any; /** * @private 窗口实例 * @since Beta v0.3.4 * @type {WebviewWindow} * @memberof TGClient */ private window: WebviewWindow | null; /** * @private 模拟路由 * @since Beta v0.3.4 * @type {string[]} * @memberof TGClient */ private route: string[] = []; /** * @constructor * @since Beta v0.3.4 * @description 构造函数 * @memberof TGClient */ constructor() { try { this.window = WebviewWindow.getByLabel("mhy_client"); } catch (error) { this.window = null; } this.route = []; this.listener = undefined; } /** * @func run * @since Beta v0.3.4 * @desc 运行米游社客户端 * @returns {void} - 无返回值 */ async run(): Promise { if (this.listener === undefined) { this.listener = await event.listen("post_mhy_client", async (arg: Event) => { await this.handleCallback(arg); }); } } /** * @func loadJSBridge * @since Beta v0.3.8 * @desc 加载 JSBridge * @returns {void} - 无返回值 */ async loadJSBridge(): Promise { const executeJS = `javascript:(function() { if(window.MiHoYoJSInterface) return; window.MiHoYoJSInterface = { postMessage: function(arg) { window.__TAURI__.event.emit('post_mhy_client', arg) }, closePage: function() { this.postMessage('{"method":"closePage"}') }, }; })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); } /** * @func hideSideBar * @since Beta v0.3.5 * @desc 隐藏侧边栏 * @returns {void} - 无返回值 */ async hideSideBar(): Promise { const executeJS = `javascript:(function(){ if (document.getElementById('teyvat_style')) { return; } else { let style = document.createElement('style'); style.innerHTML = '::-webkit-scrollbar{display:none}'; style.id = 'teyvat_style'; document.querySelector('body').appendChild(style); } })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); } /** * @func hideOverlay * @since Beta v0.3.7 * @desc 隐藏遮罩 * @returns {void} */ async hideOverlay(): Promise { const executeJS = `javascript:(function(){ if (document.getElementById('mihoyo_landscape') !== null) { let box = document.getElementById('mihoyo_landscape'); box.remove(); } })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); } /** * @func getUrl * @since Beta v0.3.8 * @desc 获取 url * @param {string} func - 方法名 * @returns {string} - url */ getUrl(func: string): string { switch (func) { case "sign_in": return "https://act.mihoyo.com/bbs/event/signin/hk4e/index.html?act_id=e202311201442471&bbs_auth_required=true&bbs_presentation_style=fullscreen&mhy_presentation_style=fullscreen&utm_source=bbs&utm_medium=ys&utm_campaign=icon"; case "game_record": return "https://webstatic.mihoyo.com/app/community-game-records/index.html?bbs_presentation_style=fullscreen"; case "daily_note": return "https://webstatic.mihoyo.com/app/community-game-records/index.html?bbs_presentation_style=fullscreen#/ys/daily/"; case "tavern": return "https://m.miyoushe.com/ys/#/home/26"; case "birthday": return "https://webstatic.mihoyo.com/ys/event/e20220303-birthday/index.html?activity_id=20220301153521"; case "toolbox": return "https://webstatic.mihoyo.com/bbs/event/e20200511toolbox/index.html?game_biz=ys_cn"; default: return "https://api-static.mihoyo.com/"; } } /** * @func open * @since Beta v0.3.7 * @desc 打开米游社客户端 * @param {string} func - 方法名 * @param {string} url - url * @returns {void} - 无返回值 */ async open(func: string, url?: string): Promise { if (this.window !== null) { try { await this.window.close(); } catch (e) { console.error(e); await invoke("create_mhy_client", { func: "default", url: "https://api-static.mihoyo.com/", }); await this.open(func, url); } } if (url === undefined) url = this.getUrl(func); this.route = [url]; console.log(`[open] ${url}`); await invoke("create_mhy_client", { func, url }); this.window = WebviewWindow.getByLabel("mhy_client"); await this.window?.show(); await this.window?.setFocus(); await this.loadJSBridge(); } /** * @func handleCustomCallback * @since Beta v0.3.8 * @desc 处理自定义的 callback * @param {Event} arg - 事件参数 * @returns {any} - 返回值 */ async handleCustomCallback(arg: Event): Promise { const { method, payload } = JSON.parse(arg.payload); switch (method) { case "teyvat_open": createPost(payload); break; default: console.warn(`[${arg.windowLabel}] ${arg.payload}`); } } /** * @func handleCallback * @since Beta v0.3.8 * @desc 处理米游社客户端的 callback * @param {Event} arg - 事件参数 * @returns {any} - 返回值 */ async handleCallback(arg: Event): Promise { const { method, payload, callback } = JSON.parse(arg.payload); if (method.startsWith("teyvat")) { await this.handleCustomCallback(arg); return; } console.log(`[${arg.windowLabel}] ${arg.payload}`); await this.hideSideBar(); await this.hideOverlay(); switch (method) { case "closePage": await this.closePage(); break; case "configure_share": break; case "eventTrack": await this.eventTrack(payload); break; case "getStatusBarHeight": await this.getStatusBarHeight(callback); break; case "genAuthKey": await this.genAuthKey(payload, callback); break; case "getCookieInfo": await this.getCookieInfo(payload, callback); break; case "getCookieToken": await this.getCookieToken(callback); break; case "getActionTicket": await this.getActionTicket(payload, callback); break; case "getHTTPRequestHeaders": await this.getHTTPRequestHeaders(callback); break; case "getDS": await this.getDS(1, callback, payload); break; case "getDS2": await this.getDS(2, callback, payload); break; case "getUserInfo": await this.getUserInfo(callback); break; case "login": await this.nullCallback(arg); break; case "onBeginDragging": await this.nullCallback(arg); break; case "onClickImg": await this.onClickImg(payload); break; case "openApplication": await this.openApplication(payload); break; case "pushPage": await this.pushPage(payload); break; case "share": await this.share(payload, callback); break; case "share2": await this.nullCallback(arg); break; default: console.warn(`[${arg.windowLabel}] ${arg.payload}`); } } /** * @func callback * @since Beta v0.3.4 * @desc 回调函数 * @param {string} callback - 回调函数名 * @param {object} data - 回调数据 * @returns {void} - 无返回值 */ async callback(callback: string, data: object): Promise { const response = { retcode: 0, message: "success", data: data ?? {}, }; const js = `javascript:mhyWebBridge("${callback}", ${JSON.stringify(response)});`; console.info(`[callback] ${js}`); await invoke("create_mhy_client", { func: "execute_js", url: "" }); await invoke("execute_js", { label: "mhy_client", js }); } /** * @func getStatusBarHeight * @since Beta v0.3.4 * @desc 获取状态栏高度 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getStatusBarHeight(callback: string): Promise { const data = { statusBarHeight: 0, }; await this.callback(callback, data); } /** * @func genAuthKey * @since Beta v0.3.7 * @desc 获取米游社客户端的 authkey * @param {Record} payload - 请求参数 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async genAuthKey(payload: Record, callback: string): Promise { const userStore = useUserStore(); const cookie = { mid: userStore.cookie.mid, stoken: userStore.cookie.stoken, }; const res = await TGRequest.User.getAuthkey2(cookie, payload); await this.callback(callback, res.data); } /** * @func getCookieInfo * @since Beta v0.3.4 * @desc 获取米游社客户端的 cookie * @param {unknown} payload - 请求参数 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getCookieInfo(payload: unknown, callback: string): Promise { const user = useUserStore(); const data = { ltoken: user.cookie.ltoken, ltuid: user.cookie.ltuid, login_ticket: "", }; await this.callback(callback, data); } /** * @func getCookieToken * @since Beta v0.3.4 * @todo 待完善 * @desc 获取米游社客户端的 cookie_token * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getCookieToken(callback: string): Promise { const user = useUserStore(); const executeJS = "javascript:(function(){" + `document.cookie = "account_id=${user.cookie.account_id};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "cookie_token=${user.cookie.cookie_token};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "ltoken=${user.cookie.ltoken};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "ltuid=${user.cookie.ltuid};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "stuid=${user.cookie.stuid};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "stoken=${user.cookie.stoken};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + `document.cookie = "mid=${user.cookie.mid};domain=.mihoyo.com;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;";` + "})();"; console.info(`[getCookieToken] ${executeJS}`); await invoke("execute_js", { label: "mhy_client", js: executeJS }); // callback const data = { cookie_token: user.cookie.cookie_token, }; await this.callback(callback, data); } /** * @func getActionTicket * @since Beta v0.3.4 * @desc 获取米游社客户端的 action_ticket\ * @param {unknown} payload - 请求参数 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getActionTicket(payload: any, callback: string): Promise { const actionType = payload.action_type; const user = useUserStore(); const uid = user.getCurAccount().gameUid; const mid = user.cookie.mid; const stoken = user.cookie.stoken; const ActionTicket = await TGRequest.User.bySToken.getActionTicket( actionType, stoken, mid, uid, ); await this.callback(callback, ActionTicket.data); } /** * @func getHTTPRequestHeaders * @since Beta v0.3.6 * @desc 获取米游社客户端的 HTTP 请求头 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getHTTPRequestHeaders(callback: string): Promise { const localFp = getDeviceInfo("device_fp"); let deviceInfo = useAppStore().deviceInfo; if (localFp === "0000000000000") { deviceInfo = await TGRequest.Device.getFp(deviceInfo); } const data = { "user-agent": TGConstant.BBS.UA_MOBILE, "x-rpc-client_type": "5", "x-rpc-device_id": deviceInfo.device_id, "x-rpc-app_version": TGConstant.BBS.VERSION, "x-rpc-device_fp": deviceInfo.device_fp, }; await this.callback(callback, data); } /** * @func getDS * @since Beta v0.3.4 * @desc 获取米游社客户端的 DS 参数 * @param {number} dsType - DS 类型 * @param {string} callback - 回调函数名 * @param {unknown} payload - 请求参数 * @returns {void} - 无返回值 */ async getDS(dsType: 1, callback: string, payload: undefined): Promise; async getDS(dsType: 2, callback: string, payload: any): Promise; async getDS(dsType: 1 | 2, callback: string, payload?: any): Promise { const saltType = dsType === 1 ? "lk2" : "common"; let ds: string; if (dsType === 2) { const { body, query } = payload; ds = getDS4JS(saltType, dsType, body, query); } else { ds = getDS4JS(saltType, dsType, undefined, undefined); } const data = { DS: ds, }; await this.callback(callback, data); } /** * @func getUserInfo * @since Beta v0.3.4 * @desc 获取米游社客户端的用户信息 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async getUserInfo(callback: string): Promise { const user = useUserStore(); const cookieToken = user.cookie.cookie_token; const accountId = user.cookie.account_id; const userInfo = await TGRequest.User.byCookie.getUserInfo(cookieToken, accountId); if ("retcode" in userInfo) { console.error(`[${callback}] ${userInfo.message}`); return; } await this.callback(callback, userInfo); } /** * @func pushPage * @since Beta v0.3.8 * @desc 打开米游社客户端的页面 * @param {unknown} payload - 请求参数 * @returns {void} - 无返回值 */ async pushPage(payload: any): Promise { const url = payload.page; const res = await parseLink(url, true); if (!res) { await appWindow.setFocus(); showSnackbar({ text: `未知链接:${url}`, color: "error", timeout: 3000, }); await new Promise((resolve) => { setTimeout(() => { resolve(); }, 3000); }); await this.window?.setFocus(); return; } if (typeof res !== "string") return; this.route.push(res); console.log(`[pushPage] ${res}`); const executeJS = `javascript:(function(){ window.location.href = '${res}'; })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); await this.loadJSBridge(); await this.hideSideBar(); await this.hideOverlay(); await this.window?.setFocus(); } /** * @func closePage * @since Beta v0.3.7 * @desc 关闭米游社客户端的页面 * @returns {void} - 无返回值 */ async closePage(): Promise { this.route.pop(); if (this.route.length === 0) { await this.window?.close(); return; } const url = this.route[this.route.length - 1]; const executeJS = `javascript:(function(){ window.location.href = '${url}'; })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); await this.loadJSBridge(); } /** * @func nullCallback * @since Beta v0.3.4 * @desc 空回调函数 * @param {Event} arg - 回调参数 * @returns {void} - 无返回值 */ async nullCallback(arg: Event): Promise { const { callback } = JSON.parse(arg.payload); await this.callback(callback, {}); } /** * @func onClickImg * @since Beta v0.3.7 * @desc 点击图片,下载到本地 * @param {unknown} payload - 请求参数 * @returns {void} - 无返回值 */ async onClickImg(payload: any): Promise { const image = payload.image_list[0]; const executeJS = this.getSaveImgJS(image.url, image.format); await invoke("execute_js", { label: "mhy_client", js: executeJS }); } /** * @func getSaveImgJS * @since Beta v0.3.7 * @desc 获取保存图片的 JS * @param {string} url - 图片链接 * @param {string} format - 图片格式 * @returns {string} - JS */ getSaveImgJS(url: string, format: string): string { return `javascript:(async function() { const _t = window.__TAURI__; const defaultPath = await _t.path.downloadDir() + Date.now() + '.${format}'; const savePath = await _t.dialog.save({ title: '保存图片', filters: [{ name: '图片', extensions: ['png'] }], defaultPath: defaultPath, }); if (savePath) { const resBlob = await _t.http.fetch('${url}',{ method: 'GET', responseType: _t.http.ResponseType.Binary }); const buffer = new Uint8Array(resBlob.data); await _t.fs.writeBinaryFile({ contents: buffer, path: savePath, }); alert('保存成功'); } })();`; } /** * @func share * @since Beta v0.3.8 * @desc 分享 * @param {unknown} payload - 请求参数 * @param {string} callback - 回调函数名 * @returns {void} - 无返回值 */ async share(payload: any, callback: string): Promise { // 如果有数据 if (payload?.content.image_url !== undefined) { const image = payload.content.image_url; const format = image.split(".").pop(); const executeJS = this.getSaveImgJS(image, format); await invoke("execute_js", { label: "mhy_client", js: executeJS }); await this.callback(callback, {}); return; } if (payload?.type === "screenshot") { const executeJS = `javascript:(async function() { // 查找 id 为 dom2img 的 script var hasDom2img = false; var scripts = document.querySelectorAll('script'); for (var i = 0; i < scripts.length; i++) { if (scripts[i].src === 'https://cdn.bootcdn.net/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js') { hasDom2img = true; break; } } // 如果没有 dom2img 的 js if (!hasDom2img) { // 添加 dom2img 的 js var script = document.createElement('script'); script.src = 'https://cdn.bootcdn.net/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js'; document.body.appendChild(script); // 等待 dom2img 加载完成 await new Promise((resolve) => { script.onload = function() { resolve(); } }); } var shareDom; if(${JSON.stringify(payload?.content)} === '{}') { shareDom = document.querySelector('.share'); } else if (${payload?.content?.preview} === true) { shareDom = document.querySelector('#root'); } if(shareDom === undefined || shareDom === null) { shareDom = document.body; } var scale = 1.5; var img = await domtoimage.toPng(shareDom, { height: shareDom.scrollHeight * scale, width: shareDom.scrollWidth * scale, style: { transform: 'scale(' + scale + ')', transformOrigin: 'top left' } }); // 转换成 blob var buffer = new Uint8Array(atob(img.split(',')[1]).split('').map(function(item) { return item.charCodeAt(0); })); var _t = window.__TAURI__; var savePath = await _t.path.downloadDir() + Date.now() + '.png'; var save = await _t.dialog.save({ title: '保存图片', filters: [{ name: '图片', extensions: ['png'] }], defaultPath: savePath, }); if (save) { await _t.fs.writeBinaryFile({ contents: buffer, path: save, }); alert('保存成功'); } mhyWebBridge('${callback}', {}); })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); return; } if (payload?.type === "image") { if (payload?.content.image_base64 !== undefined) { let image = payload.content.image_base64; image = `data:image/png;base64,${image}`; const executeJS = `javascript:(async function() { // 转换成 blob var buffer = new Uint8Array(atob('${image}'.split(',')[1]).split('').map(function(item) { return item.charCodeAt(0); })); var _t = window.__TAURI__; var savePath = await _t.path.downloadDir() + Date.now() + '.png'; var save = await _t.dialog.save({ title: '保存图片', filters: [{ name: '图片', extensions: ['png'] }], defaultPath: savePath, }); if (save) { await _t.fs.writeBinaryFile({ contents: buffer, path: save, }); alert('保存成功'); } mhyWebBridge('${callback}', {}); })();`; await invoke("execute_js", { label: "mhy_client", js: executeJS }); return; } } console.warn("[share]", payload); // 延时 3s setTimeout(async () => { await this.callback(callback, {}); }, 10000); } /** * @func eventTrack * @since Beta v0.3.8 * @desc 事件跟踪 * @param {unknown} payload - 请求参数 * @returns {void} - 无返回值 */ async eventTrack(payload: unknown): Promise { console.log(`[eventTrack] ${JSON.stringify(payload)}`); await this.loadJSBridge(); } /** * @func openApplication * @since Beta v0.3.8 * @desc 打开应用 * @param {unknown} payload - 请求参数 * @returns {void} - 无返回值 */ async openApplication(payload: unknown): Promise { console.log(`[openApplication] ${JSON.stringify(payload)}`); await appWindow.setFocus(); showSnackbar({ text: `不支持的操作:OpenApplication(${JSON.stringify(payload)})`, color: "error", }); await new Promise((resolve) => { setTimeout(() => { resolve(); }, 1500); }); await this.window?.setFocus(); } } const mhyClient = new TGClient(); export default mhyClient;