🍎 新增 TestFlight 区域解锁脚本

This commit is contained in:
sve1r 2022-06-25 20:39:09 +08:00
parent cfe50f72a4
commit 7adbecd750
4 changed files with 457 additions and 0 deletions

View File

@ -0,0 +1,33 @@
# Apple 相关服务脚本
---
## 文件目录
```
1.TestFlightAccount.js
2.TestFlightDownload.conf
3.TestFlightDownload.js
```
---
### TestFlight账户管理脚本 (TestFlightAccount.js)
#### 脚本作者: @NobyDa
#### 脚本兼容: Surge4、QuantumultX、Loon(2.1.20 413+)
#### 主要功能:
- 自动存储多个TestFlight账户并自动合并APP列表避免切换账户。
- 账户内单个测试版APP允许多方共享
- 导出点击测试版APP -> 底部开发者许可协议 -> 复制密钥并分享给对方
- 导入TestFlight 右上角"兑换" -> 粘贴密钥 -> 弹出保存成功通知后刷新APP列表
- 多方共享为实验性功能,双方都需要使用该脚本; 该功能主要解决某些APP的TF名额稀缺的问题
```
请注意,该脚本已经与"TF区域限制解除脚本"合并如需使用该脚本请务必禁用它否则可能出现APP安装异常
以上引用地址需要打开并使用KOP-XIAO资源解析器如没有解析器请使用脚本配置
[rewrite_local]
^https:\/\/testflight\.apple\.com\/v\d\/(app|account|invite)s\/ url script-analyze-echo-response https://raw.githubusercontent.com/NobyDa/Script/master/TestFlight/TestFlightAccount.js
```
## TestFlight 区域限制解除脚本
#### 脚本作者: @NobyDa
```
该订阅仅适用于QuantumultX, 用于更新TestFlight App时, 提示"APP不可用"问题. 解除区域限制.
```

View File

