├── .gitignore ├── main.js ├── strings ├── en.strings └── zh-Hans.strings ├── assets └── icon.png ├── scripts ├── aria2 │ ├── JSONRPCError.js │ ├── Aria2.js │ └── JSONRPCClient.js ├── welcome.js ├── app.js ├── utility.js ├── addActionView.js ├── detailView.js ├── clientView.js └── peerid.js ├── .prettierrc ├── config.json ├── README.md └── prefs.json /.gitignore: -------------------------------------------------------------------------------- 1 | .output 2 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const app = require("./scripts/app"); 2 | app.init(); 3 | -------------------------------------------------------------------------------- /strings/en.strings: -------------------------------------------------------------------------------- 1 | "OK" = "OK"; 2 | "DONE" = "Done"; 3 | "HELLO_WORLD" = "Hello, World!"; 4 | -------------------------------------------------------------------------------- /strings/zh-Hans.strings: -------------------------------------------------------------------------------- 1 | "OK" = "好的"; 2 | "DONE" = "完成"; 3 | "HELLO_WORLD" = "你好,世界!"; 4 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gandum2077/jsbox-aria2-client/HEAD/assets/icon.png -------------------------------------------------------------------------------- /scripts/aria2/JSONRPCError.js: -------------------------------------------------------------------------------- 1 | module.exports = class JSONRPCError extends Error { 2 | constructor({ message, code, data }) { 3 | super(message); 4 | this.code = code; 5 | if (data) this.data = data; 6 | this.name = this.constructor.name; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "requirePragma": false, 12 | "insertPragma": false, 13 | "proseWrap": "preserve" 14 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "JSBoxAria2Client", 4 | "url": "https://github.com/Gandum2077/jsbox-aria2-client", 5 | "version": "1.0.0", 6 | "author": "Gandum2077", 7 | "website": "https://github.com/Gandum2077", 8 | "types": 0 9 | }, 10 | "settings": { 11 | "theme": "auto", 12 | "minSDKVer": "1.60.0", 13 | "minOSVer": "11.0.0", 14 | "idleTimerDisabled": false, 15 | "autoKeyboardEnabled": false, 16 | "keyboardToolbarEnabled": false, 17 | "rotateDisabled": false 18 | }, 19 | "widget": { 20 | "height": 0, 21 | "staticSize": false, 22 | "tintColor": "", 23 | "iconColor": "" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSBox Aria2 Client 2 | 3 | JSBox 版 Aria2 客户端。 4 | 5 | ## Requirements 6 | 7 | - JSBox>=1.60.0 8 | 9 | ## Usage 10 | 11 | 1. 开启 aria2,并启用 RPC 12 | 13 | - 请参考[aria2 文档](https://aria2.github.io/manual/en/html/aria2c.html) 14 | 15 | - 快速设置可以参考如下命令,此命令会开启 aria2,启用 RPC,允许全部外部连接,后台运行,并设定 token 为 123 16 | 17 | ``` 18 | aria2c --enable-rpc --rpc-listen-all=true --rpc-allow-origin-all --rpc-secret=123 --daemon 19 | ``` 20 | 21 | 2. 启动 JSBoxAria2Client 后,在设置页面进行对应设置 22 | 如果使用了上述快速设置的命令,那么修改以下两个设置即可,否则请自行修改 23 | 24 | - host 服务端的 ip 25 | - token 123 26 | 27 | 3. 操作方式:轻点查看细节,长按开始/暂停,左滑删除 28 | 29 | ## Acknowledgments 30 | 31 | - [YAAW](https://github.com/binux/yaaw) 32 | - [aria2.js](https://github.com/sonnyp/aria2.js) 33 | -------------------------------------------------------------------------------- /scripts/welcome.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const utility = require("./utility"); 3 | 4 | async function welcome() { 5 | const oldOptions = utility.getOptions(); 6 | await utility.changePrefs(); 7 | let version = await utility.getVersion(); 8 | while (!version) { 9 | const result = await $ui.alert({ 10 | title: "设置错误", 11 | message: "无法获取版本号,请设置", 12 | actions: [{ title: "Exit" }, { title: "Setting" }] 13 | }); 14 | if (result.index) { 15 | await utility.changePrefs(); 16 | version = await utility.getVersion(); 17 | } else { 18 | $app.close(); 19 | } 20 | } 21 | const newOptions = utility.getOptions(); 22 | if (_.isEqual(oldOptions, newOptions)) { 23 | await utility.changeGlobalOptionForServer(); 24 | } else { 25 | const result = await utility.getGlobalOptionFromServer(); 26 | utility.setGlobalOptionToPrefs(result); 27 | } 28 | } 29 | 30 | module.exports = welcome; 31 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | const utility = require("./utility"); 2 | const clientViewGenerator = require("./clientView"); 3 | const welcome = require("./welcome"); 4 | 5 | async function init() { 6 | const rootView = { 7 | props: { 8 | id: "rootView", 9 | title: "JSBox Aria2 Client", 10 | navButtons: [ 11 | { 12 | title: "Settings", 13 | handler: async () => { 14 | await welcome(); 15 | if ($("labelVersion")) { 16 | const version = await utility.getVersion(); 17 | $("labelVersion").text = "Aria2" + " " + version; 18 | } 19 | } 20 | } 21 | ] 22 | }, 23 | views: [] 24 | }; 25 | $ui.render(rootView); 26 | const version = await utility.getVersion(); 27 | if (!version) { 28 | await $ui.alert({ 29 | title: "请先进行初始设置", 30 | actions: [{ title: "OK" }] 31 | }); 32 | await welcome(); 33 | } 34 | const result = await utility.getGlobalOptionFromServer(); 35 | utility.setGlobalOptionToPrefs(result); 36 | $ui.window.add(clientViewGenerator.defineClientView()); 37 | await $wait(1); 38 | $app.tips("操作方式:轻点查看细节,长按开始/暂停,左滑删除"); 39 | } 40 | 41 | module.exports = { 42 | init 43 | }; 44 | -------------------------------------------------------------------------------- /scripts/aria2/Aria2.js: -------------------------------------------------------------------------------- 1 | const JSONRPCClient = require("./JSONRPCClient"); 2 | 3 | function prefix(str) { 4 | if (!str.startsWith("system.") && !str.startsWith("aria2.")) { 5 | str = "aria2." + str; 6 | } 7 | return str; 8 | } 9 | 10 | function unprefix(str) { 11 | const suffix = str.split("aria2.")[1]; 12 | return suffix || str; 13 | } 14 | 15 | class Aria2 extends JSONRPCClient { 16 | addSecret(parameters) { 17 | let params = this.secret ? ["token:" + this.secret] : []; 18 | if (Array.isArray(parameters)) { 19 | params = params.concat(parameters); 20 | } 21 | return params; 22 | } 23 | 24 | async call(method, ...params) { 25 | return super.call(prefix(method), this.addSecret(params)); 26 | } 27 | 28 | async multicall(calls) { 29 | const multi = [ 30 | calls.map(([method, ...params]) => { 31 | return { methodName: prefix(method), params: this.addSecret(params) }; 32 | }) 33 | ]; 34 | return super.call("system.multicall", multi); 35 | } 36 | } 37 | 38 | Object.assign(Aria2, { prefix, unprefix }); 39 | 40 | Aria2.defaultOptions = Object.assign({}, JSONRPCClient.defaultOptions, { 41 | secure: false, 42 | host: "localhost", 43 | port: 6800, 44 | secret: "", 45 | path: "/jsonrpc" 46 | }); 47 | 48 | module.exports = Aria2; 49 | -------------------------------------------------------------------------------- /scripts/aria2/JSONRPCClient.js: -------------------------------------------------------------------------------- 1 | const JSONRPCError = require("./JSONRPCError"); 2 | 3 | class JSONRPCClient { 4 | constructor(options) { 5 | this.deferreds = Object.create(null); 6 | this.lastId = 0; 7 | Object.assign(this, this.constructor.defaultOptions, options); 8 | } 9 | 10 | id() { 11 | return this.lastId++; 12 | } 13 | 14 | url() { 15 | return ( 16 | "http" + 17 | (this.secure ? "s" : "") + 18 | "://" + 19 | this.host + 20 | ":" + 21 | this.port + 22 | this.path 23 | ); 24 | } 25 | 26 | async http(message) { 27 | const resp = await $http.post({ 28 | url: this.url(), 29 | body: message, 30 | timeout: 5, 31 | headers: { 32 | Accept: "application/json", 33 | "Content-Type": "application/json" 34 | } 35 | }); 36 | if (!resp.data || resp.response.statusCode !== 200) { 37 | throw new JSONRPCError({ 38 | message: "JSONRPCError", 39 | code: resp.response ? resp.response.statusCode : 0, 40 | data: resp.data 41 | }); 42 | } 43 | const data = resp.data; 44 | 45 | return data; 46 | } 47 | 48 | async call(method, parameters) { 49 | const message = this._buildMessage(method, parameters); 50 | const data = await this.http(message); 51 | return data; 52 | } 53 | 54 | _buildMessage(method, params) { 55 | if (typeof method !== "string") { 56 | throw new TypeError(method + " is not a string"); 57 | } 58 | 59 | const message = { 60 | method, 61 | jsonrpc: "2.0", 62 | id: this.id() 63 | }; 64 | 65 | if (params) Object.assign(message, { params }); 66 | return message; 67 | } 68 | } 69 | 70 | JSONRPCClient.defaultOptions = { 71 | secure: false, 72 | host: "localhost", 73 | port: 80, 74 | secret: "", 75 | path: "/jsonrpc" 76 | }; 77 | 78 | module.exports = JSONRPCClient; 79 | -------------------------------------------------------------------------------- /prefs.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Settings", 3 | "groups": [ 4 | { 5 | "title": "General", 6 | "items": [ 7 | { 8 | "title": "Refresh Interval", 9 | "type": "integer", 10 | "key": "refresh_interval", 11 | "value": 5 12 | } 13 | ] 14 | }, 15 | { 16 | "title": "RPC", 17 | "items": [ 18 | { 19 | "title": "Host", 20 | "type": "string", 21 | "key": "host", 22 | "value": "localhost" 23 | }, 24 | { 25 | "title": "Port", 26 | "type": "integer", 27 | "key": "port", 28 | "value": 6800 29 | }, 30 | { 31 | "title": "Path", 32 | "type": "string", 33 | "key": "path", 34 | "value": "/jsonrpc" 35 | }, 36 | { 37 | "title": "SSL/TLS", 38 | "type": "boolean", 39 | "key": "secure", 40 | "value": false 41 | }, 42 | { 43 | "title": "Token", 44 | "type": "string", 45 | "key": "secret" 46 | } 47 | ] 48 | }, 49 | { 50 | "title": "Downloads\n若本次设置更改了RPC,则本栏更改均无效,会优先从服务端更新配置", 51 | "items": [ 52 | { 53 | "title": "Directory", 54 | "type": "string", 55 | "key": "dir", 56 | "value": "/tmp" 57 | }, 58 | { 59 | "title": "Download Limit", 60 | "type": "integer", 61 | "key": "max-overall-download-limit", 62 | "value": 0 63 | }, 64 | { 65 | "title": "Upload Limit", 66 | "type": "integer", 67 | "key": "max-overall-upload-limit", 68 | "value": 0 69 | }, 70 | { 71 | "title": "Max Downloading", 72 | "type": "integer", 73 | "key": "max-concurrent-downloads", 74 | "value": 5 75 | }, 76 | { 77 | "title": "User Agent", 78 | "type": "string", 79 | "key": "user-agent", 80 | "value": "" 81 | } 82 | ] 83 | }, 84 | { 85 | "title": "About", 86 | "items": [ 87 | { 88 | "title": "Author", 89 | "type": "info", 90 | "value": "Gandum2077" 91 | }, 92 | { 93 | "title": "Report Bugs", 94 | "type": "link", 95 | "value": "https://github.com/Gandum2077/jsbox-aria2-client/issues" 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /scripts/utility.js: -------------------------------------------------------------------------------- 1 | const Aria2 = require("./aria2/Aria2"); 2 | 3 | /** 4 | * convert size in bytes to KB, MB, GB... 5 | * @param {number|string} bytes 6 | * @param {number} decimals 7 | */ 8 | function formatBytes(bytes, decimals = 2) { 9 | bytes = parseInt(bytes); 10 | if (bytes === 0) return "0 B"; 11 | const k = 1024; 12 | const dm = decimals < 0 ? 0 : decimals; 13 | const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 14 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 15 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 16 | } 17 | 18 | /** 19 | * convert time in seconds to format 20 | * @param {number|string} s 21 | */ 22 | function formatTime(s) { 23 | const sec_num = parseInt(s); // don't forget the second param 24 | const hours = Math.floor(sec_num / 3600); 25 | if (hours >= 24) { 26 | return "1d"; 27 | } 28 | const minutes = Math.floor((sec_num - hours * 3600) / 60); 29 | const seconds = sec_num - hours * 3600 - minutes * 60; 30 | let result = ""; 31 | if (hours) result += `${hours}h`; 32 | if (minutes || hours) result += `${minutes}m`; 33 | result += `${seconds}s`; 34 | return result; 35 | } 36 | 37 | function getAdjustedFormatBytes(bytes) { 38 | const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 39 | const formatedBytes = formatBytes(bytes); 40 | const [numberString, unit] = formatedBytes.split(" "); 41 | const number = parseFloat(numberString); 42 | if (number >= 999.95) { 43 | const num = parseFloat((number / 1024).toFixed(2)); 44 | return num + " " + sizes[sizes.indexOf(unit) + 1]; 45 | } else { 46 | const dm = numberString.split(".")[1]; 47 | if (dm && numberString.length === 6) { 48 | return parseFloat(number.toFixed(1)) + " " + unit; 49 | } else { 50 | return formatedBytes; 51 | } 52 | } 53 | } 54 | 55 | function bitfield(text) { 56 | const graphic = "░▒▓█"; 57 | const len = text.length; 58 | let result = ""; 59 | for (let i = 0; i < len; i++) { 60 | result += graphic[Math.floor(parseInt(text[i], 16) / 4)] + "\u200B"; 61 | } 62 | return result; 63 | } 64 | 65 | function bitfieldToPercent(text) { 66 | const len = text.length - 1; 67 | let p, 68 | one = 0; 69 | for (let i = 0; i < len; i++) { 70 | p = parseInt(text[i], 16); 71 | for (let j = 0; j < 4; j++) { 72 | one += p & 1; 73 | p >>= 1; 74 | } 75 | } 76 | return Math.floor((one / (4 * len)) * 100).toString(); 77 | } 78 | 79 | function getOptions() { 80 | const options = { 81 | host: $prefs.get("host"), 82 | port: $prefs.get("port"), 83 | secure: $prefs.get("secure"), 84 | secret: $prefs.get("secret"), 85 | path: $prefs.get("path") 86 | }; 87 | return options; 88 | } 89 | 90 | async function callRPC(method, params) { 91 | params = params || []; 92 | const options = getOptions(); 93 | const aria2 = new Aria2(options); 94 | const result = await aria2.call(method, ...params); 95 | return result.result; 96 | } 97 | 98 | async function multicallRPC(multicall) { 99 | const options = getOptions(); 100 | const aria2 = new Aria2(options); 101 | const results = await aria2.multicall(multicall); 102 | console.info(results); 103 | } 104 | 105 | async function changePrefs() { 106 | return new Promise((resolve, reject) => { 107 | $prefs.open(() => resolve()); 108 | }); 109 | } 110 | 111 | async function getGlobalOptionFromServer() { 112 | const result = await callRPC("getGlobalOption"); 113 | return result; 114 | } 115 | 116 | function setGlobalOptionToPrefs(result) { 117 | $prefs.set("dir", result["dir"] || "/tmp"); 118 | $prefs.set( 119 | "max-overall-download-limit", 120 | parseInt(result["max-overall-download-limit"]) || 0 121 | ); 122 | $prefs.set( 123 | "max-overall-upload-limit", 124 | parseInt(result["max-overall-upload-limit"]) || 0 125 | ); 126 | $prefs.set( 127 | "max-concurrent-downloads", 128 | parseInt(result["max-concurrent-downloads"]) || 5 129 | ); 130 | $prefs.set("user-agent", result["user-agent"] || ""); 131 | } 132 | 133 | async function changeGlobalOptionForServer() { 134 | const result = { 135 | dir: $prefs.get("dir"), 136 | "max-overall-download-limit": $prefs 137 | .get("max-overall-download-limit") 138 | .toString(), 139 | "max-overall-upload-limit": $prefs 140 | .get("max-overall-upload-limit") 141 | .toString(), 142 | "max-concurrent-downloads": $prefs 143 | .get("max-concurrent-downloads") 144 | .toString(), 145 | "user-agent": $prefs.get("user-agent") 146 | }; 147 | await callRPC("changeGlobalOption", [result]); 148 | } 149 | 150 | async function getStatus() { 151 | try { 152 | const result = { 153 | active: [], 154 | waiting: [], 155 | stopped: [] 156 | }; 157 | const status = await callRPC("getGlobalStat"); 158 | result["uploadSpeed"] = status.uploadSpeed; 159 | result["downloadSpeed"] = status.downloadSpeed; 160 | if (status.numActive !== "0") { 161 | result.active = await callRPC("tellActive"); 162 | } 163 | if (status.numWaiting !== "0") { 164 | result.waiting = await callRPC("tellWaiting", [0, 1000]); 165 | } 166 | if (status.numStopped !== "0") { 167 | result.stopped = await callRPC("tellStopped", [0, 1000]); 168 | } 169 | return result; 170 | } catch (err) { 171 | console.info(err); 172 | return; 173 | } 174 | } 175 | 176 | async function getVersion() { 177 | try { 178 | const result = await callRPC("getVersion"); 179 | return result.version; 180 | } catch (err) { 181 | console.info(err); 182 | return ""; 183 | } 184 | } 185 | 186 | function convertInvalidChrOfPeerId(s) { 187 | const slices = []; 188 | while (s) { 189 | if (s[0] === "%") { 190 | slices.push(s.substring(0, 3)); 191 | s = s.slice(3); 192 | } else { 193 | slices.push(s[0]); 194 | s = s.slice(1); 195 | } 196 | } 197 | for (let idx in slices) { 198 | const value = slices[idx]; 199 | if (value.length === 3) { 200 | const index = parseInt(value.substring(1, 3), 16); 201 | if (index < 20 || index > 126) { 202 | slices[idx] = "%30"; 203 | } 204 | } 205 | } 206 | return slices.join(""); 207 | } 208 | 209 | module.exports = { 210 | getAdjustedFormatBytes, 211 | formatTime, 212 | bitfield, 213 | bitfieldToPercent, 214 | getOptions, 215 | callRPC, 216 | multicallRPC, 217 | changePrefs, 218 | getGlobalOptionFromServer, 219 | setGlobalOptionToPrefs, 220 | changeGlobalOptionForServer, 221 | getStatus, 222 | getVersion, 223 | convertInvalidChrOfPeerId 224 | }; 225 | -------------------------------------------------------------------------------- /scripts/addActionView.js: -------------------------------------------------------------------------------- 1 | let OPTIONS; 2 | let RESULT; 3 | 4 | function defineUriInputView() { 5 | const text = { 6 | type: "text", 7 | props: { 8 | id: "textUri", 9 | placeholder: "HTTP, FTP or Magnet\n可添加多个链接,每个链接占一行即可", 10 | borderWidth: 1, 11 | borderColor: $color("#c6c6c8"), 12 | radius: 10, 13 | autocorrectionType: 1, 14 | autocapitalizationType: 0, 15 | spellCheckingType: 1 16 | }, 17 | layout: function (make, view) { 18 | make.top.bottom.inset(10); 19 | make.left.right.inset(30); 20 | }, 21 | events: { 22 | didEndEditing: function (sender) { 23 | const text = sender.text.trim(); 24 | const uris = text.split("\n").filter(n => n.trim()); 25 | if (uris.length) { 26 | RESULT = { 27 | type: "uri", 28 | uris: sender.text.trim().split("\n") 29 | }; 30 | } else { 31 | RESULT = undefined; 32 | } 33 | } 34 | } 35 | }; 36 | return text; 37 | } 38 | 39 | function defineFileSelectionView() { 40 | const view = { 41 | type: "view", 42 | props: { 43 | id: "fileSelectionView" 44 | }, 45 | views: [ 46 | { 47 | type: "button", 48 | props: { 49 | id: "button", 50 | title: "select a torrent file" 51 | }, 52 | layout: function (make, view) { 53 | make.height.equalTo(50); 54 | make.top.inset(10); 55 | make.left.right.inset(30); 56 | }, 57 | events: { 58 | tapped: async function (sender) { 59 | const torrent = await $drive.open(); 60 | if (torrent) { 61 | sender.super.get("torrentName").text = torrent.fileName; 62 | RESULT = { 63 | type: "torrent", 64 | base64: $text.base64Encode(torrent) 65 | }; 66 | } else { 67 | sender.super.get("torrentName").text = ""; 68 | RESULT = undefined; 69 | } 70 | } 71 | } 72 | }, 73 | { 74 | type: "text", 75 | props: { 76 | id: "torrentName", 77 | editable: false, 78 | selectable: false, 79 | scrollEnabled: false, 80 | borderWidth: 0 81 | }, 82 | layout: function (make, view) { 83 | make.top.equalTo($("button").bottom).inset(10); 84 | make.bottom.inset(10); 85 | make.left.right.inset(30); 86 | } 87 | } 88 | ], 89 | layout: function (make, view) { 90 | make.top.bottom.inset(10); 91 | make.left.right.inset(30); 92 | } 93 | }; 94 | return view; 95 | } 96 | 97 | function getData() { 98 | const data = [ 99 | { 100 | title: { 101 | text: "File Name:" 102 | }, 103 | content: { 104 | text: "", 105 | info: { 106 | key: "out" 107 | } 108 | } 109 | }, 110 | { 111 | title: { 112 | text: "Dir:" 113 | }, 114 | content: { 115 | text: "", 116 | info: { 117 | key: "dir" 118 | } 119 | } 120 | }, 121 | { 122 | title: { 123 | text: "Split:" 124 | }, 125 | content: { 126 | text: "", 127 | info: { 128 | key: "split" 129 | } 130 | } 131 | }, 132 | { 133 | title: { 134 | text: "Download Limit:" 135 | }, 136 | content: { 137 | text: "", 138 | info: { 139 | key: "max-download-limit" 140 | } 141 | } 142 | }, 143 | { 144 | title: { 145 | text: "Upload Limit:" 146 | }, 147 | content: { 148 | text: "", 149 | info: { 150 | key: "max-upload-limit" 151 | } 152 | } 153 | }, 154 | { 155 | title: { 156 | text: "Seed Ratio:" 157 | }, 158 | content: { 159 | text: "", 160 | info: { 161 | key: "seed-ratio" 162 | } 163 | } 164 | }, 165 | { 166 | title: { 167 | text: "Seed Time:" 168 | }, 169 | content: { 170 | text: "", 171 | info: { 172 | key: "seed-time" 173 | } 174 | } 175 | }, 176 | { 177 | title: { 178 | text: "Header:" 179 | }, 180 | content: { 181 | text: "", 182 | info: { 183 | key: "header" 184 | } 185 | } 186 | } 187 | ]; 188 | return data; 189 | } 190 | 191 | function defineOptionList() { 192 | const template = { 193 | views: [ 194 | { 195 | type: "label", 196 | props: { 197 | id: "title" 198 | }, 199 | layout: function (make, view) { 200 | make.top.left.bottom.inset(0); 201 | make.width.equalTo(150); 202 | } 203 | }, 204 | { 205 | type: "input", 206 | props: { 207 | id: "content", 208 | bgcolor: $color("clear"), 209 | borderWidth: 1, 210 | borderColor: $color("#c6c6c8"), 211 | radius: 10, 212 | autocorrectionType: 1, 213 | autocapitalizationType: 0, 214 | spellCheckingType: 1 215 | }, 216 | layout: function (make, view) { 217 | make.top.bottom.inset(3); 218 | make.right.inset(0); 219 | make.left.equalTo($("title").right); 220 | }, 221 | events: { 222 | returned: function (sender) { 223 | sender.blur(); 224 | }, 225 | didEndEditing: function (sender) { 226 | OPTIONS = {}; 227 | $("optionList").data.map((n, i) => { 228 | const text = $("optionList") 229 | .cell($indexPath(0, i)) 230 | .get("content").text; 231 | if (text) { 232 | OPTIONS[n.content.info.key] = text; 233 | } 234 | }); 235 | } 236 | } 237 | } 238 | ] 239 | }; 240 | const list = { 241 | type: "list", 242 | props: { 243 | id: "optionList", 244 | separatorHidden: true, 245 | template: template, 246 | data: getData(), 247 | header: { 248 | type: "label", 249 | props: { 250 | height: 20, 251 | text: "额外设置,若不需要留空即可,添加多个URI则File Name无效", 252 | align: $align.center, 253 | font: $font(12) 254 | } 255 | } 256 | }, 257 | layout: function (make, view) { 258 | make.top.equalTo($("contentView").bottom); 259 | make.bottom.inset(20); 260 | make.left.right.inset(30); 261 | } 262 | }; 263 | return list; 264 | } 265 | 266 | function defineAddActionView() { 267 | const tab = { 268 | type: "tab", 269 | props: { 270 | id: "tab", 271 | items: ["URIs", "Torrent"], 272 | index: 0 273 | }, 274 | layout: function (make, view) { 275 | make.top.left.right.inset(0); 276 | make.height.equalTo(50); 277 | }, 278 | events: { 279 | changed: function (sender) { 280 | RESULT = undefined; 281 | const index = sender.index; 282 | $("contentView").views[0].remove(); 283 | switch (index) { 284 | case 0: 285 | $("contentView").add(defineUriInputView()); 286 | break; 287 | case 1: 288 | $("contentView").add(defineFileSelectionView()); 289 | break; 290 | default: 291 | break; 292 | } 293 | } 294 | } 295 | }; 296 | const contentView = { 297 | type: "view", 298 | props: { 299 | id: "contentView" 300 | }, 301 | layout: function (make, view) { 302 | make.left.right.inset(0); 303 | make.top.equalTo($("tab").bottom); 304 | make.height.equalTo(150); 305 | } 306 | }; 307 | const optionList = defineOptionList(); 308 | const addActionView = { 309 | type: "view", 310 | props: { 311 | id: "addActionView" 312 | }, 313 | layout: $layout.fill, 314 | views: [tab, contentView, optionList] 315 | }; 316 | return addActionView; 317 | } 318 | 319 | async function pushAddActionView() { 320 | OPTIONS = undefined; 321 | RESULT = undefined; 322 | return new Promise((resolve, reject) => { 323 | $ui.push({ 324 | props: { navButtons: [{ title: "" }] }, 325 | views: [defineAddActionView()], 326 | events: { 327 | dealloc: function () { 328 | if (RESULT) { 329 | if (OPTIONS && Object.keys(OPTIONS).length === 0) 330 | OPTIONS = undefined; 331 | if (OPTIONS && RESULT.type === "uri" && RESULT.uris.length > 1) { 332 | delete OPTIONS.out; 333 | } 334 | RESULT.options = OPTIONS; 335 | resolve(RESULT); 336 | } else { 337 | reject("cancelled"); 338 | } 339 | } 340 | } 341 | }); 342 | $("contentView").add(defineUriInputView()); 343 | }); 344 | } 345 | 346 | module.exports = pushAddActionView; 347 | -------------------------------------------------------------------------------- /scripts/detailView.js: -------------------------------------------------------------------------------- 1 | const utility = require("./utility"); 2 | const peerid = require("./peerid"); 3 | 4 | let GID; 5 | 6 | function getDataForStatusInfoView(statusInfo) { 7 | let type; 8 | let title = ""; 9 | if ( 10 | statusInfo.bittorrent && 11 | statusInfo.bittorrent.info && 12 | statusInfo.bittorrent.info.name 13 | ) { 14 | title = statusInfo.bittorrent.info.name; 15 | type = "bittorrent"; 16 | } else if (statusInfo.files.length === 1 && statusInfo.files[0].path) { 17 | title = statusInfo.files[0].path.split("/").pop(); 18 | type = "url"; 19 | } 20 | const status = statusInfo.status; 21 | const dir = statusInfo.dir; 22 | const speed = `⇣${utility.getAdjustedFormatBytes( 23 | statusInfo.downloadSpeed 24 | )}/s ⇡${utility.getAdjustedFormatBytes(statusInfo.uploadSpeed)}/s`; 25 | const size = `${utility.getAdjustedFormatBytes(statusInfo.totalLength)}(${ 26 | statusInfo.numPieces 27 | } @ ${utility.getAdjustedFormatBytes(statusInfo.pieceLength)})`; 28 | const download = utility.getAdjustedFormatBytes(statusInfo.completedLength); 29 | const upload = utility.getAdjustedFormatBytes(statusInfo.uploadLength); 30 | const uriTitle = type === "bittorrent" ? "infohash" : "url"; 31 | const uri = 32 | type === "bittorrent" 33 | ? statusInfo.infoHash 34 | : statusInfo.files[0].uris[0].uri; 35 | const connections = statusInfo.connections; 36 | const numSeeders = statusInfo.numSeeders; 37 | // const bitfield = utility.bitfield(statusInfo.bitfield); 38 | const data = [ 39 | { 40 | title: { 41 | text: "title" 42 | }, 43 | content: { 44 | text: title 45 | } 46 | }, 47 | { 48 | title: { 49 | text: "status" 50 | }, 51 | content: { 52 | text: status 53 | } 54 | }, 55 | { 56 | title: { 57 | text: "dir" 58 | }, 59 | content: { 60 | text: dir 61 | } 62 | }, 63 | { 64 | title: { 65 | text: "speed" 66 | }, 67 | content: { 68 | text: speed 69 | } 70 | }, 71 | { 72 | title: { 73 | text: "size" 74 | }, 75 | content: { 76 | text: size 77 | } 78 | }, 79 | { 80 | title: { 81 | text: "download" 82 | }, 83 | content: { 84 | text: download 85 | } 86 | }, 87 | { 88 | title: { 89 | text: "upload" 90 | }, 91 | content: { 92 | text: upload 93 | } 94 | }, 95 | { 96 | title: { 97 | text: uriTitle 98 | }, 99 | content: { 100 | text: uri 101 | } 102 | }, 103 | { 104 | title: { 105 | text: "connections" 106 | }, 107 | content: { 108 | text: connections 109 | } 110 | }, 111 | { 112 | title: { 113 | text: "numSeeders" 114 | }, 115 | content: { 116 | text: numSeeders 117 | } 118 | } 119 | ]; 120 | return data; 121 | } 122 | 123 | function defineStatusView() { 124 | const template = { 125 | views: [ 126 | { 127 | type: "label", 128 | props: { 129 | id: "title", 130 | align: $align.left 131 | }, 132 | layout: function (make, view) { 133 | make.top.left.bottom.inset(0); 134 | make.width.equalTo(100); 135 | } 136 | }, 137 | { 138 | type: "label", 139 | props: { 140 | id: "content", 141 | align: $align.right, 142 | autoFontSize: false, 143 | lines: 0 144 | }, 145 | layout: function (make, view) { 146 | make.top.right.bottom.inset(0); 147 | make.left.equalTo($("title").right); 148 | } 149 | } 150 | ] 151 | }; 152 | const list = { 153 | type: "list", 154 | props: { 155 | id: "statusInfoList", 156 | template: template 157 | }, 158 | layout: function (make, view) { 159 | make.top.left.right.bottom.inset(30); 160 | }, 161 | events: { 162 | ready: async function (sender) { 163 | const info = await utility.callRPC("tellStatus", [GID]); 164 | sender.data = getDataForStatusInfoView(info); 165 | }, 166 | didSelect: function (sender, indexPath) { 167 | const data = sender.data[indexPath.row]; 168 | $ui.alert({ 169 | title: data.title.text, 170 | message: data.content.text, 171 | actions: [ 172 | { 173 | title: "Copy", 174 | handler: function () { 175 | $clipboard.text = data.content.text; 176 | $ui.toast("Copied"); 177 | } 178 | }, 179 | { title: "OK" } 180 | ] 181 | }); 182 | } 183 | } 184 | }; 185 | return list; 186 | } 187 | 188 | function getDataForFilesView(filesInfo) { 189 | return filesInfo.map(n => { 190 | return { 191 | title: { 192 | text: n.path 193 | }, 194 | size: { 195 | text: `${utility.getAdjustedFormatBytes( 196 | n.completedLength 197 | )}/\n${utility.getAdjustedFormatBytes(n.length)}` 198 | } 199 | }; 200 | }); 201 | } 202 | 203 | function defineFilesView() { 204 | const template = { 205 | views: [ 206 | { 207 | type: "label", 208 | props: { 209 | id: "title", 210 | lines: 2 211 | }, 212 | layout: function (make, view) { 213 | make.top.left.bottom.inset(0); 214 | make.right.inset(80); 215 | } 216 | }, 217 | { 218 | type: "label", 219 | props: { 220 | id: "size", 221 | lines: 2, 222 | font: $font(13) 223 | }, 224 | layout: function (make, view) { 225 | make.top.right.bottom.inset(0); 226 | make.width.equalTo(80); 227 | } 228 | } 229 | ] 230 | }; 231 | const list = { 232 | type: "list", 233 | props: { 234 | id: "filesInfoList", 235 | template: template 236 | }, 237 | layout: function (make, view) { 238 | make.top.left.right.bottom.inset(30); 239 | }, 240 | events: { 241 | ready: async function (sender) { 242 | const info = await utility.callRPC("tellStatus", [GID]); 243 | sender.data = getDataForFilesView(info.files); 244 | }, 245 | didSelect: function (sender, indexPath) { 246 | const data = sender.data[indexPath.row]; 247 | $ui.alert({ 248 | title: "File", 249 | message: data.title.text, 250 | actions: [ 251 | { 252 | title: "Copy", 253 | handler: function () { 254 | $clipboard.text = data.title.text; 255 | $ui.toast("Copied"); 256 | } 257 | }, 258 | { title: "OK" } 259 | ] 260 | }); 261 | } 262 | } 263 | }; 264 | return list; 265 | } 266 | 267 | function getDataForPeersView(peersInfo) { 268 | return peersInfo.map(n => { 269 | let client; 270 | try { 271 | const peerId = $text.URLDecode( 272 | utility.convertInvalidChrOfPeerId(n.peerId) 273 | ); 274 | client = peerid.parseClient(peerId).client; 275 | } catch (err) { 276 | client = "unknown"; 277 | } 278 | return { 279 | address: { 280 | text: n.ip + ":" + n.port 281 | }, 282 | client: { 283 | text: client 284 | }, 285 | percent: { 286 | text: utility.bitfieldToPercent(n.bitfield) + "%" 287 | }, 288 | speed: { 289 | text: `⇣${utility.getAdjustedFormatBytes( 290 | n.downloadSpeed 291 | )}/s ⇡${utility.getAdjustedFormatBytes(n.uploadSpeed)}/s` 292 | } 293 | }; 294 | }); 295 | } 296 | 297 | function definePeersView() { 298 | const template = { 299 | views: [ 300 | { 301 | type: "label", 302 | props: { 303 | id: "address" 304 | }, 305 | layout: function (make, view) { 306 | make.top.left.inset(0); 307 | make.height.equalTo(22); 308 | make.width.equalTo(250); 309 | } 310 | }, 311 | { 312 | type: "label", 313 | props: { 314 | id: "client" 315 | }, 316 | layout: function (make, view) { 317 | make.left.bottom.inset(0); 318 | make.height.equalTo(22); 319 | make.width.equalTo(150); 320 | } 321 | }, 322 | { 323 | type: "label", 324 | props: { 325 | id: "percent", 326 | align: $align.right 327 | }, 328 | layout: function (make, view) { 329 | make.top.right.inset(0); 330 | make.height.equalTo(22); 331 | make.width.equalTo(100); 332 | } 333 | }, 334 | { 335 | type: "label", 336 | props: { 337 | id: "speed", 338 | align: $align.right 339 | }, 340 | layout: function (make, view) { 341 | make.right.bottom.inset(0); 342 | make.height.equalTo(22); 343 | make.width.equalTo(200); 344 | } 345 | } 346 | ] 347 | }; 348 | const list = { 349 | type: "list", 350 | props: { 351 | id: "peersInfoList", 352 | template: template 353 | }, 354 | layout: function (make, view) { 355 | make.top.left.right.bottom.inset(30); 356 | }, 357 | events: { 358 | ready: async function (sender) { 359 | try { 360 | const info = await utility.callRPC("getPeers", [GID]); 361 | sender.data = getDataForPeersView(info); 362 | } catch (err) { 363 | $ui.toast("getPeers失败"); 364 | } 365 | } 366 | } 367 | }; 368 | return list; 369 | } 370 | 371 | function defineDetailView() { 372 | const tab = { 373 | type: "tab", 374 | props: { 375 | id: "tab", 376 | items: ["status", "files", "peers"], 377 | index: 0 378 | }, 379 | layout: function (make, view) { 380 | make.top.left.right.inset(0); 381 | make.height.equalTo(50); 382 | }, 383 | events: { 384 | changed: function (sender) { 385 | const index = sender.index; 386 | $("contentView").views[0].remove(); 387 | switch (index) { 388 | case 0: 389 | $("contentView").add(defineStatusView()); 390 | break; 391 | case 1: 392 | $("contentView").add(defineFilesView()); 393 | break; 394 | case 2: 395 | $("contentView").add(definePeersView()); 396 | break; 397 | default: 398 | break; 399 | } 400 | } 401 | } 402 | }; 403 | const contentView = { 404 | type: "view", 405 | props: { 406 | id: "contentView" 407 | }, 408 | layout: function (make, view) { 409 | make.bottom.left.right.inset(0); 410 | make.top.equalTo($("tab").bottom); 411 | } 412 | }; 413 | const detailView = { 414 | type: "view", 415 | props: { 416 | id: "detailView" 417 | }, 418 | layout: $layout.fill, 419 | views: [tab, contentView] 420 | }; 421 | return detailView; 422 | } 423 | 424 | function init(gid) { 425 | GID = gid; 426 | $ui.push({ 427 | props: { 428 | title: "Details", 429 | navButtons: [{ title: "" }] 430 | }, 431 | views: [defineDetailView()] 432 | }); 433 | $("contentView").add(defineStatusView()); 434 | } 435 | 436 | module.exports = { 437 | init 438 | }; 439 | -------------------------------------------------------------------------------- /scripts/clientView.js: -------------------------------------------------------------------------------- 1 | const utility = require("./utility"); 2 | const detailViewGenerator = require("./detailView"); 3 | const pushAddActionView = require("./addActionView"); 4 | 5 | function defineToolsView() { 6 | const buttonAdd = { 7 | type: "button", 8 | props: { 9 | id: "buttonAdd", 10 | icon: $icon("104", $color("#ccc"), $size(25, 25)), 11 | bgcolor: $color("#007aff") 12 | }, 13 | layout: function (make, view) { 14 | make.centerY.equalTo(view.super); 15 | make.left.inset(25); 16 | make.height.equalTo(32); 17 | make.width 18 | .equalTo(view.super.width) 19 | .multipliedBy(0.25) 20 | .offset(-(80 / 4)); 21 | }, 22 | events: { 23 | tapped: async function (sender) { 24 | const result = await pushAddActionView(); 25 | console.info(result); 26 | switch (result.type) { 27 | case "uri": { 28 | const multicall = result.uris.map(n => { 29 | if (result.options) { 30 | return ["addUri", [n], result.options]; 31 | } else { 32 | return ["addUri", [n]]; 33 | } 34 | }); 35 | try { 36 | await utility.multicallRPC(multicall); 37 | await refresh(); 38 | } catch (err) { 39 | $ui.toast("失败"); 40 | console.info(err); 41 | } 42 | break; 43 | } 44 | case "torrent": { 45 | try { 46 | if (result.options) { 47 | await utility.callRPC("addTorrent", [ 48 | result.base64, 49 | result.options 50 | ]); 51 | } else { 52 | await utility.callRPC("addTorrent", [result.base64]); 53 | } 54 | await refresh(); 55 | } catch (err) { 56 | $ui.toast("失败"); 57 | console.info(err); 58 | } 59 | break; 60 | } 61 | default: 62 | break; 63 | } 64 | } 65 | } 66 | }; 67 | const buttonUnpauseAll = { 68 | type: "button", 69 | props: { 70 | id: "buttonUnpauseAll", 71 | bgcolor: $color("green") 72 | }, 73 | views: [ 74 | { 75 | type: "image", 76 | props: { 77 | symbol: "play.fill", 78 | contentMode: 1, 79 | tintColor: $color("gray"), 80 | alpha: 0.5 81 | }, 82 | layout: $layout.fill 83 | } 84 | ], 85 | layout: function (make, view) { 86 | make.centerY.equalTo(view.super); 87 | make.left.equalTo($("buttonAdd").right).offset(10); 88 | make.height.equalTo(32); 89 | make.width.equalTo($("buttonAdd").width); 90 | }, 91 | events: { 92 | tapped: async function (sender) { 93 | try { 94 | await utility.callRPC("unpauseAll"); 95 | await refresh(); 96 | } catch (err) { 97 | $ui.toast("失败"); 98 | console.info(err); 99 | } 100 | } 101 | } 102 | }; 103 | const buttonPauseAll = { 104 | type: "button", 105 | props: { 106 | id: "buttonPauseAll", 107 | bgcolor: $color("orange") 108 | }, 109 | views: [ 110 | { 111 | type: "image", 112 | props: { 113 | symbol: "pause.fill", 114 | contentMode: 1, 115 | tintColor: $color("white"), 116 | alpha: 0.5 117 | }, 118 | layout: $layout.fill 119 | } 120 | ], 121 | layout: function (make, view) { 122 | make.centerY.equalTo(view.super); 123 | make.left.equalTo($("buttonUnpauseAll").right).offset(10); 124 | make.height.equalTo(32); 125 | make.width.equalTo($("buttonUnpauseAll").width); 126 | }, 127 | events: { 128 | tapped: async function (sender) { 129 | try { 130 | await utility.callRPC("pauseAll"); 131 | await refresh(); 132 | } catch (err) { 133 | $ui.toast("失败"); 134 | console.info(err); 135 | } 136 | } 137 | } 138 | }; 139 | const buttonRemoveFinished = { 140 | type: "button", 141 | props: { 142 | id: "buttonRemoveFinished", 143 | bgcolor: $color("red") 144 | }, 145 | views: [ 146 | { 147 | type: "image", 148 | props: { 149 | symbol: "trash.fill", 150 | contentMode: 1, 151 | tintColor: $color("white"), 152 | alpha: 0.5 153 | }, 154 | layout: $layout.fill 155 | } 156 | ], 157 | layout: function (make, view) { 158 | make.centerY.equalTo(view.super); 159 | make.left.equalTo($("buttonPauseAll").right).offset(10); 160 | make.height.equalTo(32); 161 | make.width.equalTo($("buttonPauseAll").width); 162 | }, 163 | events: { 164 | tapped: async function (sender) { 165 | try { 166 | await utility.callRPC("purgeDownloadResult"); 167 | await refresh(); 168 | } catch (err) { 169 | $ui.toast("失败"); 170 | console.info(err); 171 | } 172 | } 173 | } 174 | }; 175 | const toolsView = { 176 | type: "view", 177 | props: { 178 | id: "toolsView", 179 | bgcolor: $color("#f3f3f4", "darkGray") 180 | }, 181 | views: [buttonAdd, buttonUnpauseAll, buttonPauseAll, buttonRemoveFinished], 182 | layout: function (make, view) { 183 | make.top.left.right.inset(0); 184 | make.height.equalTo(50); 185 | } 186 | }; 187 | return toolsView; 188 | } 189 | 190 | function defineFooterView() { 191 | const labelSpeed = { 192 | type: "label", 193 | props: { 194 | id: "labelSpeed", 195 | text: "⇩0 B/s ⇧0 B/s", 196 | align: $align.left 197 | }, 198 | layout: function (make, view) { 199 | make.top.inset(0); 200 | make.left.inset(10); 201 | make.height.equalTo(32); 202 | make.right.equalTo($("labelVersion").left); 203 | } 204 | }; 205 | const labelVersion = { 206 | type: "label", 207 | props: { 208 | id: "labelVersion", 209 | text: "Aria2", 210 | align: $align.right 211 | }, 212 | layout: function (make, view) { 213 | make.top.inset(0); 214 | make.right.inset(10); 215 | make.height.equalTo(32); 216 | make.width.equalTo(100); 217 | } 218 | }; 219 | const footerView = { 220 | type: "view", 221 | props: { 222 | id: "footerView", 223 | bgcolor: $color("#f3f3f4", "darkGray") 224 | }, 225 | views: [labelVersion, labelSpeed], 226 | layout: function (make, view) { 227 | make.bottom.left.right.equalTo(view.super.safeArea); 228 | make.height.equalTo(32); 229 | } 230 | }; 231 | return footerView; 232 | } 233 | 234 | const template = { 235 | views: [ 236 | { 237 | type: "view", 238 | props: { 239 | id: "background", 240 | alpha: 0.25 241 | }, 242 | layout: $layout.fill, 243 | views: [ 244 | { 245 | type: "view", 246 | props: { 247 | id: "inner" 248 | } 249 | } 250 | ], 251 | events: { 252 | layoutSubviews: sender => { 253 | const inner = sender.get("inner"); 254 | const bounds = sender.frame; 255 | const percentage = sender.info.percentage; 256 | inner.frame = $rect(0, 0, bounds.width * percentage, bounds.height); 257 | inner.bgcolor = sender.info.innerColor; 258 | } 259 | } 260 | }, 261 | { 262 | type: "image", 263 | props: { 264 | id: "icon" 265 | }, 266 | layout: function (make, view) { 267 | make.size.equalTo($size(30, 30)); 268 | make.centerY.equalTo(view.super); 269 | make.left.inset(10); 270 | } 271 | }, 272 | { 273 | type: "label", 274 | props: { 275 | id: "title" 276 | }, 277 | layout: function (make, view) { 278 | make.height.equalTo(32); 279 | make.top.inset(0); 280 | make.left.equalTo($("icon").right).inset(10); 281 | make.right.inset(0); 282 | } 283 | }, 284 | { 285 | type: "label", 286 | props: { 287 | id: "size", 288 | align: $align.right, 289 | font: $font(11), 290 | autoFontSize: true 291 | }, 292 | layout: function (make, view) { 293 | make.size.equalTo($size(110, 32)); 294 | make.bottom.inset(0); 295 | make.right.inset(10); 296 | } 297 | }, 298 | { 299 | type: "label", 300 | props: { 301 | id: "speed", 302 | align: $align.left, 303 | font: $font(11), 304 | autoFontSize: true 305 | }, 306 | layout: function (make, view) { 307 | make.height.equalTo(32); 308 | make.bottom.inset(0); 309 | make.left.equalTo($("icon").right).inset(10); 310 | make.right.equalTo($("size").left); 311 | } 312 | } 313 | ] 314 | }; 315 | 316 | function getData(result) { 317 | function handleItems(items) { 318 | items.reverse(); 319 | const colors = { 320 | active: $color("green"), 321 | waiting: $color("white"), 322 | paused: $color("yellow"), 323 | error: $color("red"), 324 | complete: $color("green"), 325 | removed: $color("gray") 326 | }; 327 | const icons = { 328 | active: "icloud.and.arrow.down.fill", 329 | waiting: "pause.circle", 330 | paused: "pause.circle", 331 | error: "xmark.circle.fill", 332 | complete: "checkmark.circle.fill", 333 | removed: "trash.fill" 334 | }; 335 | return items.map(n => { 336 | let title = ""; 337 | if (n.bittorrent && n.bittorrent.info && n.bittorrent.info.name) { 338 | title = n.bittorrent.info.name; 339 | } else if (n.files.length === 1 && n.files[0].path) { 340 | title = n.files[0].path.split("/").pop(); 341 | } 342 | const speedText = 343 | n.status === "complete" 344 | ? "" 345 | : `⇣${utility.getAdjustedFormatBytes( 346 | n.downloadSpeed 347 | )}/s ⇡${utility.getAdjustedFormatBytes(n.uploadSpeed)}/s`; 348 | const remainingTime = 349 | n.status === "active" 350 | ? parseInt(n.downloadSpeed) 351 | ? " ETA: " + 352 | utility.formatTime( 353 | (parseInt(n.totalLength) - parseInt(n.completedLength)) / 354 | parseInt(n.downloadSpeed) 355 | ) 356 | : " ETA: INF" 357 | : ""; 358 | return { 359 | background: { 360 | info: { 361 | percentage: 362 | n.totalLength !== "0" 363 | ? parseInt(n.completedLength) / parseInt(n.totalLength) 364 | : 0, 365 | innerColor: colors[n.status] 366 | } 367 | }, 368 | icon: { 369 | symbol: icons[n.status] 370 | }, 371 | title: { 372 | text: title, 373 | info: { 374 | gid: n.gid, 375 | status: n.status 376 | } 377 | }, 378 | speed: { 379 | text: speedText + remainingTime 380 | }, 381 | size: { 382 | text: 383 | n.status === "complete" 384 | ? utility.getAdjustedFormatBytes(n.totalLength) 385 | : utility.getAdjustedFormatBytes(n.completedLength) + 386 | "/" + 387 | utility.getAdjustedFormatBytes(n.totalLength) 388 | } 389 | }; 390 | }); 391 | } 392 | return [ 393 | ...handleItems(result.active), 394 | ...handleItems(result.waiting), 395 | ...handleItems(result.stopped) 396 | ]; 397 | } 398 | 399 | function defineListView() { 400 | const list = { 401 | type: "list", 402 | props: { 403 | id: "list", 404 | rowHeight: 64, 405 | template: template, 406 | actions: [ 407 | { 408 | title: "Remove", 409 | color: $color("red"), 410 | handler: async function (sender, indexPath) { 411 | const info = sender.data[indexPath.row].title.info; 412 | try { 413 | if ( 414 | ["complete", "error", "removed"].indexOf(info.status) !== -1 415 | ) { 416 | await utility.callRPC("removeDownloadResult", [info.gid]); 417 | } else { 418 | await utility.callRPC("remove", [info.gid]); 419 | } 420 | await refresh(); 421 | } catch (err) { 422 | console.info(err); 423 | $ui.toast("remove失败"); 424 | } 425 | } 426 | } 427 | ] 428 | }, 429 | layout: function (make, view) { 430 | make.top.equalTo($("toolsView").bottom); 431 | make.bottom.equalTo($("footerView").top); 432 | make.left.right.inset(0); 433 | }, 434 | events: { 435 | ready: async function (sender) { 436 | const version = await utility.getVersion(); 437 | $("labelVersion").text = "Aria2" + " " + version; 438 | while (sender.super) { 439 | if (!sender.hasActiveAction) { 440 | await refresh(); 441 | } 442 | await $wait($prefs.get("refresh_interval") || 5); 443 | } 444 | }, 445 | forEachItem: (view, indexPath) => { 446 | const wrapper = view.get("background"); 447 | const inner = wrapper.get("inner"); 448 | const percentage = wrapper.info.percentage; 449 | inner.frame = $rect( 450 | 0, 451 | 0, 452 | wrapper.frame.width * percentage, 453 | wrapper.frame.height 454 | ); 455 | inner.bgcolor = wrapper.info.innerColor; 456 | }, 457 | didSelect: function (sender, indexPath, data) { 458 | const info = data.title.info; 459 | detailViewGenerator.init(info.gid); 460 | }, 461 | didLongPress: async function (sender, indexPath, data) { 462 | const info = data.title.info; 463 | if (info.status === "paused") { 464 | await utility.callRPC("unpause", [info.gid]); 465 | await refresh(); 466 | } else if (info.status === "active" || info.status === "waiting") { 467 | await utility.callRPC("pause", [info.gid]); 468 | await refresh(); 469 | } else if (info.status === "error") { 470 | const taskStatus = await utility.callRPC("tellStatus", [info.gid]); 471 | const taskOption = await utility.callRPC("getOption", [info.gid]); 472 | const taskUris = taskStatus.files[0].uris; 473 | const retryUris = taskUris.map(n => { 474 | return n.uri; 475 | }); 476 | try { 477 | await utility.callRPC("addUri", [retryUris, taskOption]); 478 | await refresh(); 479 | } catch (err) { 480 | $ui.toast("失败"); 481 | console.info(err); 482 | } 483 | //console.log(retryUris) 484 | } 485 | } 486 | } 487 | }; 488 | return list; 489 | } 490 | 491 | function defineClientView() { 492 | const footerView = defineFooterView(); 493 | const toolsView = defineToolsView(); 494 | const listView = defineListView(); 495 | const clientView = { 496 | type: "view", 497 | props: { 498 | id: "clientView" 499 | }, 500 | views: [toolsView, footerView, listView], 501 | layout: $layout.fill 502 | }; 503 | return clientView; 504 | } 505 | 506 | async function refresh() { 507 | const list = $("list"); 508 | const result = await utility.getStatus(); 509 | if (result && !list.hasActiveAction) { 510 | list.data = getData(result); 511 | $("labelSpeed").text = `▼ ${utility.getAdjustedFormatBytes( 512 | result.downloadSpeed 513 | )}/s ▲ ${utility.getAdjustedFormatBytes(result.uploadSpeed)}/s`; 514 | } 515 | } 516 | 517 | module.exports = { 518 | defineClientView 519 | }; 520 | -------------------------------------------------------------------------------- /scripts/peerid.js: -------------------------------------------------------------------------------- 1 | const azStyleClients = { 2 | "A~": "Ares", 3 | AG: "Ares", 4 | AN: "Ares", 5 | AR: "Ares", 6 | AV: "Avicora", 7 | AX: "BitPump", 8 | AT: "Artemis", 9 | AZ: "Vuze", 10 | BB: "BitBuddy", 11 | BC: "BitComet", 12 | BE: "BitTorrent SDK", 13 | BF: "BitFlu", 14 | BG: "BTG", 15 | bk: "BitKitten (libtorrent)", 16 | BR: "BitRocket", 17 | BS: "BTSlave", 18 | BW: "BitWombat", 19 | BX: "BittorrentX", 20 | CB: "Shareaza Plus", 21 | CD: "Enhanced CTorrent", 22 | CT: "CTorrent", 23 | DP: "Propogate Data Client", 24 | DE: "Deluge", 25 | EB: "EBit", 26 | ES: "Electric Sheep", 27 | FC: "FileCroc", 28 | FG: "FlashGet", 29 | FT: "FoxTorrent/RedSwoosh", 30 | GR: "GetRight", 31 | GS: "GSTorrent", 32 | HL: "Halite", 33 | HN: "Hydranode", 34 | KG: "KGet", 35 | KT: "KTorrent", 36 | LC: "LeechCraft", 37 | LH: "LH-ABC", 38 | LK: "linkage", 39 | LP: "Lphant", 40 | LT: "libtorrent (Rasterbar)", 41 | lt: "libTorrent (Rakshasa)", 42 | LW: "LimeWire", 43 | MO: "MonoTorrent", 44 | MP: "MooPolice", 45 | MR: "Miro", 46 | MT: "MoonlightTorrent", 47 | NE: "BT Next Evolution", 48 | NX: "Net Transport", 49 | OS: "OneSwarm", 50 | OT: "OmegaTorrent", 51 | PC: "CacheLogic", 52 | PD: "Pando", 53 | PE: "PeerProject", 54 | pX: "pHoeniX", 55 | qB: "qBittorrent", 56 | QD: "qqdownload", 57 | RT: "Retriever", 58 | RZ: "RezTorrent", 59 | "S~": "Shareaza alpha/beta", 60 | SB: "SwiftBit", 61 | SD: "\u8FC5\u96F7\u5728\u7EBF (Xunlei)", 62 | SG: "GS Torrent", 63 | SN: "ShareNET", 64 | SP: "BitSpirit", 65 | SS: "SwarmScope", 66 | ST: "SymTorrent", 67 | st: "SharkTorrent", 68 | SZ: "Shareaza", 69 | TN: "Torrent.NET", 70 | TR: "Transmission", 71 | TS: "TorrentStorm", 72 | TT: "TuoTu", 73 | UL: "uLeecher!", 74 | UT: "\u00B5Torrent", 75 | UM: "\u00B5Torrent Mac", 76 | WT: "Bitlet", 77 | WW: "WebTorrent", 78 | WY: "FireTorrent", 79 | VG: "\u54c7\u560E (Vagaa)", 80 | XL: "\u8FC5\u96F7\u5728\u7EBF (Xunlei)", 81 | XT: "XanTorrent", 82 | XX: "XTorrent", 83 | XC: "XTorrent", 84 | ZT: "ZipTorrent", 85 | "7T": "aTorrent", 86 | "#@": "Invalid PeerID" 87 | }; 88 | 89 | const shadowStyleClients = { 90 | A: "ABC", 91 | O: "Osprey Permaseed", 92 | Q: "BTQueue", 93 | R: "Tribler", 94 | S: "Shad0w", 95 | T: "BitTornado", 96 | U: "UPnP NAT" 97 | }; 98 | 99 | const mainlineStyleClients = { 100 | M: "Mainline", 101 | Q: "Queen Bee" 102 | }; 103 | 104 | const customStyleClients = [ 105 | { 106 | id: "-UT170-", 107 | client: "µTorrent", 108 | position: 0 109 | }, 110 | { 111 | id: "Azureus", 112 | client: "Azureus", 113 | position: 0 114 | }, 115 | { 116 | id: "Azureus", 117 | client: "Azureus", 118 | position: 5 119 | }, 120 | { 121 | id: "-aria2-", 122 | client: "Aria", 123 | position: 0 124 | }, 125 | { 126 | id: "PRC.P---", 127 | client: "BitTorrent Plus!", 128 | position: 0 129 | }, 130 | { 131 | id: "P87.P---", 132 | client: "BitTorrent Plus!", 133 | position: 0 134 | }, 135 | { 136 | id: "S587Plus", 137 | client: "BitTorrent Plus!", 138 | position: 0 139 | }, 140 | { 141 | id: "AZ2500BT", 142 | client: "BitTyrant (Azureus Mod)", 143 | position: 0 144 | }, 145 | { 146 | id: "BLZ", 147 | client: "Blizzard Downloader", 148 | position: 0 149 | }, 150 | { 151 | id: "BG", 152 | client: "BTGetit", 153 | position: 10 154 | }, 155 | { 156 | id: "btuga", 157 | client: "BTugaXP", 158 | position: 0 159 | }, 160 | { 161 | id: "BTuga", 162 | client: "BTugaXP", 163 | position: 5 164 | }, 165 | { 166 | id: "oernu", 167 | client: "BTugaXP", 168 | position: 0 169 | }, 170 | { 171 | id: "BTDWV-", 172 | client: "Deadman Walking", 173 | position: 0 174 | }, 175 | { 176 | id: "Deadman Walking-", 177 | client: "Deadman", 178 | position: 0 179 | }, 180 | { 181 | id: "Ext", 182 | client: "External Webseed", 183 | position: 0 184 | }, 185 | { 186 | id: "-G3", 187 | client: "G3 Torrent", 188 | position: 0 189 | }, 190 | { 191 | id: "271-", 192 | client: "GreedBT", 193 | position: 0 194 | }, 195 | { 196 | id: "arclight", 197 | client: "Hurricane Electric", 198 | position: 0 199 | }, 200 | { 201 | id: "-WS", 202 | client: "HTTP Seed", 203 | position: 0 204 | }, 205 | { 206 | id: "10-------", 207 | client: "JVtorrent", 208 | position: 0 209 | }, 210 | { 211 | id: "LIME", 212 | client: "Limewire", 213 | position: 0 214 | }, 215 | { 216 | id: "martini", 217 | client: "Martini Man", 218 | position: 0 219 | }, 220 | { 221 | id: "Pando", 222 | client: "Pando", 223 | position: 0 224 | }, 225 | { 226 | id: "PEERAPP", 227 | client: "PeerApp", 228 | position: 0 229 | }, 230 | { 231 | id: "btfans", 232 | client: "SimpleBT", 233 | position: 4 234 | }, 235 | { 236 | id: "a00---0", 237 | client: "Swarmy", 238 | position: 0 239 | }, 240 | { 241 | id: "a02---0", 242 | client: "Swarmy", 243 | position: 0 244 | }, 245 | { 246 | id: "T00---0", 247 | client: "Teeweety", 248 | position: 0 249 | }, 250 | { 251 | id: "346-", 252 | client: "TorrentTopia", 253 | position: 0 254 | }, 255 | { 256 | id: "DansClient", 257 | client: "XanTorrent", 258 | position: 0 259 | }, 260 | { 261 | id: "-MG1", 262 | client: "MediaGet", 263 | position: 0 264 | }, 265 | { 266 | id: "-MG21", 267 | client: "MediaGet", 268 | position: 0 269 | }, 270 | { 271 | id: "S3-", 272 | client: "Amazon AWS S3", 273 | position: 0 274 | }, 275 | { 276 | id: "DNA", 277 | client: "BitTorrent DNA", 278 | position: 0 279 | }, 280 | { 281 | id: "OP", 282 | client: "Opera", 283 | position: 0 284 | }, 285 | { 286 | id: "O", 287 | client: "Opera", 288 | position: 0 289 | }, 290 | { 291 | id: "Mbrst", 292 | client: "Burst!", 293 | position: 0 294 | }, 295 | { 296 | id: "turbobt", 297 | client: "TurboBT", 298 | position: 0 299 | }, 300 | { 301 | id: "btpd", 302 | client: "BT Protocol Daemon", 303 | position: 0 304 | }, 305 | { 306 | id: "Plus", 307 | client: "Plus!", 308 | position: 0 309 | }, 310 | { 311 | id: "XBT", 312 | client: "XBT", 313 | position: 0 314 | }, 315 | { 316 | id: "-BOW", 317 | client: "BitsOnWheels", 318 | position: 0 319 | }, 320 | { 321 | id: "eX", 322 | client: "eXeem", 323 | position: 0 324 | }, 325 | { 326 | id: "-ML", 327 | client: "MLdonkey", 328 | position: 0 329 | }, 330 | { 331 | id: "BitLet", 332 | client: "Bitlet", 333 | position: 0 334 | }, 335 | { 336 | id: "AP", 337 | client: "AllPeers", 338 | position: 0 339 | }, 340 | { 341 | id: "BTM", 342 | client: "BTuga Revolution", 343 | position: 0 344 | }, 345 | { 346 | id: "RS", 347 | client: "Rufus", 348 | position: 2 349 | }, 350 | { 351 | id: "BM", 352 | client: "BitMagnet", 353 | position: 2 354 | }, 355 | { 356 | id: "QVOD", 357 | client: "QVOD", 358 | position: 0 359 | }, 360 | { 361 | id: "TB", 362 | client: "Top-BT", 363 | position: 0 364 | }, 365 | { 366 | id: "TIX", 367 | client: "Tixati", 368 | position: 0 369 | }, 370 | { 371 | id: "-FL", 372 | client: "folx", 373 | position: 0 374 | }, 375 | { 376 | id: "-UM", 377 | client: "µTorrent Mac", 378 | position: 0 379 | }, 380 | { 381 | id: "-UT", 382 | client: "µTorrent", 383 | position: 0 384 | } 385 | ]; 386 | 387 | if (typeof String.prototype.endsWith !== "function") { 388 | String.prototype.endsWith = function(str) { 389 | return this.slice(-str.length) === str; 390 | }; 391 | } 392 | 393 | if (typeof String.prototype.startsWith !== "function") { 394 | String.prototype.startsWith = function(str, index) { 395 | index = index || 0; 396 | return this.slice(index, index + str.length) === str; 397 | }; 398 | } 399 | 400 | function isDigit(s) { 401 | var code = s.charCodeAt(0); 402 | return code >= "0".charCodeAt(0) && code <= "9".charCodeAt(0); 403 | } 404 | 405 | function isLetter(s) { 406 | var code = s.toLowerCase().charCodeAt(0); 407 | return code >= "a".charCodeAt(0) && code <= "z".charCodeAt(0); 408 | } 409 | 410 | function isAlphaNumeric(s) { 411 | return isDigit(s) || isLetter(s) || s === "."; 412 | } 413 | 414 | function isPossibleSpoofClient(peerId) { 415 | return peerId.endsWith("UDP0") || peerId.endsWith("HTTPBT"); 416 | } 417 | 418 | function isAzStyle(peerId) { 419 | if (peerId.charAt(0) !== "-") return false; 420 | if (peerId.charAt(7) === "-") return true; 421 | 422 | /** 423 | * Hack for FlashGet - it doesn't use the trailing dash. 424 | * Also, LH-ABC has strayed into "forgetting about the delimiter" territory. 425 | * 426 | * In fact, the code to generate a peer ID for LH-ABC is based on BitTornado's, 427 | * yet tries to give an Az style peer ID... oh dear. 428 | * 429 | * BT Next Evolution seems to be in the same boat as well. 430 | * 431 | * KTorrent 3 appears to use a dash rather than a final character. 432 | */ 433 | if (peerId.substring(1, 3) === "FG") return true; 434 | if (peerId.substring(1, 3) === "LH") return true; 435 | if (peerId.substring(1, 3) === "NE") return true; 436 | if (peerId.substring(1, 3) === "KT") return true; 437 | if (peerId.substring(1, 3) === "SP") return true; 438 | 439 | return false; 440 | } 441 | 442 | /** 443 | * Checking whether a peer ID is Shadow style or not is a bit tricky. 444 | * 445 | * The BitTornado peer ID convention code is explained here: 446 | * http://forums.degreez.net/viewtopic.php?t=7070 447 | * 448 | * The main thing we are interested in is the first six characters. 449 | * Although the other characters are base64 characters, there's no 450 | * guarantee that other clients which follow that style will follow 451 | * that convention (though the fact that some of these clients use 452 | * BitTornado in the core does blur the lines a bit between what is 453 | * "style" and what is just common across clients). 454 | * 455 | * So if we base it on the version number information, there's another 456 | * problem - there isn't the use of absolute delimiters (no fixed dash 457 | * character, for example). 458 | * 459 | * There are various things we can do to determine how likely the peer 460 | * ID is to be of that style, but for now, I'll keep it to a relatively 461 | * simple check. 462 | * 463 | * We'll assume that no client uses the fifth version digit, so we'll 464 | * expect a dash. We'll also assume that no client has reached version 10 465 | * yet, so we expect the first two characters to be "letter,digit". 466 | * 467 | * We've seen some clients which don't appear to contain any version 468 | * information, so we need to allow for that. 469 | */ 470 | function isShadowStyle(peerId) { 471 | if (peerId.charAt(5) !== "-") return false; 472 | if (!isLetter(peerId.charAt(0))) return false; 473 | if (!(isDigit(peerId.charAt(1)) || peerId.charAt(1) === "-")) return false; 474 | 475 | // Find where the version number string ends. 476 | var lastVersionNumberIndex = 4; 477 | for (; lastVersionNumberIndex > 0; lastVersionNumberIndex--) { 478 | if (peerId.charAt(lastVersionNumberIndex) !== "-") break; 479 | } 480 | 481 | // For each digit in the version string, check if it is a valid version identifier. 482 | for (var i = 1; i <= lastVersionNumberIndex; i++) { 483 | var c = peerId.charAt(i); 484 | if (c === "-") return false; 485 | if (isAlphaNumeric(c) === null) return false; 486 | } 487 | return true; 488 | } 489 | 490 | function isMainlineStyle(peerId) { 491 | /** 492 | * One of the following styles will be used: 493 | * Mx-y-z-- 494 | * Mx-yy-z- 495 | */ 496 | return ( 497 | peerId.charAt(2) === "-" && 498 | peerId.charAt(7) === "-" && 499 | (peerId.charAt(4) === "-" || peerId.charAt(5) === "-") 500 | ); 501 | } 502 | 503 | function decodeBitSpiritClient(peerId) { 504 | if (peerId.substring(2, 4) !== "BS") return null; 505 | return { 506 | client: "BitSpirit" 507 | }; 508 | } 509 | 510 | function decodeBitCometClient(peerId) { 511 | var modName = ""; 512 | if (peerId.startsWith("exbc")) modName = ""; 513 | else if (peerId.startsWith("FUTB")) modName = "(Solidox Mod)"; 514 | else if (peerId.startsWith("xUTB")) modName = "(Mod 2)"; 515 | else return null; 516 | 517 | var isBitlord = peerId.substring(6, 10) === "LORD"; 518 | 519 | // Older versions of BitLord are of the form x.yy, whereas new versions (1 and onwards), 520 | // are of the form x.y. BitComet is of the form x.yy 521 | var clientName = isBitlord ? "BitLord" : "BitComet"; 522 | 523 | return { 524 | client: clientName + (modName ? " " + modName : "") 525 | }; 526 | } 527 | 528 | function getAzStyleClientName(peerId) { 529 | return azStyleClients[peerId.substring(1, 3)]; 530 | } 531 | 532 | function getShadowStyleClientName(peerId) { 533 | return shadowStyleClients[peerId.substring(0, 1)]; 534 | } 535 | 536 | function getMainlineStyleClientName(peerId) { 537 | return mainlineStyleClients[peerId.substring(0, 1)]; 538 | } 539 | 540 | function getSimpleClient(peerId) { 541 | for (let client of customStyleClients) { 542 | if (peerId.startsWith(client.id, client.position)) { 543 | return client; 544 | } 545 | } 546 | return null; 547 | } 548 | 549 | function parseClient(peerId) { 550 | if (peerId.length !== 20) { 551 | throw new Error( 552 | "Invalid peerId length (hex buffer must be 20 bytes): " + peerId 553 | ); 554 | } 555 | var UNKNOWN = "unknown"; 556 | var FAKE = "fake"; 557 | var client = null; 558 | var data; 559 | 560 | // If the client reuses parts of the peer ID of other peers, then try to determine this 561 | // first (before we misidentify the client). 562 | if (isPossibleSpoofClient(peerId)) { 563 | if ((client = decodeBitSpiritClient(peerId))) return client; 564 | if ((client = decodeBitCometClient(peerId))) return client; 565 | return { 566 | client: "BitSpirit?" 567 | }; 568 | } 569 | 570 | // See if the client uses Az style identification 571 | if (isAzStyle(peerId)) { 572 | if ((client = getAzStyleClientName(peerId))) { 573 | // Hack for fake ZipTorrent clients - there seems to be some clients 574 | // which use the same identifier, but they aren't valid ZipTorrent clients 575 | if (client.startsWith("ZipTorrent") && peerId.startsWith("bLAde", 8)) { 576 | return { 577 | client: UNKNOWN + " [" + FAKE + ": " + name + "]" 578 | }; 579 | } 580 | 581 | // BitTorrent 6.0 Beta currently misidentifies itself 582 | if ("\u00B5Torrent" === client) { 583 | return { 584 | client: "Mainline" 585 | }; 586 | } 587 | 588 | // If it's the rakshasa libtorrent, then it's probably rTorrent 589 | if (client.startsWith("libTorrent (Rakshasa)")) { 590 | return { 591 | client: client + " / rTorrent*" 592 | }; 593 | } 594 | 595 | return { 596 | client: client 597 | }; 598 | } 599 | } 600 | 601 | // See if the client uses Shadow style identification 602 | if (isShadowStyle(peerId)) { 603 | if ((client = getShadowStyleClientName(peerId))) { 604 | // TODO: handle shadow style client version numbers 605 | return { 606 | client: client 607 | }; 608 | } 609 | } 610 | 611 | // See if the client uses Mainline style identification 612 | if (isMainlineStyle(peerId)) { 613 | if ((client = getMainlineStyleClientName(peerId))) { 614 | // TODO: handle mainline style client version numbers 615 | return { 616 | client: client 617 | }; 618 | } 619 | } 620 | 621 | // Check for BitSpirit / BitComet disregarding from spoof mode 622 | if ((client = decodeBitSpiritClient(peerId))) return client; 623 | if ((client = decodeBitCometClient(peerId))) return client; 624 | 625 | // See if the client identifies itself using a particular substring 626 | if ((data = getSimpleClient(peerId))) { 627 | client = data.client; 628 | 629 | // TODO: handle simple client version numbers 630 | return { 631 | client: client 632 | }; 633 | } 634 | 635 | // TODO: handle unknown az-formatted and shadow-formatted clients 636 | return { 637 | client: "unknown" 638 | }; 639 | } 640 | 641 | module.exports = { 642 | parseClient 643 | }; 644 | --------------------------------------------------------------------------------