├── .gitignore ├── LICENSE ├── README.md ├── build ├── 1024x1024.png ├── 128x128.png ├── 16x16.png ├── 24x24.png ├── 256x256.png ├── 32x32.png ├── 48x48.png ├── 512x512.png ├── 64x64.png ├── background.png ├── background.tiff ├── background@2x.png ├── favicon.png ├── icon.icns ├── icon.ico └── setup.png ├── const.js ├── domutil.js ├── downloader.js ├── i18n ├── en_US.json ├── zh_CN.json └── zh_HK.json ├── icons.js ├── index.html ├── main.js ├── package-lock.json ├── package.json ├── preload.js ├── readme ├── feature.cn.svg ├── feature.en.svg ├── install chrome app.png ├── miniui.cn.png ├── miniui.en.png ├── ui.cn.png └── ui.en.png ├── renderer.js ├── resource ├── downloaded.svg ├── downloading.svg ├── exit.svg ├── failed.svg ├── folder.svg ├── github.svg ├── keeptop.svg ├── maximize.svg ├── minimize.svg ├── paste.svg ├── pause.svg ├── quittop.svg ├── resume.svg ├── stop.svg ├── stopwatch.svg ├── unknown.svg ├── waiting.svg └── watch.svg ├── styles.css └── tool └── buildSvgSymbol.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .prettierrc.js 3 | dist/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tairraos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TikDown App [点这里查看中文说明](#中文说明) 2 | 3 | This is a TikTok/Douyin downloader built with Electron. Download the video without watermark by pasting the share link in the clipboard. 4 | 5 | ### Install 6 | - Download the Win/Mac installation package 7 | https://github.com/Tairraos/TikDown/releases/latest 8 | - Or download the portable version to use directly, which is the package with "portable" in the name. 9 | - The application is only available for Mac and Win. If you want to use it under Linux, please clone this repository and build it yourself. 10 | - Install with brew 11 | ``` 12 | brew tap Tairraos/tikdown && brew install --cask tikdown 13 | ``` 14 | - Use this command to check if new version is available 15 | ``` 16 | brew livecheck tikdown 17 | ``` 18 | - The Homebrew tap is maintained here 19 | https://github.com/Tairraos/homebrew-tikdown 20 | 21 | ### Features 22 | ![feature.en](readme/feature.en.svg) 23 | 24 | 25 | ### Comments 26 | - ![Install Chrome App](readme/install%20chrome%20app.png) 27 | - The Chrome app version of TikTok/Douyin works very well. 28 | - Visit TikTok/Douyin site from Chrome (or any other Chromium based browser), and you will see installation icon on the right of the address bar. 29 | - If you are looking for a Python application, there is a tiny version of this app writen in Python, with only 100 lines code. [Go to Python Version](https://github.com/Tairraos/tiktok-downloader.py) 30 | 31 | 32 | ### Screenshot 33 | - ![Normal UI](readme/ui.en.png) 34 | - ![Mini UI](readme/miniui.en.png) 35 | 36 | ### Features will be implemented 37 | 38 | - Bug that List is cleared after closing the interface and opening it again. 39 | - Allow to pause, resume, cancel a downloading task. 40 | - Allow to cancel a task that has not been downloaded yet. 41 | - Allow user to select filename template 42 | - Allow user to save download log 43 | - Batch add download URLs, paste in multiple lines at once. 44 | - Remove the counter at the end of the download file name. 45 | - MAYBE: Batch downad all video of a user 46 | 47 | 48 | **************************************** 49 | 50 | ## 中文说明 51 | 52 | 基于Electron构建的 TikTok/抖音 下载器。通过粘帖剪贴板里的分享链接下载无水印的视频。 53 | 54 | ### 安装 55 | - 下载 Win/Mac 安装包: 56 | https://github.com/Tairraos/TikDown/releases/latest 57 | - 也可以下载便携版直接使用,即文件名里带 "portable" 字样的。 58 | - 仅提供 Mac 和 Win 的 App,如果你需要在 Linux 下使用,请自己克隆仓库编译。 59 | - 或使用brew安装: 60 | ``` 61 | brew tap Tairraos/tikdown && brew install --cask tikdown 62 | ``` 63 | - 用这个命令检测是否有新版本。 64 | ``` 65 | brew livecheck tikdown 66 | ``` 67 | - Homebrew tap在这里维护: 68 | https://github.com/Tairraos/homebrew-tikdown 69 | 70 | ### 功能 71 | ![feature.cn](readme/feature.cn.svg) 72 | 73 | 74 | ### 备注 75 | - ![Install Chrome App](readme/install%20chrome%20app.png) 76 | - Chrome app版本的TikTok/Douyin很好用. 77 | - 用Chrome访问TikTok/Douyin网站,在地址栏最右侧有安装图标。 78 | - 如果你在找 Python App, 我有一个100行代码的Pythont版本。 [跳转到Python版本](https://github.com/Tairraos/tiktok-downloader.py) 79 | 80 | 81 | ### 截图 82 | - ![普通界面](readme/ui.cn.png) 83 | - ![迷你界面](readme/miniui.cn.png) 84 | 85 | ### 将会在后续版本中实现的功能 86 | - BUG: 关闭界面后再打开List被清空。 87 | - 允许暂停,继续,取消一个正在下载的任务。 88 | - 允许取消还未下载的任务。 89 | - 允许用户选择保存的文件名模板。 90 | - 允许用户保存下载日志 91 | - 批量添加下载URL,一次粘帖入多行。 92 | - 移除下载文件名尾部的计数器。 93 | - 可能会做:批量下载某帐号下的所有视频。 94 | 95 | **************************************** 96 |
Reference & Thanks 参考及鸣谢 97 | 98 | - UI Design / UI设计: [MasterGo](https://mastergo.com/file/64638217599752) 99 | - API Information / API 信息: [Github Repo](https://github.com/Evil0ctal/Douyin_TikTok_Download_API) 100 | - background material / 安装程序背景: [TikTok background vector created by BiZkettE1](https://www.freepik.com/vectors/tiktok-background) 101 | - arraw material / 箭头素材: [Trajectory vector created by freepik](https://www.freepik.com/vectors/trajectory) 102 |
103 | -------------------------------------------------------------------------------- /build/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/1024x1024.png -------------------------------------------------------------------------------- /build/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/128x128.png -------------------------------------------------------------------------------- /build/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/16x16.png -------------------------------------------------------------------------------- /build/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/24x24.png -------------------------------------------------------------------------------- /build/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/256x256.png -------------------------------------------------------------------------------- /build/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/32x32.png -------------------------------------------------------------------------------- /build/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/48x48.png -------------------------------------------------------------------------------- /build/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/512x512.png -------------------------------------------------------------------------------- /build/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/64x64.png -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background.png -------------------------------------------------------------------------------- /build/background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background.tiff -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background@2x.png -------------------------------------------------------------------------------- /build/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/favicon.png -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/icon.ico -------------------------------------------------------------------------------- /build/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/setup.png -------------------------------------------------------------------------------- /const.js: -------------------------------------------------------------------------------- 1 | const STATE_PARSING = "Parsing..."; 2 | const STATE_WAITING = "Waiting..."; 3 | const STATE_DOWNLOADING = "Downloading..."; 4 | const STATE_DOWNLOADED = "Downloaded"; 5 | const STATE_FAILED = "Failed"; 6 | const STATE_CANCELED = "Canceled"; 7 | const STATE_PAUSED = "Paused"; 8 | const STATE_OK = "ok"; 9 | const STATE_ERROR = "error"; 10 | 11 | -------------------------------------------------------------------------------- /domutil.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate new dom or selector exist dom like a simplest Sizzle. 3 | * @param {string} arg 4 | * @returns 5 | */ 6 | function $(arg) { 7 | if (arg.match(/^`, 24 | ``, 25 | `${i18n.get(textKey)}`, 26 | `` 27 | ].join(""); 28 | return $(domStr); 29 | } 30 | 31 | /** 32 | * Generate the top bar icon with hover title. 33 | * @param {string} iconName 34 | * @param {string} textKey 35 | * @returns 36 | */ 37 | function genIconButton(iconName, textKey) { 38 | const domStr = [ 39 | `` 42 | ].join(""); 43 | return $(domStr); 44 | } 45 | 46 | /** 47 | * Generate the footer stat. 48 | * @param {string} iconName 49 | * @param {string} textKey 50 | * @param {string} counter 51 | * @returns 52 | */ 53 | function genIconDataStat(iconName, textKey, counter) { 54 | const domStr = [ 55 | `
`, 56 | ``, 57 | `${counter}`, 58 | `` 59 | ].join(""); 60 | return $(domStr); 61 | } 62 | 63 | function genFolderTextBtn() { 64 | return $(``); 65 | } 66 | 67 | /** 68 | * Generate the language selector. 69 | * @returns 70 | */ 71 | function genLangSelector() { 72 | const domArr = [``); 77 | return $(domArr.join("")); 78 | } 79 | 80 | /** 81 | * Generate the task box content. 82 | * @param {object} task 83 | * @returns 84 | */ 85 | function genTaskBox(task) { 86 | const domStr = [ 87 | `
`, 88 | `
`, 89 | `
`, 90 | `
${task.videoUrl}
`, 91 | `
`, 92 | `
`, 93 | ``, 94 | ``, 95 | `
`, 96 | `
`, 97 | `
` 98 | ].join(""); 99 | return $(domStr); 100 | } 101 | 102 | /** 103 | * Show a flash frame to Paste&Download button. 104 | * @param {string} type 105 | */ 106 | function flashPasteBtnUI(type) { 107 | const extClass = `border-flash-${type}`; 108 | dom.btnPaste.classList.add(extClass); 109 | setTimeout(() => dom.btnPaste.classList.remove(extClass), 1000); 110 | } 111 | 112 | /** 113 | * Update the task content with the new data. 114 | * @param {string} domId 115 | * @param {object} data 116 | */ 117 | function updateTaskBoxUI(domId, data) { 118 | const base = `.task-${domId}`, 119 | $container = $(base), 120 | $title = $(base + " .task-title"), 121 | $cover = $(base + " .task-cover"), 122 | $url = $(base + " .task-url"), 123 | $size = $(base + " .task-size"), 124 | $status = $(base + " .task-status"); 125 | data.cover && ($cover.innerHTML = ``); 126 | data.url && ($url.innerText = data.url); 127 | data.title && ($title.innerText = i18n.get(data.title)); 128 | if (data.size) { 129 | $size.innerText = (data.size / 1024).toFixed(1).replace(/\B(?=(?:\d{3})+\b)/g, ",") + "KB"; 130 | } 131 | if (data.status) { 132 | $container.className = `task-box task-${domId} ${data.status.toLowerCase().replace(/\.+$/, "")}`; 133 | $status.innerText = i18n.get(data.status); 134 | $status.setAttribute("data-i18n", `innerText%${data.status}`); 135 | if (data.status === STATE_DOWNLOADED) { 136 | $container.classList.add("canopen"); 137 | $title.addEventListener("click", () => { 138 | utils.openFolder(data.openpath); 139 | }); 140 | } 141 | } 142 | if (data.progress) { 143 | $(`${base} .task-progressbar`).classList.remove("hide"); 144 | $(`${base} .task-progress`).style.width = `${+data.progress * 2}px`; 145 | } 146 | updateFooterStatUI(); 147 | } 148 | 149 | function updateFooterStatUI() { 150 | const counter = updateTaskCounter(); 151 | dom.dataDownloading.innerText = counter.Downloading || 0; 152 | dom.dataWaiting.innerText = (counter.Waiting || 0) + (counter.Parsing || 0); 153 | dom.dataDownloaded.innerText = counter.Downloaded || 0; 154 | dom.dataFailed.innerText = counter.Failed || 0; 155 | } 156 | 157 | /** 158 | * Update the download folder 159 | * @param {string} folder 160 | */ 161 | function updateFolderTextUI(folder) { 162 | if (folder !== "") { 163 | dom.btnFolderText.innerText = folder; 164 | dom.btnFolderText.classList.remove("error"); 165 | } 166 | if (!utils.existDir(dom.btnFolderText.innerText)) { 167 | dom.btnFolderText.classList.add("error"); 168 | } else { 169 | config.target = folder; 170 | utils.setSetting("target", folder); 171 | printFooterLog("You have changed the download folder."); 172 | } 173 | } 174 | 175 | /** 176 | * Iterate throughout the UI, replacing all i18n strings. 177 | * @param {string} lang 178 | */ 179 | function updateI18nStringUI(lang) { 180 | const domList = document.querySelectorAll("[data-i18n]"); 181 | config.lang = lang; 182 | utils.setSetting("lang", lang); 183 | i18n.select(lang); 184 | domList.forEach((item) => { 185 | const [attr, i18nKey] = item.getAttribute("data-i18n").split("%"); 186 | item[attr] = i18n.get(i18nKey); 187 | }); 188 | document.title = i18n.get("TikDown", utils.getVersion()); 189 | printFooterLog("You have changed the display language."); 190 | } 191 | 192 | /** 193 | * Show message in footer. 194 | * @param {string} logKey 195 | */ 196 | function printFooterLog(logKey) { 197 | dom.staLogText.innerText = i18n.get(logKey); 198 | clearTimeout(dom.staLogText.timer); 199 | dom.staLogText.timer = setTimeout(() => { 200 | dom.staLogText.innerText = ""; 201 | }, 8000); 202 | } 203 | -------------------------------------------------------------------------------- /downloader.js: -------------------------------------------------------------------------------- 1 | const downloadQueue = {}, //parsed task, waiting for download, can be cancelled 2 | parseQueue = [], //pasted string, waitting to parse 3 | taskStore = { 4 | newTaskId: 1, 5 | downloadQueue, 6 | parseQueue, 7 | isParseBusy: false, 8 | isDownloadBusy: false, 9 | watchHandler: null, 10 | lastClipboard: "" 11 | }; 12 | 13 | function parseContent(urlStr) { 14 | let parsed = urlStr.match( 15 | /https?:\/\/(www\.tiktok\.com\/@[^/]+\/video\/(\d+)|vm\.tiktok\.com\/([^/]+)\/|www\.douyin\.com\/video\/(\d+)|v\.douyin\.com\/([^/]+)\/)/ 16 | ); 17 | 18 | return parsed 19 | ? { videoUrl: parsed[0], type: parsed[1].replace(/((www|v|vm)\.(tiktok|douyin)).*/, "$1"), parsedId: parsed.slice(2).filter((n) => n)[0] } 20 | : null; 21 | } 22 | 23 | function watchClipboard(toggle) { 24 | if (toggle) { 25 | taskStore.watchHandler = setInterval(() => { 26 | const clipStr = utils.readClipboard(); 27 | if (taskStore.lastClipboard !== clipStr) { 28 | manageClipboard(clipStr); 29 | } 30 | }, 1000); 31 | } else { 32 | clearInterval(taskStore.watchHandler); 33 | taskStore.watchHandler = null; 34 | } 35 | } 36 | 37 | async function parseVideoId(task) { 38 | if (task.type === "v.douyin" || task.type === "vm.tiktok") { 39 | let parsed = parseContent((await fetchURL(task.videoUrl))["url"]); 40 | return parsed.parsedId; 41 | } 42 | return task.parsedId; 43 | } 44 | 45 | async function manageClipboard(clipStr) { 46 | if (taskStore.lastClipboard === clipStr) { 47 | printFooterLog("The same task is already in the download list."); 48 | flashPasteBtnUI(STATE_ERROR); 49 | return; 50 | } 51 | taskStore.lastClipboard = clipStr; 52 | parseQueue.push(...clipStr.split("\n")); 53 | manageTask(); 54 | } 55 | 56 | async function manageTask() { 57 | if (parseQueue.length === 0 || taskStore.isParseBusy) { 58 | return; 59 | } 60 | 61 | const parsed = parseContent(parseQueue.shift()); 62 | taskStore.isParseBusy = true; 63 | // step 1: parse clipboard to get 64 | if (!parsed) { 65 | printFooterLog("The content of the clipboard is not a valid TikTok/Douyin URL."); 66 | taskStore.isParseBusy = false; 67 | return flashPasteBtnUI(STATE_ERROR); 68 | } 69 | 70 | const parsedId = parsed.parsedId; 71 | if ($(`.task-${parsed.parsedId}`)) { 72 | printFooterLog("The same task is already in the download list."); 73 | taskStore.isParseBusy = false; 74 | return flashPasteBtnUI(STATE_ERROR); 75 | } 76 | 77 | const taskId = taskStore.newTaskId++, 78 | task = { 79 | taskId, 80 | videoUrl: parsed.videoUrl, 81 | type: parsed.type, 82 | parsedId: parsed.parsedId, 83 | domId: parsedId 84 | }; 85 | downloadQueue[taskId] = task; 86 | task.dom = createTaskUI(task); 87 | printFooterLog("You have added a new download task."); 88 | flashPasteBtnUI(STATE_OK); 89 | 90 | // step 2: parse parsedId to get videoId 91 | task.step = STATE_PARSING; 92 | updateTaskBoxUI(task.domId, { status: STATE_PARSING }); 93 | task.videoId = await parseVideoId(task); 94 | 95 | // step 3: parse videoId to get video info 96 | const data = await parseVideoInfo(task); 97 | if (data.success) { 98 | const title = data.title 99 | .replace(/[/\|\n*"':<>()[\]{}.?!‘“’”:()[]{}。?!]/g, " ") //replace invalid chars to space 100 | .replace(/\s+/g, " ") //merge multi space as one 101 | .replace(/&[^;]{3,5};/g, " ") //remove html entities 102 | .replace(/#[^ ]+( |$)/g, "") //remove #tags 103 | .trim() 104 | .replace(/^(.{60}[\w]+.).*/, "$1") //truncate title to 60 chars + last word 105 | .replace(/^(.{80}).*/, "$1"), //truncate title to 80 chars 106 | filename = `${data.author} - ${data.date} - ${title || i18n.get("untitled")}`; 107 | task.step = STATE_WAITING; 108 | updateTaskBoxUI(task.domId, { 109 | status: STATE_WAITING, 110 | title: filename, 111 | cover: data.cover 112 | }); 113 | task.filename = filename; 114 | task.fileurl = data.fileurl; 115 | task.videoCover = data.cover; 116 | downloadWaitingTask(); 117 | } else { 118 | task.step = STATE_FAILED; 119 | updateTaskBoxUI(task.domId, { 120 | status: STATE_FAILED, 121 | title: data.resaon 122 | }); 123 | } 124 | 125 | taskStore.isParseBusy = false; 126 | manageTask(); 127 | } 128 | 129 | function downloadWaitingTask() { 130 | // step 4: download video 131 | if (!taskStore.isDownloadBusy) { 132 | const task = getWaitingTask(); 133 | if (task) { 134 | utils.download({ 135 | taskId: task.taskId, 136 | filename: task.filename, 137 | fileurl: task.fileurl 138 | }); 139 | taskStore.isDownloadBusy = true; 140 | } 141 | } 142 | } 143 | 144 | function updateTaskCounter() { 145 | const result = {}; 146 | for (let key in downloadQueue) { 147 | let step = downloadQueue[key].step.replace(/\.+$/, ""); 148 | result[step] = (result[step] || 0) + 1; 149 | } 150 | taskStore.counter = result; 151 | return result; 152 | } 153 | 154 | function getWaitingTask() { 155 | for (let key in downloadQueue) { 156 | if (downloadQueue[key].step === STATE_WAITING) { 157 | return downloadQueue[key]; 158 | } 159 | } 160 | } 161 | 162 | async function parseVideoInfo(task) { 163 | let result, apiurl, rootInfo; 164 | switch (task.type) { 165 | case "v.douyin": 166 | case "www.douyin": 167 | apiurl = `https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=${task.videoId}`; 168 | result = await fetchURL(apiurl); 169 | if (result.status_code !== 0) { 170 | return { success: false, reason: result.status_msg }; 171 | } 172 | rootInfo = result["item_list"][0]; 173 | rootInfo.fileurl = (await fetchURL(rootInfo["video"]["play_addr"]["url_list"][0].replace("playwm", "play")))["url"]; 174 | break; 175 | case "vm.tiktok": 176 | case "www.tiktok": 177 | apiurl = `https://api-h2.tiktokv.com/aweme/v1/feed/?version_code=2613&aweme_id=${task.videoId}&device_type=iPad`; 178 | result = await fetchURL(apiurl); 179 | if (result.status_code !== 0) { 180 | return { success: false, reason: result.status_msg }; 181 | } 182 | rootInfo = result["aweme_list"][0]; 183 | rootInfo.fileurl = rootInfo["video"]["play_addr"]["url_list"][0]; 184 | break; 185 | default: 186 | return { 187 | success: false, 188 | resaon: "The content of the clipboard is not a valid TikTok/Douyin URL." 189 | }; 190 | } 191 | return { 192 | success: true, 193 | title: rootInfo["desc"], 194 | fileurl: rootInfo.fileurl, 195 | author: rootInfo["author"]["nickname"], 196 | cover: rootInfo["video"]["cover"]["url_list"][0], 197 | date: new Date(rootInfo.create_time * 1000).toISOString().replace(/-|T.*/g, "") 198 | }; 199 | } 200 | 201 | async function fetchURL(url) { 202 | let headers = new Headers(); 203 | headers.set("Referer", "no-referrer"); 204 | let requestOptions = { 205 | method: "POST", 206 | headers: headers, 207 | redirect: "follow" 208 | }; 209 | let response = await fetch(url, requestOptions); 210 | return response.redirected ? response : await response.json(); 211 | } 212 | 213 | /** 214 | * Put download event handler here, will be bind to IPC in renderer.js 215 | */ 216 | const downloadEventHandler = { 217 | downloadStart: function (data) { 218 | downloadQueue[data.taskId].step = STATE_DOWNLOADING; 219 | updateTaskBoxUI(downloadQueue[data.taskId].domId, { 220 | status: STATE_DOWNLOADING, 221 | title: data.filename, 222 | size: data.size 223 | }); 224 | downloadQueue[data.taskId].filename = data.filename; 225 | }, 226 | 227 | downloadProgress: function (data) { 228 | downloadQueue[data.taskId].step = STATE_DOWNLOADING; 229 | updateTaskBoxUI(downloadQueue[data.taskId].domId, { 230 | status: STATE_DOWNLOADING, 231 | progress: data.progress 232 | }); 233 | }, 234 | 235 | downloadError: function (data) { 236 | downloadQueue[data.taskId].step = STATE_FAILED; 237 | updateTaskBoxUI(downloadQueue[data.taskId].domId, { 238 | status: STATE_FAILED, 239 | title: data.message 240 | }); 241 | }, 242 | 243 | downloadEnd: function (data) { 244 | if (data.isSuccess) { 245 | downloadQueue[data.taskId].step = STATE_DOWNLOADED; 246 | updateTaskBoxUI(downloadQueue[data.taskId].domId, { 247 | status: STATE_DOWNLOADED, 248 | openpath: data.openpath 249 | }); 250 | config.record.push(downloadQueue[data.taskId].videoId); 251 | utils.setSetting("record", config.record); 252 | } else { 253 | onDownloadError(data); 254 | } 255 | taskStore.isDownloadBusy = false; 256 | downloadWaitingTask(); 257 | } 258 | }; 259 | -------------------------------------------------------------------------------- /i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_name": "English", 3 | "TikDown": "TikDown v{0}", 4 | "Paste/Download": "Paste/Download", 5 | "Start clipboard monitoring (auto paste)": "Start clipboard monitoring (auto paste)", 6 | "Stop clipboard monitoring": "Stop clipboard monitoring", 7 | "Keep window on top": "Keep window on top", 8 | "Exit window on top mode": "Exit window on top mode", 9 | "Mini window mode": "Mini window mode", 10 | "Normal window mode": "Normal window mode", 11 | "Change download folder": "Change download folder", 12 | "Open folder": "Open folder", 13 | "Change language": "Change language", 14 | "Exit": "Exit", 15 | "Duplicated": "Duplicated", 16 | "Waiting...": "Waiting...", 17 | "Parsing...": "Parsing...", 18 | "Downloading...": "Downloading...", 19 | "Downloaded": "Downloaded", 20 | "Cancelled": "Cancelled", 21 | "Paused": "Paused", 22 | "Failed": "Failed", 23 | "Github source": "Github source", 24 | "You have added a new download task.": "You have added a new download task.", 25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "The content of the clipboard is not a valid TikTok/Douyin URL.", 26 | "You have changed the download folder.": "You have changed the download folder.", 27 | "You have changed the display language.": "You have changed the display language.", 28 | "The same task is already in the download list.": "The same task is already in the download list.", 29 | "This video was once downloaded, requires a manual launch.": "This video was once downloaded, requires a manual launch.", 30 | "untitled": "untitled" 31 | } 32 | -------------------------------------------------------------------------------- /i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_name": "简体中文", 3 | "TikDown": "TikDown v{0}", 4 | "Paste/Download": "粘帖/下载", 5 | "Start clipboard monitoring (auto paste)": "监视剪贴板(自动粘帖)", 6 | "Stop clipboard monitoring": "停止监视剪贴板", 7 | "Keep window on top": "窗口置顶", 8 | "Exit window on top mode": "取消窗口置顶", 9 | "Mini window mode": "迷你窗口模式", 10 | "Normal window mode": "正常窗口模式", 11 | "Change download folder": "更改下载位置", 12 | "Open folder": "打开文件夹位置", 13 | "Change language": "更改语言", 14 | "Exit": "退出", 15 | "Duplicated": "重复任务", 16 | "Waiting...": "等待下载...", 17 | "Parsing...": "正在解析...", 18 | "Downloading...": "正在下载...", 19 | "Downloaded": "下载完成", 20 | "Cancelled": "取消下载", 21 | "Paused": "暂停下载", 22 | "Failed": "下载失败", 23 | "Github source": "开源地址", 24 | "You have added a new download task.": "你已经添加了一个新的下载任务。", 25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "剪贴板里的内容并非有效的TikTok/Douyin网址。", 26 | "You have changed the download folder.": "您更改了下载文件夹。", 27 | "You have changed the display language.": "您更改了显示语言。", 28 | "The same task is already in the download list.": "下载列表里已经有相同的任务。", 29 | "This video was once downloaded, requires a manual launch.": "这个视频曾经下载过,需要手动启动。", 30 | "untitled": "无标题" 31 | } 32 | -------------------------------------------------------------------------------- /i18n/zh_HK.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_name": "簡體中文", 3 | "TikDown": "TikDown v{0}", 4 | "Paste/Download": "粘帖/下載", 5 | "Start clipboard monitoring (auto paste)": "監視剪貼闆(自動粘帖)", 6 | "Stop clipboard monitoring": "停止監視剪貼闆", 7 | "Keep window on top": "窗口置頂", 8 | "Exit window on top mode": "取消窗口置頂", 9 | "Mini window mode": "迷你窗口模式", 10 | "Normal window mode": "正常窗口模式", 11 | "Change download folder": "更改下載位置", 12 | "Open folder": "打開文件夾位置", 13 | "Change language": "更改語言", 14 | "Exit": "退出", 15 | "Duplicated": "重複任務", 16 | "Waiting...": "等待下載...", 17 | "Parsing...": "正在解析...", 18 | "Downloading...": "正在下載...", 19 | "Downloaded": "下載完成", 20 | "Cancelled": "取消下載", 21 | "Paused": "暫停下載", 22 | "Failed": "下載失敗", 23 | "Github source": "開源地址", 24 | "You have added a new download task.": "你已經添加了一個新的下載任務。", 25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "剪貼闆裡的內容並非有效的TikTok/Douyin網址。", 26 | "You have changed the download folder.": "您更改了下載文件夾。", 27 | "You have changed the display language.": "您更改了顯示語言。", 28 | "The same task is already in the download list.": "下載列表裡已經有相同的任務。", 29 | "This video was once downloaded, requires a manual launch.": "這個視頻曾經下載過,需要手動啟動。", 30 | "untitled": "無標題" 31 | } 32 | -------------------------------------------------------------------------------- /icons.js: -------------------------------------------------------------------------------- 1 | const svgSymbol = [ 2 | '
', 3 | '', 4 | '', 5 | ' ', 6 | ' ', 7 | '', 8 | '', 9 | ' ', 10 | ' ', 11 | '', 12 | '', 13 | ' ', 14 | ' ', 15 | '', 16 | '', 17 | ' ', 18 | ' ', 19 | '', 20 | '', 21 | ' ', 22 | '', 23 | '', 24 | ' ', 25 | ' ', 26 | '', 27 | '', 28 | ' ', 29 | ' ', 30 | '', 31 | '', 32 | ' ', 33 | ' ', 34 | '', 35 | '', 36 | ' ', 37 | ' ', 38 | '', 39 | '', 40 | ' ', 41 | ' ', 42 | ' ', 43 | ' ', 44 | ' ', 45 | ' ', 46 | ' ', 47 | ' ', 48 | ' ', 49 | ' ', 50 | ' ', 51 | ' ', 52 | '', 53 | '', 54 | ' ', 55 | '', 56 | '', 57 | ' ', 58 | ' ', 59 | '', 60 | '', 61 | ' ', 62 | '', 63 | '', 64 | ' ', 65 | '', 66 | '', 67 | ' ', 68 | ' ', 69 | ' ', 70 | '', 71 | '', 72 | ' ', 73 | ' ', 74 | '', 75 | '', 76 | ' ', 77 | ' ', 78 | '', 79 | '', 80 | ' ', 81 | ' ', 82 | ' ', 83 | '', 84 | '', 85 | '
' 86 | ] 87 | document.body.insertAdjacentHTML("afterBegin", svgSymbol.join("")); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | TikDown 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const settings = require("electron-settings"); 5 | const { app, BrowserWindow, ipcMain, dialog } = require("electron"); 6 | const { DownloaderHelper } = require("node-downloader-helper"); 7 | const config = {}; 8 | 9 | /** 10 | * Add setting here 11 | */ 12 | const defaultSettings = { 13 | lang: "en_US", 14 | target: path.join(os.homedir(), "Downloads"), 15 | record: [] 16 | }; 17 | 18 | function createWindow() { 19 | config.mainWindow = new BrowserWindow({ 20 | width: 800, 21 | height: 600, 22 | webPreferences: { 23 | preload: path.join(__dirname, "preload.js") 24 | }, 25 | icon: "resource/favicon.ico" 26 | }); 27 | 28 | // open debug 29 | // config.mainWindow.webContents.openDevTools(); 30 | 31 | // setup user-agent 32 | config.mainWindow.webContents.userAgent = "Mozilla/5.0 (iPad; CPU OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3"; 33 | config.mainWindow.loadFile("index.html"); 34 | } 35 | 36 | function getUnqueFilename(filepath, filename, n = 1) { 37 | if (fs.existsSync(path.join(filepath, filename + ".mp4"))) { 38 | filename = filename.replace(/\(\d+\)$/, "") + `(${n})`; 39 | return getUnqueFilename(filepath, filename, n + 1); 40 | } 41 | return filename; 42 | } 43 | 44 | function initIPC() { 45 | ipcMain.handle("keepTop", (event, toggle) => { 46 | config.mainWindow.setAlwaysOnTop(toggle); 47 | }); 48 | 49 | ipcMain.handle("selectFolder", async () => { 50 | const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }, (folder) => folder); 51 | return result.canceled ? "" : result.filePaths[0]; 52 | }); 53 | 54 | ipcMain.handle("exit", () => { 55 | app.quit(); 56 | }); 57 | 58 | ipcMain.handle("getSetting", (event, item) => { 59 | if (Object.keys(defaultSettings).includes(item)) { 60 | config[item] = settings.getSync(item) || defaultSettings[item]; 61 | return config[item]; 62 | } 63 | }); 64 | 65 | ipcMain.handle("setSetting", (event, item, value) => { 66 | if (Object.keys(defaultSettings).includes(item)) { 67 | config[item] = value; 68 | settings.setSync(item, config[item]); 69 | } 70 | }); 71 | 72 | ipcMain.handle("download", (event, data) => { 73 | const taskId = data.taskId; 74 | 75 | const dl = new DownloaderHelper(data.fileurl, config.target, { 76 | fileName: getUnqueFilename(config.target, data.filename) + ".mp4" 77 | }); 78 | 79 | dl.on("end", (info) => { 80 | config.mainWindow.send("downloadEnd", { taskId, isSuccess: !info.incomplete, openpath: info.filePath }); 81 | }); 82 | dl.on("error", (info) => { 83 | config.mainWindow.send("downloadError", { taskId, message: info.message }); 84 | }); 85 | dl.on("download", (info) => { 86 | config.mainWindow.send("downloadStart", { taskId, size: info.totalSize, filename: info.fileName }); 87 | }); 88 | dl.on("progress", (info) => { 89 | config.mainWindow.send("downloadProgress", { taskId, progress: info.progress }); 90 | }); 91 | dl.start().catch((info) => { 92 | config.mainWindow.send("downloadError", { taskId, message: info.message }); 93 | }); 94 | // config.mainWindow.webContents.downloadURL(data.fileurl + "#" + data.taskId); 95 | }); 96 | 97 | ipcMain.handle("resize", (event, w, h) => { 98 | config.mainWindow.setSize(w, h, true); 99 | }); 100 | } 101 | 102 | const onlyInstance = app.requestSingleInstanceLock(); 103 | if (!onlyInstance) { 104 | app.quit(); 105 | } 106 | 107 | app.on("ready", () => { 108 | createWindow(); 109 | 110 | app.on("activate", function () { 111 | if (BrowserWindow.getAllWindows().length === 0) { 112 | createWindow(); 113 | } 114 | }); 115 | 116 | if (process.platform === "darwin") { 117 | let forceQuit = false; 118 | app.on("before-quit", () => { 119 | forceQuit = true; 120 | }); 121 | config.mainWindow.on("close", (event) => { 122 | if (!forceQuit) { 123 | event.preventDefault(); 124 | config.mainWindow.minimize(); 125 | } 126 | }); 127 | } 128 | 129 | initIPC(); 130 | 131 | }); 132 | 133 | app.on("second-instance", () => config.mainWindow.show()); 134 | 135 | app.on("window-all-closed", function () { 136 | if (process.platform !== "darwin") { 137 | app.quit(); 138 | } 139 | }); 140 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiktok-downloader", 3 | "productName": "TikDown", 4 | "description": "Electron version downloader to download TikTok/Douyin video.", 5 | "keywords": [ 6 | "TikTok", 7 | "Douyin", 8 | "video", 9 | "downloader" 10 | ], 11 | "main": "./main.js", 12 | "version": "1.2.2", 13 | "author": "Tairraos", 14 | "license": "MIT", 15 | "scripts": { 16 | "start": "electron ./main.js", 17 | "debug": "electron --inspect=8888 ./main.js", 18 | "buildsvg": "node tool/buildSvgSymbol.js", 19 | "buildicon": "electron-icon-builder --input=./build/favicon.png --output=build --flatten", 20 | "buildbackground": "tiffutil -cathidpicheck build/background.png build/background@2x.png -out build/background.tiff", 21 | "shasum": "shasum -a 256 dist/TikDown-*.dmg", 22 | "build:mac": "electron-builder --mac", 23 | "build:win": "electron-builder --win" 24 | }, 25 | "dependencies": { 26 | "electron-settings": "^4.0.2", 27 | "node-downloader-helper": "^2.1.3" 28 | }, 29 | "devDependencies": { 30 | "cheerio": "^1.0.0-rc.11", 31 | "electron": "19.0.1", 32 | "electron-builder": "^23.0.3", 33 | "electron-icon-builder": "^2.0.1" 34 | }, 35 | "build": { 36 | "appId": "com.tairraos.tikdown", 37 | "productName": "TikDown", 38 | "copyright": "Copyright © 2022 Tairraos", 39 | "compression": "maximum", 40 | "directories": { 41 | "buildResources": "build", 42 | "output": "dist" 43 | }, 44 | "asar": true, 45 | "mac": { 46 | "target": [ 47 | "dmg", 48 | "zip" 49 | ], 50 | "category": "public.app-category.utilities" 51 | }, 52 | "dmg": { 53 | "icon": "build/icon.icns", 54 | "iconSize": 128, 55 | "background": "build/background.tiff", 56 | "contents": [ 57 | { 58 | "x": 134, 59 | "y": 289, 60 | "type": "file" 61 | }, 62 | { 63 | "x": 405, 64 | "y": 289, 65 | "type": "link", 66 | "path": "/Applications" 67 | } 68 | ], 69 | "window": { 70 | "width": 540, 71 | "height": 490 72 | } 73 | }, 74 | "win": { 75 | "target": [ 76 | "portable", 77 | "nsis" 78 | ], 79 | "icon": "build/icon.ico" 80 | }, 81 | "nsis": { 82 | "oneClick": false, 83 | "language": "2052", 84 | "perMachine": true, 85 | "allowToChangeInstallationDirectory": true 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const package = require(path.join(__dirname, "./package.json")); 4 | const { contextBridge, ipcRenderer, clipboard, shell } = require("electron"); 5 | 6 | const utils = { 7 | //node bridge 8 | getVersion: () => package.version, 9 | openGithub: () => shell.openExternal("https://github.com/Tairraos/tiktok-downloader"), 10 | openFolder: (target) => shell.showItemInFolder(target), 11 | readClipboard: () => clipboard.readText(), 12 | existDir: (dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory(), 13 | existFile: (filepath) => fs.existsSync(filepath), 14 | 15 | //ipc bridge 16 | exit: () => ipcRenderer.invoke("exit"), 17 | toggleKeepTop: (toggle) => ipcRenderer.invoke("keepTop", toggle), 18 | selectFolder: () => ipcRenderer.invoke("selectFolder"), 19 | getSetting: (item) => ipcRenderer.invoke("getSetting", item), 20 | setSetting: (item, value) => ipcRenderer.invoke("setSetting", item, value), 21 | download: (params) => ipcRenderer.invoke("download", params), 22 | resize: (w, h) => ipcRenderer.invoke("resize", w, h) 23 | }; 24 | 25 | const eventList = ["downloadStart", "downloadEnd", "downloadProgress", "downloadPaused", "downloadStopped", "downloadError"]; 26 | const ipc = { 27 | eventList, 28 | eventStore: Object.fromEntries(eventList.map((item) => [item, () => {}])), 29 | addEventListener: (event, callback) => { 30 | if (eventList.includes(event) && typeof callback === "function") { 31 | ipc.eventStore[event] = callback; 32 | } 33 | } 34 | }; 35 | 36 | function prepareI18n(lang) { 37 | const i18n = { 38 | lang: lang, 39 | langList: [], 40 | select: (lang) => { 41 | if (lang.match(/^[a-z]{2}_[A-Z]{2}$/) && typeof i18n[lang] === "object") { 42 | i18n.lang = lang; 43 | } 44 | }, 45 | get: (item, ...args) => { 46 | return (i18n[i18n.lang][item] || item).replace(/\{(\d+)\}/g, function (match, number) { 47 | return args[+number]; 48 | }); 49 | } 50 | }; 51 | const root = path.join(__dirname, "i18n"); 52 | const files = fs.readdirSync(root); 53 | files.forEach((file) => { 54 | const lang = path.basename(file, ".json"); 55 | if (fs.statSync(path.join(root, file)).isFile() && lang.match(/^[a-z]{2}_[A-Z]{2}$/)) { 56 | i18n[lang] = require(path.join(root, file)); 57 | i18n.langList.push({ 58 | name: lang, 59 | local: i18n[lang]["language_name"] 60 | }); 61 | } 62 | }); 63 | return i18n; 64 | } 65 | 66 | async function initApp() { 67 | const lang = await utils.getSetting("lang"), 68 | target = await utils.getSetting("target"), 69 | record = await utils.getSetting("record"); 70 | 71 | contextBridge.exposeInMainWorld("ipc", ipc); 72 | contextBridge.exposeInMainWorld("utils", utils); 73 | contextBridge.exposeInMainWorld("i18n", prepareI18n(lang)); 74 | contextBridge.exposeInMainWorld("setting", { lang, target, record }); 75 | eventList.forEach((item) => ipcRenderer.on(item, (event, data) => ipc.eventStore[item](data))); 76 | } 77 | 78 | contextBridge.exposeInMainWorld("initApp", initApp); 79 | -------------------------------------------------------------------------------- /readme/feature.cn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /readme/install chrome app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/install chrome app.png -------------------------------------------------------------------------------- /readme/miniui.cn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/miniui.cn.png -------------------------------------------------------------------------------- /readme/miniui.en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/miniui.en.png -------------------------------------------------------------------------------- /readme/ui.cn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/ui.cn.png -------------------------------------------------------------------------------- /readme/ui.en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/ui.en.png -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | let dom = { 2 | root: $("#app-root"), 3 | header: $("#header-area"), 4 | footer: $("#footer-area"), 5 | taskLog: $("#task-area"), 6 | headerLeft: $("#header-area .left-area"), 7 | headerRight: $("#header-area .right-area"), 8 | footerLeft: $("#footer-area .left-area"), 9 | footerRight: $("#footer-area .right-area") 10 | }, 11 | taskQue = {}, 12 | config = {}; 13 | 14 | function prepareConfig() { 15 | config.lang = setting.lang; 16 | config.target = setting.target; 17 | config.record = Array.from(setting.record); 18 | } 19 | 20 | function createUI() { 21 | document.title = i18n.get("TikDown", utils.getVersion()); 22 | 23 | dom.btnPaste = genIconTextButton("paste", "Paste/Download"); 24 | dom.btnKeepTop = genIconButton("keeptop", "Keep window on top"); 25 | dom.btnWatch = genIconButton("watch", "Start clipboard monitoring (auto paste)"); 26 | dom.btnStopWatch = genIconButton("stopwatch", "Stop clipboard monitoring"); 27 | dom.btnQuitTop = genIconButton("quittop", "Exit window on top mode"); 28 | dom.btnMiniWin = genIconButton("minimize", "Mini window mode"); 29 | dom.btnNormalWin = genIconButton("maximize", "Normal window mode"); 30 | dom.btnFolder = genIconButton("folder", "Change download folder"); 31 | dom.btnFolderText = genFolderTextBtn(); 32 | dom.selectLang = genLangSelector(); 33 | dom.btnExit = genIconButton("exit", "Exit"); 34 | 35 | dom.btnStopWatch.classList.add("hide"); 36 | dom.btnQuitTop.classList.add("hide"); 37 | dom.btnNormalWin.classList.add("hide"); 38 | 39 | dom.headerLeft.appendChild(dom.btnPaste); 40 | dom.headerRight.appendChild(dom.btnWatch); 41 | dom.headerRight.appendChild(dom.btnStopWatch); 42 | dom.headerRight.appendChild(dom.btnKeepTop); 43 | dom.headerRight.appendChild(dom.btnQuitTop); 44 | dom.headerRight.appendChild(dom.btnMiniWin); 45 | dom.headerRight.appendChild(dom.btnNormalWin); 46 | dom.headerRight.appendChild(dom.btnFolder); 47 | dom.headerRight.appendChild(dom.btnFolderText); 48 | dom.headerRight.appendChild(dom.selectLang); 49 | dom.headerRight.appendChild(dom.btnExit); 50 | 51 | dom.btnGithub = genIconButton("github", "Github source"); 52 | dom.staLogText = $(``); 53 | dom.statDownloading = genIconDataStat("downloading", "Downloading...", 0); 54 | dom.dataDownloading = dom.statDownloading.querySelector(".data"); 55 | dom.statWaiting = genIconDataStat("waiting", "Waiting...", 0); 56 | dom.dataWaiting = dom.statWaiting.querySelector(".data"); 57 | dom.statDownloaded = genIconDataStat("downloaded", "Downloaded", 0); 58 | dom.dataDownloaded = dom.statDownloaded.querySelector(".data"); 59 | dom.statFailed = genIconDataStat("failed", "Failed", 0); 60 | dom.dataFailed = dom.statFailed.querySelector(".data"); 61 | 62 | dom.footerLeft.appendChild(dom.btnGithub); 63 | dom.footerLeft.appendChild(dom.staLogText); 64 | dom.footerRight.appendChild(dom.statDownloading); 65 | dom.footerRight.appendChild(dom.statWaiting); 66 | dom.footerRight.appendChild(dom.statDownloaded); 67 | dom.footerRight.appendChild(dom.statFailed); 68 | } 69 | 70 | function createTaskUI(task) { 71 | const domtask = genTaskBox(task); 72 | dom.taskLog.appendChild(domtask); 73 | dom.taskLog.scrollTo(0, dom.taskLog.scrollHeight); 74 | return domtask; 75 | } 76 | 77 | function bindUIEvent() { 78 | dom.btnPaste.addEventListener("click", () => { 79 | manageClipboard(utils.readClipboard()); 80 | }); 81 | 82 | dom.btnGithub.addEventListener("click", () => { 83 | utils.openGithub(); 84 | }); 85 | 86 | dom.btnWatch.addEventListener("click", () => { 87 | watchClipboard(true); 88 | dom.btnStopWatch.classList.remove("hide"); 89 | dom.btnWatch.classList.add("hide"); 90 | }); 91 | 92 | dom.btnStopWatch.addEventListener("click", () => { 93 | watchClipboard(false); 94 | dom.btnStopWatch.classList.add("hide"); 95 | dom.btnWatch.classList.remove("hide"); 96 | }); 97 | 98 | dom.btnKeepTop.addEventListener("click", () => { 99 | utils.toggleKeepTop(true); 100 | dom.btnQuitTop.classList.remove("hide"); 101 | dom.btnKeepTop.classList.add("hide"); 102 | }); 103 | 104 | dom.btnQuitTop.addEventListener("click", () => { 105 | utils.toggleKeepTop(false); 106 | dom.btnQuitTop.classList.add("hide"); 107 | dom.btnKeepTop.classList.remove("hide"); 108 | }); 109 | 110 | dom.btnMiniWin.addEventListener("click", () => { 111 | dom.root.classList.add("mini"); 112 | dom.btnMiniWin.classList.add("hide"); 113 | dom.root.classList.remove("normal"); 114 | dom.btnNormalWin.classList.remove("hide"); 115 | utils.resize(166, 128); 116 | }); 117 | 118 | dom.btnNormalWin.addEventListener("click", () => { 119 | dom.root.classList.add("normal"); 120 | dom.btnNormalWin.classList.add("hide"); 121 | dom.root.classList.remove("mini"); 122 | dom.btnMiniWin.classList.remove("hide"); 123 | utils.resize(800, 600); 124 | }); 125 | 126 | dom.btnFolder.addEventListener("click", async () => { 127 | updateFolderTextUI(await utils.selectFolder()); 128 | }); 129 | 130 | dom.btnFolderText.addEventListener("click", () => { 131 | const target = dom.btnFolderText.innerText; 132 | if (utils.existDir(target)) { 133 | utils.openFolder(target); 134 | } 135 | }); 136 | 137 | dom.selectLang.addEventListener("change", () => { 138 | updateI18nStringUI(dom.selectLang.value); 139 | }); 140 | 141 | dom.btnExit.addEventListener("click", () => { 142 | utils.exit(); 143 | }); 144 | } 145 | 146 | function bindDownloadEvent() { 147 | Object.keys(downloadEventHandler).forEach((item) => { 148 | ipc.addEventListener(item, downloadEventHandler[item]); 149 | }); 150 | } 151 | 152 | //start rendering 153 | initApp().then(() => { 154 | prepareConfig(); 155 | createUI(); 156 | bindUIEvent(); //bind UI event to DOM 157 | bindDownloadEvent(); //bind download event to IPC 158 | }); 159 | -------------------------------------------------------------------------------- /resource/downloaded.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/downloading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/failed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resource/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/keeptop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/paste.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resource/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resource/quittop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/resume.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resource/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resource/stopwatch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resource/unknown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/waiting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resource/watch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | height: 100%; 4 | margin: 0; 5 | background-color: #d4d4d4; 6 | } 7 | 8 | button, 9 | span, 10 | div { 11 | font-family: Helvetica, Tahoma, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei"; 12 | cursor: default; 13 | } 14 | 15 | button, 16 | select, 17 | button span, 18 | .canopen { 19 | cursor: pointer; 20 | } 21 | 22 | .hide { 23 | display: none !important; 24 | } 25 | 26 | .icon { 27 | display: inline-block; 28 | vertical-align: middle; 29 | } 30 | 31 | .btn-paste .icon { 32 | width: 32px; 33 | height: 32px; 34 | margin-top: 5px; 35 | } 36 | 37 | .normal .icon-btn .icon { 38 | width: 16px; 39 | height: 16px; 40 | } 41 | 42 | .mini .icon-btn .icon { 43 | width: 14px; 44 | height: 14px; 45 | } 46 | 47 | .normal .icon-data-stat .icon { 48 | width: 12px; 49 | height: 12px; 50 | margin-right: 4px; 51 | } 52 | 53 | .mini .icon-data-stat .icon { 54 | width: 12px; 55 | height: 12px; 56 | margin-right: 4px; 57 | } 58 | 59 | #app-root { 60 | display: flex; 61 | flex-direction: column; 62 | height: 100%; 63 | } 64 | 65 | /* layout */ 66 | .normal #header-area { 67 | height: 76px; 68 | background: #d4d4d4; 69 | border-bottom: 1px solid #b0b0b0; 70 | display: flex; 71 | flex-direction: row; 72 | } 73 | 74 | .mini #header-area { 75 | height: 76px; 76 | background: #d4d4d4; 77 | display: flex; 78 | flex-direction: row; 79 | width: 165px; 80 | } 81 | 82 | .normal #header-area .left-area { 83 | line-height: 75px; 84 | flex: 1; 85 | } 86 | 87 | .mini #header-area .left-area { 88 | line-height: 75px; 89 | width: 136px; 90 | } 91 | 92 | .normal #header-area .right-area { 93 | padding: 2px 10px 2px 0; 94 | display: flex; 95 | align-items: flex-end; 96 | } 97 | 98 | .mini #header-area .right-area { 99 | display: flex; 100 | flex-direction: column; 101 | justify-content: center; 102 | } 103 | 104 | .normal #footer-area { 105 | height: 29px; 106 | border-top: 1px solid #b0b0b0; 107 | display: flex; 108 | } 109 | .mini #footer-area { 110 | width: 165px; 111 | height: 16px; 112 | line-height: 12px; 113 | } 114 | 115 | .normal #footer-area .right-area { 116 | flex: 1; 117 | text-align: right; 118 | } 119 | 120 | .mini #footer-area .right-area { 121 | text-align: left; 122 | padding-left: 10px; 123 | } 124 | 125 | .normal #task-area { 126 | flex: 1; 127 | overflow-x: hidden; 128 | overflow-y: auto; 129 | background-color: #f0f0f0; 130 | } 131 | 132 | .mini #task-area, 133 | .mini #footer-area .left-area { 134 | display: none; 135 | } 136 | 137 | /* components */ 138 | .icon-text-btn .text { 139 | display: block; 140 | margin-top: 5px; 141 | font-size: 12px; 142 | color: #333; 143 | height: 12px; 144 | } 145 | 146 | .btn-paste { 147 | height: 66px; 148 | width: 120px; 149 | border-radius: 5px; 150 | vertical-align: middle; 151 | background-color: #d0d0d0; 152 | border: 2px solid transparent; 153 | margin: 4px 8px; 154 | outline: 1px dashed; 155 | } 156 | 157 | .normal .icon-btn { 158 | height: 28px; 159 | width: 28px; 160 | fill: #333; 161 | padding: 0; 162 | margin-left: 4px; 163 | border-radius: 4px; 164 | border: 0; 165 | vertical-align: middle; 166 | background-color: transparent; 167 | } 168 | 169 | .mini .icon-btn { 170 | height: 22px; 171 | width: 24px; 172 | fill: #333; 173 | padding: 0; 174 | border-radius: 4px; 175 | border: 0; 176 | font-size: 0; 177 | background-color: transparent; 178 | } 179 | 180 | .normal .text-stat, 181 | .normal .btn-stat { 182 | height: 20px; 183 | display: inline-block; 184 | text-align: left; 185 | color: #333; 186 | font-size: 13px; 187 | line-height: 20px; 188 | vertical-align: middle; 189 | overflow: hidden; 190 | text-overflow: ellipsis; 191 | white-space: nowrap; 192 | } 193 | 194 | .normal #header-area .btn-stat { 195 | margin: 4px 10px 4px 2px; 196 | padding: 0 5px; 197 | direction: rtl; 198 | background: #f0f0f0; 199 | border: 1px solid #666; 200 | border-radius: 2px; 201 | width: 220px; 202 | } 203 | 204 | .normal #header-area .btn-stat.error { 205 | border: 1px solid #bd403a; 206 | } 207 | 208 | .normal #header-area .select-lang { 209 | height: 20px; 210 | margin: 4px 10px 4px 0; 211 | background: #f0f0f0; 212 | border: 1px solid #666; 213 | border-radius: 2px; 214 | outline: none; 215 | font-size: 13px; 216 | } 217 | 218 | .mini #header-area .btn-stat, 219 | .mini #header-area .select-lang, 220 | .mini #header-area .btn-folder { 221 | display: none; 222 | } 223 | 224 | .icon-btn:hover, 225 | .icon-text-btn:hover { 226 | background-color: #c0c0c0; 227 | } 228 | 229 | .icon-btn:active, 230 | .icon-text-btn:active { 231 | background-color: #b0b0b0; 232 | } 233 | 234 | .normal #footer-area .icon-data-stat { 235 | display: inline-block; 236 | font-size: 0; 237 | height: 18px; 238 | margin-right: 20px; 239 | } 240 | 241 | .mini #footer-area .icon-data-stat { 242 | display: inline-block; 243 | font-size: 0; 244 | margin-right: 12px; 245 | } 246 | 247 | .icon-data-stat .data { 248 | display: inline-block; 249 | color: #333; 250 | font-size: 12px; 251 | vertical-align: middle; 252 | } 253 | 254 | .normal .btn-github { 255 | width: 24px; 256 | height: 24px; 257 | margin: 2px 5px; 258 | } 259 | 260 | .stat-downloading svg { 261 | fill: #d59720; 262 | } 263 | 264 | .stat-waiting svg { 265 | fill: #929191; 266 | } 267 | 268 | .stat-downloaded svg { 269 | fill: #24a137; 270 | } 271 | 272 | .stat-failed svg { 273 | fill: #bd403a; 274 | } 275 | 276 | /* effect */ 277 | body #app-root .border-flash-ok { 278 | animation: flash-green 0.6s linear; 279 | } 280 | 281 | body #app-root .border-flash-error { 282 | animation: flash-red 0.6s linear; 283 | } 284 | 285 | @keyframes flash-green { 286 | 0% { 287 | border-color: #24a137ff; 288 | } 289 | 20% { 290 | border-color: #24a13700; 291 | } 292 | 40% { 293 | border-color: #24a137ff; 294 | } 295 | 60% { 296 | border-color: #24a13700; 297 | } 298 | 80% { 299 | border-color: #24a137ff; 300 | } 301 | 100% { 302 | border-color: #24a13700; 303 | } 304 | } 305 | 306 | @keyframes flash-red { 307 | 0% { 308 | border-color: #bd403aff; 309 | } 310 | 20% { 311 | border-color: #bd403a00; 312 | } 313 | 40% { 314 | border-color: #bd403aff; 315 | } 316 | 60% { 317 | border-color: #bd403a00; 318 | } 319 | 80% { 320 | border-color: #bd403aff; 321 | } 322 | 100% { 323 | border-color: #bd403a00; 324 | } 325 | } 326 | 327 | #task-area .task-box { 328 | height: 80px; 329 | display: flex; 330 | flex-direction: row; 331 | border: 1px solid #929191; 332 | border-radius: 4px; 333 | margin: 6px 5px; 334 | font-size: 13px; 335 | color: #222; 336 | background: #f0f0f0; 337 | } 338 | 339 | #task-area .task-box.downloading { 340 | border-color: #d59720; 341 | } 342 | 343 | #task-area .task-box.waiting { 344 | border-color: #929191; 345 | } 346 | 347 | #task-area .task-box.downloaded { 348 | border-color: #24a137; 349 | } 350 | 351 | #task-area .task-box.failed { 352 | border-color: #bd403a; 353 | } 354 | 355 | #task-area .task-thumb { 356 | width: 135px; 357 | } 358 | 359 | #task-area .task-cover { 360 | width: 120px; 361 | height: 70px; 362 | margin: 4px 5px; 363 | border-radius: 5px; 364 | overflow: hidden; 365 | display: flex; 366 | align-items: center; 367 | } 368 | 369 | #task-area .task-thumb img { 370 | width: 120px; 371 | } 372 | #task-area .task-thumb svg { 373 | width: 96px; 374 | height: 54px; 375 | } 376 | 377 | #task-area .task-info { 378 | display: flex; 379 | align-items: flex-start; 380 | flex-direction: column; 381 | justify-content: center; 382 | width: 540px; 383 | } 384 | #task-area .task-url, 385 | #task-area .task-title, 386 | #task-area .task-download { 387 | height: 20px; 388 | align-items: center; 389 | width: 540px; 390 | overflow: hidden; 391 | text-overflow: ellipsis; 392 | white-space: nowrap; 393 | } 394 | 395 | #task-area .task-download { 396 | display: flex; 397 | flex-direction: row; 398 | align-items: center; 399 | } 400 | 401 | #task-area .task-size { 402 | display: inline-block; 403 | min-width: 60px; 404 | margin-right: 20px; 405 | } 406 | #task-area .task-progressbar { 407 | width: 200px; 408 | height: 4px; 409 | border: 1px solid #ccc; 410 | border-radius: 4px; 411 | display: flex; 412 | } 413 | #task-area .task-progress { 414 | width: 25px; 415 | height: 4px; 416 | display: inline-block; 417 | background: #23a237; 418 | } 419 | 420 | #task-area .task-status { 421 | padding: 10px; 422 | flex: 1; 423 | display: flex; 424 | text-align: center; 425 | align-items: center; 426 | flex-direction: row-reverse; 427 | } 428 | -------------------------------------------------------------------------------- /tool/buildSvgSymbol.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"), 2 | path = require("path"), 3 | cheerio = require("cheerio"), 4 | svgSource = path.join(__dirname, "../resource"), 5 | targetJsFile = path.join(__dirname, "../icons.js"); 6 | 7 | var svgBuilder = { 8 | getSvgList: () => fs.readdirSync(svgSource).filter((name) => fs.statSync(path.join(svgSource + "/" + name)).isFile() && name.match(/\.svg$/)), 9 | getFileContent: (file) => fs.readFileSync(file, { encoding: "utf8" }), 10 | saveFile: (file, content) => fs.writeFileSync(file, content, { encoding: "utf8" }), 11 | isUselessTag: (name) => ["symbol", "title", "desc", "use", "script"].indexOf(name) > -1, 12 | isEmptyTag: (dom) => JSON.stringify(dom.attr()) === "{}" && !dom.children().length, 13 | fetchSymbol: function (filename) { 14 | var fileContent = svgBuilder.getFileContent(path.join(svgSource, filename)), 15 | $ = cheerio.load(fileContent, { 16 | normalizeWhitespace: false, 17 | xmlMode: true 18 | }), 19 | id = "icon-" + filename.replace(/\.svg$/, "").replace(/\s+/g, "-"), 20 | svg = $("svg"), 21 | children = svg.children(), 22 | symbolContent = []; 23 | console.log("Building:", filename); 24 | svg.find("g,circle,ellipse,image,line,path,pattern,polygon,polyline,rect,text").removeAttr("id"); 25 | symbolContent.push(''); 26 | for (var i = 0; i < children.length; i++) { 27 | var child = children.eq(i); 28 | if (!svgBuilder.isUselessTag(children.get(i).tagName) && !svgBuilder.isEmptyTag(child)) { 29 | symbolContent.push(" " + $.html(child)); 30 | } 31 | } 32 | symbolContent.push(""); 33 | return symbolContent; 34 | }, 35 | 36 | doBuild: function () { 37 | var svgContent = []; 38 | var nameList = svgBuilder.getSvgList(svgSource); 39 | svgContent.push('
'); 40 | svgContent.push(''); 41 | console.log("Start to build svg symbol from: ", svgSource); 42 | 43 | nameList.forEach(function (name) { 44 | svgContent.push(...svgBuilder.fetchSymbol(name)); 45 | }); 46 | svgContent.push(""); 47 | svgContent.push("
"); 48 | 49 | console.log("Saving JS file to ", targetJsFile); 50 | let outContent = [ 51 | `const svgSymbol = [`, 52 | svgContent.map((line) => `'${line}'`).join(",\n"), 53 | `]`, 54 | `document.body.insertAdjacentHTML("afterBegin", svgSymbol.join(""));` 55 | ]; 56 | svgBuilder.saveFile(targetJsFile, outContent.join("\n")); 57 | console.log("Build success."); 58 | } 59 | }; 60 | 61 | svgBuilder.doBuild(); 62 | --------------------------------------------------------------------------------