@ -0,0 +1,416 @@
/********************************
TestFlight账户管理脚本
脚本作者: @NobyDa
脚本兼容: Surge4QuantumultXLoon(2.1.20 413+)
主要功能
1. 自动存储多个TestFlight账户并自动合并APP列表避免切换账户
2. 账户内单个测试版APP允许多方共享
- 导出点击测试版APP -> 底部开发者许可协议 -> 复制密钥并分享给对方
- 导入TestFlight 右上角"兑换" -> 粘贴密钥 -> 弹出保存成功通知后刷新APP列表
- 多方共享为实验性功能双方都需要使用该脚本 该功能主要解决某些APP的TF名额稀缺的问题
请注意该脚本已经与"TF区域限制解除脚本"合并如需使用该脚本请务必禁用它否则可能出现APP安装异常
以上引用地址需要打开并使用KOP-XIAO资源解析器如没有解析器请使用脚本配置
[rewrite_local]
^https:\/\/testflight\.apple\.com\/v\d\/(app|account|invite)s\/ url script-analyze-echo-response https://raw.githubusercontent.com/NobyDa/Script/master/TestFlight/TestFlightAccount.js
* ********************************/
const $ = API("TESTFLIGHT-ACCOUNT");
$.env.isNode ? $request = $.read('Request') : null;
const [arr, obj, req, rsp] = [[], new Map(), $request, {}];
const [k1, k2, k3] = ['x-session-id', 'x-request-id', 'x-session-digest'];
const [list, appList] = [$.read('AccountList') || {}, $.read('AppList') || {}];
$.debug = $.read('Debug') === 'true';
runs()
.catch(e => $.error(e.error || e.message || e))
.finally(() => {
const ret = {
...{
status: 200,
headers: {
'Content-Type': 'application/json'
}
},
...rsp
};
ret.headers = formatHeaders(ret.headers); //compatible with HTTP/2
ret.status = $.env.isQX ? `HTTP/1.1 ${ret.status}` : ret.status;
delete ret.headers['content-length'];
delete ret.headers['transfer-encoding']; //prevent issues in qx
$.log(`Return to client: ${$.stringify(ret)}`);
$.done($.env.isQX ? ret : {
response: ret
})
});
async function runs() {
// Object.keys(list).map(a => delete list[a].only)
req.headers = formatHeaders(req.headers); //compatible with HTTP/2
const appID = req.url.split(/\/apps\/(\d+)/)[1];
const build = req.url.split(/\/builds\/(\d+)/)[1];
const other = /\/(accept|withdraw|devices|session|notifications|status)/.test(req.url);
if (/accounts\/[a-z0-9-]{36}\/apps$/.test(req.url)) {
const acc = SaveAccount(req.url.split(/\/([a-z0-9-]{36})\//)[1]);
const all = await Promise.all(Object.keys(acc).map(QueryAppList));
const out = arr.filter(r => !r.previouslyTested && !obj.has(r.appAdamId) && obj.set(r.appAdamId, 1));
$.log(`Final app: ${$.stringify(out.map(i => i.name))}`);
if (out.length) {
rsp.body = $.stringify({
data: out,
error: null
});
$.write(out.reduce((l, v) => (l[v.appAdamId] = v.aid, l), {}), 'AppList');
}
} else if (/\/install$/.test(req.url) && req.body) {
req.body = JSON.parse(req.body);
req.body.storefrontId = '143380-1,29'; //prevent regional restrictions
req.body = $.stringify(req.body);
} else if (/\d+\/eula$/.test(req.url)) {
rsp.body = $.stringify(ShareAccount(appID, build));
} else if (/\/[A-Z]{200,}\/redeem$/.test(req.url)) {
rsp.body = ExternalAccount(req.url.split(/\/([A-Z]+)\/redeem$/)[1]);
}
if (!rsp.body) {
await QueryFallback(!other && appList[appID]);
}
}
function SaveAccount(id, part, o) {
if (!list[id]) {
list[id] = {};
const text = `Account ID "${id}" saved. (total ${Object.keys(list).length}) 🎉`;
$.notify('TestFlight Account', '', text);
$.info(text);
}
;
list[id][k1] = (part || req.headers)[k1];
list[id][k2] = (part || req.headers)[k2];
list[id][k3] = (part || req.headers)[k3];
if (o) {
if (list[id].only) {
list[id].only.push(o);
$.notify('TestFlight Account', '', `App ID "${o}" saved 🎉`);
} else {
list[id].only = [o];
}
}
return $.write(list, 'AccountList'), list;
}
function formatHeaders(h) {
return Object.keys(h).reduce((t, i) => (t[i.toLowerCase()] = h[i], t), {})
}
function ChangeHeaders(id) {
const re = JSON.parse(JSON.stringify(req)); //easy deep copy
if (id) {
$.log(`Request header replaced, using "${id}"`);
re.headers[k1] = list[id][k1];
re.headers[k2] = list[id][k2];
re.headers[k3] = list[id][k3];
re.url = re.url.replace(/\/[a-z0-9-]{36}\//, `/${id}/`);
}
if (typeof $Shadowrocket !== 'undefined') {
re.proxy = false; //prevent shadowrocket infinite loop
}
delete re.headers['if-none-match']; //prevent 304
delete re.headers['content-length'];
$.log(`Send request: ${$.stringify(re)}`);
return re;
}
function QueryFallback(o) {
return $.http[req.method.toLowerCase()](ChangeHeaders(o))
.then(r => {
$.log(`Received response: status=${r.statusCode}, body=${Boolean(r.body)}`);
[rsp.status, rsp.headers, rsp.body] = [r.statusCode, r.headers, r.body];
if (/\/apps\/\d+\/builds\/\d+$/.test(req.url) && r.body) { //beta app page
r.body = JSON.parse(r.body);
r.body.data.builds.map(e => e.eula = `https://testflight.apple.com/v1/apps/${e.appAdamId}/builds/${e.id}/eula`);
rsp.body = $.stringify(r.body);
}
})
.catch(e => $.error(`Response failed: ${e.error || e.message || e}`))
}
function QueryAppList(o) {
return $.http[req.method.toLowerCase()](ChangeHeaders(o))
.then(r => {
const m = req.url.includes(o);
$.log(`Received response: status=${r.statusCode}, body=${Boolean(r.body)}, account=${o}, main=${m}`);
if (m) {
[rsp.status, rsp.headers, rsp.body] = [r.statusCode, r.headers, r.body];
}
if (r.statusCode === 401) {
throw new Error('Key expires');
}
const res = JSON.parse(r.body || '{}');
$.log(`Account "${o}" app list: ${$.stringify((res.data || []).map(i => i.name))}`);
return (res.data || []).filter(i => (i.aid = o, !list[o].only || list[o].only.includes(String(i.appAdamId))))
.map(p => arr[m ? 'unshift' : 'push'](p))
}).catch(e => { //surge cannot get 401 in apple domain
if (/Key expires|NSURLErrorDomain.+?-1012/.test(e.error || e.message || e)) {
if (list[o].InvalidKey >= 2) { //prevent misjudgment
delete list[o];
} else {
list[o].InvalidKey = (list[o].InvalidKey || 0) + 1;
}
$.write(list, 'AccountList');
e = `key expired ⚠️`;
$.notify('TestFlight Account', '', `Account ID "${o}" ${e}`);
}
;
$.error(`Account "${o}" response failed: ${e.error || e.message || e}`);
})
}
function ExternalAccount(key) {
try {
const k = JSON.parse(letterDecode(key));
$.log(`Raw data: ${key}\nDecode data: ${$.stringify(k)}`);
if (!k.appID || !k.accID || !k.key[k1] || !k.key[k2] || !k.key[k3]) {
throw new Error('Missing data');
} else if (appList[k.appID]) {
$.notify('TestFlight Account', '', `Failed, app already exists ⚠️`);
} else {
const save = SaveAccount(k.accID, k.key, k.appID);
}
} catch (e) {
const text = `External account parse failed`;
$.notify('TestFlight Account', '', `${text} ⚠️`);
$.error(`${text}: ${e.message || e}`);
}
return '{}'
}
function ShareAccount(appID, bid) {
const raw = $.stringify({
appID: appID,
accID: appList[appID],
key: list[appList[appID]]
});
const key = letterEncode(raw);
const disclaimer = `
请注意使用"共享"功能时请务必仔细阅读以下声明
请注意使用"共享"功能时请务必仔细阅读以下声明
请注意使用"共享"功能时请务必仔细阅读以下声明
======================================
权限
您即将共享的密钥理论上具有以下权限包括但不限于
- 查看/下载您 TestFlight 账号内的任何测试版 APP
- 使用您的密钥接受测试 TestFlight 中的任何测试版 APP
- 停止测试您 TestFlight 账号内的任何测试版 APP
- 查看您接受 TestFlight 测试版 APP 邀请时所使用的邮箱
- 查看/加入/移除您 TestFlight 账号中的设备列表
- 更改您 TestFlight 测试版 APP 中的推送/电子邮件更新通知
免责
任何用户使用"共享"功能时都应该仔细阅读权限声明一旦您开始使用该功能即视为您已知晓并理解密钥所具有的权限密钥泄漏可能会导致不可预知的损失或损害脚本作者(NobyDa)不对由此产生的任何后果负责
======================================
该脚本在"默认"情况下对方仅可查看/下载您共享的单个APP但仍建议仅与您信任的人共享
`;
$.log(`Raw data: ${raw}\nEncode data: ${key}`);
return {
data: {
buildId: bid,
eula: disclaimer + key
},
messages: null
}
}
// private encode method, based on variant in RFC4648
function letterEncode(e) {
e = e.split("").map(e => e.charCodeAt());
const t = new Uint8Array(4 * Math.ceil(8 * e.length / 4));
let n = 0;
for (const o of e) {
let e = 128;
for (let r = 0; r < 8; r++) t[n++] = o & e ? 1 : 0, e >>= 1
}
let o = "",
r = 0;
return t.forEach((e, t) => {
r = r << 1 | e, (t + 1) % 4 == 0 && (o += "XKNWSPRMCTGZVDHF"[r], r = 0)
}), o
}
function letterDecode(e) {
const t = new Uint8Array(4 * e.length);
let n = 0;
for (const o of e) {
const e = "XKNWSPRMCTGZVDHF".indexOf(o);
let r = 8;
for (let o = 0; o < 4; o++) t[n++] = e & r ? 1 : 0, r >>= 1
}
const o = new Uint8Array(Math.floor(t.length / 8));
return t.forEach((e, t) => {
const n = Math.floor(t / 8);
n < o.length && (o[n] = o[n] << 1 | e)
}), String.fromCharCode(...o)
}
// https://github.com/Peng-YM/QuanX/tree/master/Tools/OpenAPI
function ENV() {
const e = "function" == typeof require && "undefined" != typeof $jsbox;
return {
isQX: "undefined" != typeof $task,
isLoon: "undefined" != typeof $loon,
isSurge: "undefined" != typeof $httpClient && "undefined" == typeof $loon,
isBrowser: "undefined" != typeof document,
isNode: "function" == typeof require && !e,
isJSBox: e,
isRequest: "undefined" != typeof $request,
isScriptable: "undefined" != typeof importModule
}
}
function HTTP(e = {baseURL: ""}) {
const {isQX: t, isLoon: s, isSurge: o, isScriptable: n, isNode: i, isBrowser: r} = ENV(),
u = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
const a = {};
return ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"].forEach(h => a[h.toLowerCase()] = (a => (function (a, h) {
h = "string" == typeof h ? {url: h} : h;
const d = e.baseURL;
d && !u.test(h.url || "") && (h.url = d ? d + h.url : h.url), h.body && h.headers;
const l = (h = {...e, ...h}).timeout, c = {
onRequest: () => {
}, onResponse: e => e, onTimeout: () => {
}, ...h.events
};
let f, p;
if (c.onRequest(a, h), t) f = $task.fetch({method: a, ...h}); else if (s || o || i) f = new Promise((e, t) => {
(i ? require("request") : $httpClient)[a.toLowerCase()](h, (s, o, n) => {
s ? t(s) : e({statusCode: o.status || o.statusCode, headers: o.headers, body: n})
})
}); else if (n) {
const e = new Request(h.url);
e.method = a, e.headers = h.headers, e.body = h.body, f = new Promise((t, s) => {
e.loadString().then(s => {
t({statusCode: e.response.statusCode, headers: e.response.headers, body: s})
}).catch(e => s(e))
})
} else r && (f = new Promise((e, t) => {
fetch(h.url, {
method: a,
headers: h.headers,
body: h.body
}).then(e => e.json()).then(t => e({statusCode: t.status, headers: t.headers, body: t.data})).catch(t)
}));
const y = l ? new Promise((e, t) => {
p = setTimeout(() => (c.onTimeout(), t(`${a} URL: ${h.url} exceeds the timeout ${l} ms`)), l)
}) : null;
return (y ? Promise.race([y, f]).then(e => (clearTimeout(p), e)) : f).then(e => c.onResponse(e))
})(h, a))), a
}
function API(e = "untitled", t = !1) {
const {isQX: s, isLoon: o, isSurge: n, isNode: i, isJSBox: r, isScriptable: u} = ENV();
return new class {
constructor(e, t) {
this.name = e, this.debug = t, this.http = HTTP(), this.env = ENV(), this.node = (() => {
if (i) {
return {fs: require("fs")}
}
return null
})(), this.initCache();
Promise.prototype.delay = function (e) {
return this.then(function (t) {
return ((e, t) => new Promise(function (s) {
setTimeout(s.bind(null, t), e)
}))(e, t)
})
}
}
initCache() {
if (s && (this.cache = JSON.parse($prefs.valueForKey(this.name) || "{}")), (o || n) && (this.cache = JSON.parse($persistentStore.read(this.name) || "{}")), i) {
let e = "root.json";
this.node.fs.existsSync(e) || this.node.fs.writeFileSync(e, JSON.stringify({}), {flag: "wx"}, e => console.log(e)), this.root = {}, e = `${this.name}.json`, this.node.fs.existsSync(e) ? this.cache = JSON.parse(this.node.fs.readFileSync(`${this.name}.json`)) : (this.node.fs.writeFileSync(e, JSON.stringify({}), {flag: "wx"}, e => console.log(e)), this.cache = {})
}
}
persistCache() {
const e = JSON.stringify(this.cache, null, 2);
s && $prefs.setValueForKey(e, this.name), (o || n) && $persistentStore.write(e, this.name), i && (this.node.fs.writeFileSync(`${this.name}.json`, e, {flag: "w"}, e => console.log(e)), this.node.fs.writeFileSync("root.json", JSON.stringify(this.root, null, 2), {flag: "w"}, e => console.log(e)))
}
write(e, t) {
if (this.log(`SET ${t}`), -1 !== t.indexOf("#")) {
if (t = t.substr(1), n || o) return $persistentStore.write(e, t);
if (s) return $prefs.setValueForKey(e, t);
i && (this.root[t] = e)
} else this.cache[t] = e;
this.persistCache()
}
read(e) {
return this.log(`READ ${e}`), -1 === e.indexOf("#") ? this.cache[e] : (e = e.substr(1), n || o ? $persistentStore.read(e) : s ? $prefs.valueForKey(e) : i ? this.root[e] : void 0)
}
delete(e) {
if (this.log(`DELETE ${e}`), -1 !== e.indexOf("#")) {
if (e = e.substr(1), n || o) return $persistentStore.write(null, e);
if (s) return $prefs.removeValueForKey(e);
i && delete this.root[e]
} else delete this.cache[e];
this.persistCache()
}
notify(e, t = "", a = "", h = {}) {
const d = h["open-url"], l = h["media-url"];
if (s && $notify(e, t, a, h), n && $notification.post(e, t, a + `${l ? "\n多媒体:" + l : ""}`, {url: d}), o) {
let s = {};
d && (s.openUrl = d), l && (s.mediaUrl = l), "{}" === JSON.stringify(s) ? $notification.post(e, t, a) : $notification.post(e, t, a, s)
}
if (i || u) {
const s = a + (d ? `\n点击跳转: ${d}` : "") + (l ? `\n多媒体: ${l}` : "");
if (r) {
require("push").schedule({title: e, body: (t ? t + "\n" : "") + s})
} else console.log(`${e}\n${t}\n${s}\n\n`)
}
}
log(e) {
this.debug && console.log(`[${this.name}] LOG: ${this.stringify(e)}`)
}
info(e) {
console.log(`[${this.name}] INFO: ${this.stringify(e)}`)
}
error(e) {
console.log(`[${this.name}] ERROR: ${this.stringify(e)}`)
}
wait(e) {
return new Promise(t => setTimeout(t, e))
}
done(e = {}) {
s || o || n ? $done(e) : i && !r && "undefined" != typeof $context && ($context.headers = e.headers, $context.statusCode = e.statusCode, $context.body = e.body)
}
stringify(e) {
if ("string" == typeof e || e instanceof String) return e;
try {
return JSON.stringify(e, null, 2)
} catch (e) {
return "[object Object]"
}
}
}(e, t)
}

View File

@ -0,0 +1,5 @@
# 该订阅仅适用于QuantumultX, 用于更新TestFlight App时, 提示"APP不可用"问题. 解除区域限制.
hostname = testflight.apple.com
^https?:\/\/testflight\.apple\.com\/v\d\/accounts\/.+?\/install$ url script-request-body https://cdn.jsdelivr.net/gh/sve1r/Rules-For-Quantumult-X@develop/Rewrite/Services/Apple/TestFlightDownload.js

View File

@ -0,0 +1,3 @@
let app = JSON.parse($request.body);
app.storefrontId = '143380-1,29';
$done({body:JSON.stringify(app)});