TestFlightAccount.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. /********************************
  2. TestFlight账户管理脚本
  3. 脚本作者: @NobyDa
  4. 脚本兼容: Surge4、QuantumultX、Loon(2.1.20 413+)
  5. 更新时间: 2024/04/15
  6. 主要功能:
  7. 1. 自动存储多个TestFlight账户,并自动合并APP列表,避免切换账户。
  8. 2. 账户内单个测试版APP允许多方共享:
  9. - 导出:点击测试版APP -> App详情 -> 描述 -> 复制底部密钥并分享给对方
  10. - 导入:TestFlight 右上角"兑换" -> 粘贴密钥 -> 弹出保存成功通知后刷新APP列表
  11. - 多方共享为实验性功能,双方都需要使用该脚本; 该功能主要解决某些APP的TF名额稀缺的问题
  12. 请注意,该脚本已经与"TF区域限制解除脚本"合并,如需使用该脚本请务必禁用它,否则可能出现APP安装异常
  13. *********************************
  14. Surge4 添加脚本:
  15. *********************************
  16. Surge模块地址:
  17. https://raw.githubusercontent.com/NobyDa/Script/master/Surge/Module/TestFlightAccount.sgmodule
  18. *********************************
  19. QuantumultX 添加脚本:
  20. *********************************
  21. QuantumultX重写引用地址:
  22. https://raw.githubusercontent.com/NobyDa/Script/master/TestFlight/TestFlightAccount.js
  23. 注:以上引用地址需要打开并使用KOP-XIAO资源解析器,如没有解析器请使用脚本配置:
  24. [rewrite_local]
  25. ^https:\/\/testflight\.apple\.com\/v\d\/(app|account|invite)s\/ url script-analyze-echo-response https://git.jojo21.top/shawenguan/Quantumult-X/raw/master/Scripts/testflight/TestFlightAccount.js
  26. [mitm]
  27. hostname = testflight.apple.com
  28. *********************************
  29. Loon 添加脚本:
  30. *********************************
  31. Loon插件地址:
  32. https://raw.githubusercontent.com/NobyDa/Script/master/Loon/Loon_TF_Account.plugin
  33. *********************************/
  34. const $ = API("TESTFLIGHT-ACCOUNT");
  35. const args = formatArgument(typeof $argument == "string" && $argument || '');
  36. $.env.isNode ? $request = $.read('Request') : null;
  37. const [obj, req, rsp] = [new Map(), $request, {}];
  38. const [k1, k2, k3] = ['x-session-id', 'x-request-id', 'x-session-digest'];
  39. const [list, appList, cacheInfo] = [$.read('AccountList') || {}, $.read('AppList') || {}, $.read('CachedInfo') || {}];
  40. $.debug = Number(args.debug) || ($.read('Debug') === 'true');
  41. $.EnableCache = !(Number(args.enableCache) === 0) || !($.read('EnableCache') === 'false');
  42. $.ForceIOSlist = Number(args.forceIOSlist) || ($.read('ForceIOSlist') === 'true');
  43. $.RequestTimeout = Number(args.timeout || $.read('Timeout')) || 30;
  44. runs()
  45. .then((resp) => {
  46. resp = ChangeBody(resp);
  47. rsp.body = resp.body || '{}';
  48. rsp.headers = formatHeaders(resp.headers || { 'Content-Type': 'application/json' });
  49. rsp.status = $.env.isQX ? `HTTP/1.1 ${resp.status || 200}` : resp.status || 200;
  50. delete rsp.headers['content-length'];
  51. delete rsp.headers['transfer-encoding']; //prevent issues in qx
  52. $.log(`Return to client: ${$.stringify(rsp)}`);
  53. })
  54. .catch(e => $.error(e.error || e.message || e))
  55. .finally(() => $.done($.env.isQX ? rsp : { response: rsp }));
  56. async function runs() {
  57. // Object.keys(list).map(a => delete list[a].only)
  58. req.headers = formatHeaders(req.headers); //compatible with HTTP/2
  59. const appID = req.url.split(/\/apps\/(\d+)/)[1];
  60. const other = /\/(accept|withdraw|devices|session|notifications|status)/.test(req.url);
  61. if (/accounts\/[a-z0-9-]{36}\/apps$/.test(req.url)) {
  62. const acc = SaveAccount(req.url.split(/\/([a-z0-9-]{36})\//)[1]);
  63. return await Promise.all(Object.keys(acc).map(QueryRequest));
  64. } else if (/\/install$/.test(req.url) && req.body) {
  65. req.body = JSON.parse(req.body);
  66. req.body.storefrontId = '143441-19,29'; //prevent regional restrictions
  67. req.body = JSON.stringify(req.body);
  68. } else if (/\/[A-Z]{200,}\/redeem$/.test(req.url)) {
  69. return { body: ExternalAccount(req.url.split(/\/([A-Z]+)\/redeem$/)[1]) };
  70. }
  71. return await QueryRequest(!other && appList[appID] || null);
  72. }
  73. function SaveAccount(id, part, o) {
  74. if (!list[id]) {
  75. list[id] = {};
  76. const text = `Account ID "${id}" saved. (total ${Object.keys(list).length}) 🎉`;
  77. $.notify('TestFlight Account', '', text);
  78. $.info(text);
  79. };
  80. list[id][k1] = (part || req.headers)[k1];
  81. list[id][k2] = (part || req.headers)[k2];
  82. list[id][k3] = (part || req.headers)[k3];
  83. if (o) {
  84. if (list[id].only) {
  85. list[id].only.push(o);
  86. $.notify('TestFlight Account', '', `App ID "${o}" saved 🎉`);
  87. } else {
  88. list[id].only = [o];
  89. }
  90. }
  91. return $.write(list, 'AccountList'), list;
  92. }
  93. function formatHeaders(h) {
  94. return Object.keys(h).reduce((t, i) => (t[i.toLowerCase()] = h[i], t), {})
  95. }
  96. function formatArgument(s) {
  97. return Object.fromEntries(s.split('&').map(item => item.split('=')))
  98. }
  99. function ChangeHeaders(id) {
  100. const re = JSON.parse(JSON.stringify(req)); //easy deep copy
  101. re.url = re.url.replace(/:\/\/.+?\//, '://testflight.apple.com/'); //prevent cdn issues
  102. re.timeout = $.RequestTimeout * 1000;
  103. re.insecure = true; //skip ssl
  104. re['X-Surge-Skip-Scripting'] = true; //prevent shadowrocket loopback issues
  105. if ($.ForceIOSlist && req.url.endsWith('/apps') && re.headers['user-agent'].includes('Mac')) {
  106. re.headers['user-agent'] = 'Oasis/3.5.1 OasisBuild/425.2 iOS/17.4 model/iPhone16,2 hwp/t8130 build/21E219 (6; dt:311) AMS/1 TSE/0';
  107. }
  108. if (id) {
  109. $.log(`Request header replaced, using "${id}"`);
  110. re.headers[k1] = list[id][k1];
  111. re.headers[k2] = list[id][k2];
  112. re.headers[k3] = list[id][k3];
  113. re.url = re.url.replace(/\/[a-z0-9-]{36}\//, `/${id}/`);
  114. }
  115. delete re.headers['if-none-match']; //prevent 304
  116. delete re.headers['content-length'];
  117. $.log(`Send request: ${$.stringify(re)}`);
  118. return re;
  119. }
  120. function ChangeBody(resp) {
  121. if (req.url.endsWith('/apps')) {
  122. resp = resp.reduce((t, d) => {
  123. d.body = JSON.parse(d.status == 200 && d.body || '{}');
  124. $.log(`Account "${d.account}" app list: ${$.stringify((d.body.data || []).map(i => i.name))}`);
  125. d.body.data = (d.body.data || []).map(i => {
  126. if ($.ForceIOSlist) {
  127. i.platforms = i.platforms.map(j => {
  128. if (j.name == 'osx') {
  129. $.log(`Account "${d.account}" app [${i.name}] force mac compatible`);
  130. j.build.compatible = true;
  131. j.build.platformCompatible = true;
  132. j.build.osCompatible = true;
  133. j.build.hardwareCompatible = true;
  134. }
  135. return j
  136. })
  137. }
  138. i.aid = d.account;
  139. const only = !list[d.account].only || list[d.account].only.includes(String(i.appAdamId));
  140. return only && t.body.data[req.url.includes(d.account) ? 'unshift' : 'push'](i), i;
  141. });
  142. if (req.url.includes(d.account)) {
  143. [t.status, t.headers] = [d.status, d.headers];
  144. }
  145. return t
  146. }, { body: { data: [], error: null } });
  147. resp.body.data = resp.body.data.filter(r => !r.previouslyTested && !obj.has(r.appAdamId) && obj.set(r.appAdamId, 1));
  148. $.write(resp.body.data.reduce((l, v) => (l[v.appAdamId] = v.aid, l), {}), 'AppList');
  149. $.log(`Final app: ${$.stringify(resp.body.data.map(i => i.name))}`);
  150. resp.body = JSON.stringify(resp.body);
  151. }
  152. if (/\/apps\/\d+\/builds\/\d+$/.test(req.url) && resp.status == 200 && resp.body) { //beta app page
  153. const share = ShareAccount(req.url.split(/\/apps\/(\d+)/)[1]);
  154. resp.body = JSON.parse(resp.body);
  155. resp.body.data.builds.map(e => e.description = `${e.description || '-'}${share}`);
  156. resp.body = JSON.stringify(resp.body);
  157. }
  158. return resp;
  159. }
  160. function QueryRequest(o) {
  161. const option = ChangeHeaders(o);
  162. const needCache = $.EnableCache && (option.url.endsWith('/apps') || /\/apps\/\d+\/builds\/\d+$/.test(req.url));
  163. return $.http[req.method.toLowerCase()](option)
  164. .then(r => {
  165. $.log(`URL "${option.url}" response: status=${r.status}, body=${Boolean(r.body)}`);
  166. if (r.status == 401 && o) {
  167. if (list[o].InvalidKey >= 2) { //prevent misjudgment
  168. delete list[o];
  169. } else {
  170. list[o].InvalidKey = (list[o].InvalidKey || 0) + 1;
  171. }
  172. $.write(list, 'AccountList');
  173. $.notify('TestFlight Account', '', `Account ID "${o}" key expired ⚠️`);
  174. throw 'key expired ⚠️';
  175. }
  176. if (needCache && r.status == 200 && r.body && r.body.startsWith('{')) {
  177. const cacheKey = (cacheInfo[option.url] && cacheInfo[option.url].key) || `TESTFLIGHT-ACCOUNT-${letterEncode(option.url.split(/\/\/.+?\/(.+)/)[1])}`;
  178. $.log(`Write to cache, URL "${option.url}", READ KEY "${cacheKey}"`);
  179. cacheInfo[option.url] = { key: cacheKey, lastUsed: Date.now() };
  180. Object.keys(cacheInfo).forEach((i) => (Date.now() - (cacheInfo[i].lastUsed || 0) > 864e5 * 7) && delete cacheInfo[i] && $.delete(`#${cacheKey}`));
  181. $.write(cacheInfo, 'CachedInfo');
  182. $.write(JSON.stringify(r), `#${cacheKey}`);
  183. }
  184. return { ...r, account: o }
  185. })
  186. .catch(e => {
  187. if (needCache && cacheInfo[option.url] && !(e).includes('key expired')) {
  188. $.log(`URL "${option.url}" Try using cached data`);
  189. const cachedData = $.read(`#${cacheInfo[option.url].key}`);
  190. cacheInfo[option.url].lastUsed = Date.now();
  191. !cachedData ? delete cacheInfo[option.url] : null;
  192. $.write(cacheInfo, 'CachedInfo');
  193. return { ...JSON.parse(cachedData || '{}'), account: o }
  194. }
  195. $.error(`URL "${option.url}" response failed: ${e}`);
  196. return { account: o }
  197. })
  198. }
  199. function ExternalAccount(key) {
  200. try {
  201. const k = JSON.parse(letterDecode(key));
  202. $.log(`Raw data: ${key}\nDecode data: ${$.stringify(k)}`);
  203. if (!k.appID || !k.accID || !k.key[k1] || !k.key[k2] || !k.key[k3]) {
  204. throw new Error('Missing data');
  205. } else if (appList[k.appID]) {
  206. $.notify('TestFlight Account', '', `Failed, app already exists ⚠️`);
  207. } else {
  208. const save = SaveAccount(k.accID, k.key, k.appID);
  209. }
  210. } catch (e) {
  211. const text = `External account parse failed`;
  212. $.notify('TestFlight Account', '', `${text} ⚠️`);
  213. $.error(`${text}: ${e.message || e}`);
  214. }
  215. return '{}'
  216. }
  217. function ShareAccount(appID) {
  218. const raw = $.stringify({
  219. appID: appID,
  220. accID: appList[appID],
  221. key: list[appList[appID]]
  222. });
  223. const key = letterEncode(raw);
  224. const disclaimer = `\n\n\n
  225. ================================
  226. TestFlight 账户管理脚本:
  227. 请注意,使用"共享"功能时,请务必仔细阅读以下声明 ‼️
  228. 请注意,使用"共享"功能时,请务必仔细阅读以下声明 ‼️
  229. 请注意,使用"共享"功能时,请务必仔细阅读以下声明 ‼️
  230. ================================
  231. 权限:
  232. 您即将共享的密钥理论上具有以下权限,包括但不限于:
  233. - 查看/下载您 TestFlight 账号内的任何测试版 APP
  234. - 使用您的密钥接受测试 TestFlight 中的任何测试版 APP
  235. - 停止测试您 TestFlight 账号内的任何测试版 APP
  236. - 查看您接受 TestFlight 测试版 APP 邀请时所使用的邮箱
  237. - 查看/加入/移除您 TestFlight 账号中的设备列表
  238. - 更改您 TestFlight 测试版 APP 中的推送/电子邮件更新通知
  239. 免责:
  240. 任何用户使用"共享"功能时都应该仔细阅读权限声明,一旦您开始使用该功能,即视为您已知晓并理解密钥所具有的权限,密钥泄漏可能会导致不可预知的损失或损害,脚本作者(NobyDa)不对由此产生的任何后果负责。
  241. ================================
  242. 该脚本在"默认"情况下,对方仅可查看/下载您共享的单个APP,但仍建议仅与您信任的人共享:
  243. `;
  244. $.log(`Raw data: ${raw}\nEncode data: ${key}`);
  245. return disclaimer + key;
  246. }
  247. // private encode method, based on variant in RFC4648
  248. function letterEncode(e) {
  249. e = e.split("").map(e => e.charCodeAt());
  250. const t = new Uint8Array(4 * Math.ceil(8 * e.length / 4));
  251. let n = 0;
  252. for (const o of e) {
  253. let e = 128;
  254. for (let r = 0; r < 8; r++) t[n++] = o & e ? 1 : 0, e >>= 1
  255. }
  256. let o = "",
  257. r = 0;
  258. return t.forEach((e, t) => {
  259. r = r << 1 | e, (t + 1) % 4 == 0 && (o += "XKNWSPRMCTGZVDHF"[r], r = 0)
  260. }), o
  261. }
  262. function letterDecode(e) {
  263. const t = new Uint8Array(4 * e.length);
  264. let n = 0;
  265. for (const o of e) {
  266. const e = "XKNWSPRMCTGZVDHF".indexOf(o);
  267. let r = 8;
  268. for (let o = 0; o < 4; o++) t[n++] = e & r ? 1 : 0, r >>= 1
  269. }
  270. const o = new Uint8Array(Math.floor(t.length / 8));
  271. return t.forEach((e, t) => {
  272. const n = Math.floor(t / 8);
  273. n < o.length && (o[n] = o[n] << 1 | e)
  274. }), String.fromCharCode(...o)
  275. }
  276. // https://github.com/Peng-YM/QuanX/tree/master/Tools/OpenAPI
  277. function ENV() { const a = "function" == typeof require && "undefined" != typeof $jsbox; return { isQX: "undefined" != typeof $task, isLoon: "undefined" != typeof $loon, isSurge: "undefined" != typeof $httpClient && "undefined" == typeof $loon, isShadowrocket: "undefined" != typeof $Shadowrocket, isBrowser: "undefined" != typeof document, isNode: "function" == typeof require && !a, isJSBox: a, isRequest: "undefined" != typeof $request, isScriptable: "undefined" != typeof importModule } } function HTTP(a = { baseURL: "" }) { function b(b, j) { j = "string" == typeof j ? { url: j } : j; const k = a.baseURL; k && !i.test(j.url || "") && (j.url = k ? k + j.url : j.url), j = { ...a, ...j }; const l = j.timeout, m = { ...{ onRequest: () => { }, onResponse: a => a, onTimeout: () => { } }, ...j.events }; m.onRequest(b, j); let n; if (c) n = new Promise((a, c) => { $task.fetch({ method: b, ...j }).then(b => a({ status: b.statusCode, headers: b.headers, body: b.body }), a => c(a.error)) }); else if (d || e || g) n = new Promise((a, c) => { var e = Math.ceil; const f = g ? require("request") : $httpClient; !j.timeout || g || d || (j.timeout = e(j.timeout / 1e3)), f[b.toLowerCase()](j, (b, d, e) => { b ? c(b) : a({ status: d.status || d.statusCode, headers: d.headers, body: e }) }) }); else if (f) { const a = new Request(j.url); a.method = b, a.headers = j.headers, a.body = j.body, n = new Promise((b, c) => { a.loadString().then(c => { b({ status: a.response.statusCode, headers: a.response.headers, body: c }) }).catch(a => c(a)) }) } else h && (n = new Promise((a, c) => { fetch(j.url, { method: b, headers: j.headers, body: j.body }).then(a => a.json()).then(b => a({ status: b.status, headers: b.headers, body: b.data })).catch(c) })); let o; const p = l ? new Promise((a, b) => { o = setTimeout(() => (m.onTimeout(), b(`timeout`)), l) }) : null; return (p ? Promise.race([p, n]).then(a => ("undefined" != typeof clearTimeout && clearTimeout(o), a)) : n).then(a => m.onResponse(a)) } const { isQX: c, isLoon: d, isSurge: e, isScriptable: f, isNode: g, isBrowser: h } = ENV(), i = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/, j = {}; return ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"].forEach(a => j[a.toLowerCase()] = c => b(a, c)), j } function API(a = "untitled", b = !1) { const { isQX: c, isLoon: d, isSurge: e, isNode: f, isJSBox: g, isScriptable: h } = ENV(); return new class { constructor(a, b) { this.name = a, this.debug = b, this.http = HTTP(), this.env = ENV(), this.node = (() => { if (f) { const a = require("fs"); return { fs: a } } return null })(), this.initCache(); const c = (a, b) => new Promise(function (c) { setTimeout(c.bind(null, b), a) }); Promise.prototype.delay = function (a) { return this.then(function (b) { return c(a, b) }) } } initCache() { if (c && (this.cache = JSON.parse($prefs.valueForKey(this.name) || "{}")), (d || e) && (this.cache = JSON.parse($persistentStore.read(this.name) || "{}")), f) { let a = "root.json"; this.node.fs.existsSync(a) || this.node.fs.writeFileSync(a, JSON.stringify({}), { flag: "wx" }, a => console.log(a)), this.root = {}, a = `${this.name}.json`, this.node.fs.existsSync(a) ? this.cache = JSON.parse(this.node.fs.readFileSync(`${this.name}.json`)) : (this.node.fs.writeFileSync(a, JSON.stringify({}), { flag: "wx" }, a => console.log(a)), this.cache = {}) } } persistCache() { const a = JSON.stringify(this.cache, null, 2); c && $prefs.setValueForKey(a, this.name), (d || e) && $persistentStore.write(a, this.name), f && (this.node.fs.writeFileSync(`${this.name}.json`, a, { flag: "w" }, a => console.log(a)), this.node.fs.writeFileSync("root.json", JSON.stringify(this.root, null, 2), { flag: "w" }, a => console.log(a))) } write(a, b) { if (this.log(`SET ${b}`), -1 !== b.indexOf("#")) { if (b = b.substr(1), e || d) return $persistentStore.write(a, b); if (c) return $prefs.setValueForKey(a, b); f && (this.root[b] = a) } else this.cache[b] = a; this.persistCache() } read(a) { if (this.log(`READ ${a}`), -1 !== a.indexOf("#")) { if (a = a.substr(1), e || d) return $persistentStore.read(a); if (c) return $prefs.valueForKey(a); if (f) return this.root[a] } else return this.cache[a] } delete(a) { if (this.log(`DELETE ${a}`), -1 !== a.indexOf("#")) { if (a = a.substr(1), e || d) return $persistentStore.write(null, a); if (c) return $prefs.removeValueForKey(a); f && delete this.root[a] } else delete this.cache[a]; this.persistCache() } notify(a, b = "", i = "", j = {}) { const k = j["open-url"], l = j["media-url"]; if (c && $notify(a, b, i, j), e && $notification.post(a, b, i + `${l ? "\n\u591A\u5A92\u4F53:" + l : ""}`, { url: k }), d) { let c = {}; k && (c.openUrl = k), l && (c.mediaUrl = l), "{}" === JSON.stringify(c) ? $notification.post(a, b, i) : $notification.post(a, b, i, c) } if (f || h) { const c = i + (k ? `\n点击跳转: ${k}` : "") + (l ? `\n多媒体: ${l}` : ""); if (g) { const d = require("push"); d.schedule({ title: a, body: (b ? b + "\n" : "") + c }) } else console.log(`${a}\n${b}\n${c}\n\n`) } } log(a) { this.debug && console.log(`[${this.name}] LOG: ${this.stringify(a)}`) } info(a) { console.log(`[${this.name}] INFO: ${this.stringify(a)}`) } error(a) { console.log(`[${this.name}] ERROR: ${this.stringify(a)}`) } wait(a) { return new Promise(b => setTimeout(b, a)) } done(a = {}) { c || d || e ? $done(a) : f && !g && "undefined" != typeof $context && ($context.headers = a.headers, $context.statusCode = a.statusCode, $context.body = a.body) } stringify(a) { if ("string" == typeof a || a instanceof String) return a; try { return JSON.stringify(a, null, 2) } catch (a) { return "[object Object]" } } }(a, b) }