[
  {
    "path": ".gitattributes",
    "content": "legacy/* linguist-vendored\nscripts/alfred/workflow/* linguist-vendored\n\n*.css linguist-language=javascript\n"
  },
  {
    "path": ".github/issue_template.md",
    "content": "### System (Mac, Windows 10/11, Linux) / 操作系统\n\n\n\n### SwitchHosts Version / SwitchHosts 版本\n\n\n\n### Description / 描述\n\n\n\n### How to reproduce / 重现步骤\n\n\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n\n# dependencies\nnode_modules\nnpm-debug.log*\nyarn-error.log\nyarn.lock\npackage-lock.json\n\n# production\nbuild\ndist\n\n# misc\n.DS_Store\n\n# umi\nsrc/.umi\nsrc/.umi-production\nsrc/.umi-test\nsrc/renderer/.umi\nsrc/renderer/.umi-production\nsrc/renderer/.umi-test\nsrc/renderer/dist\n.env.local\n\ntmp\ntest/tmp\n\n.env\n*.provisionprofile\n"
  },
  {
    "path": ".prettierignore",
    "content": "src/renderer/.umi\nsrc/renderer/.umi-production\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"arrowParens\": \"always\",\n  \"bracketSpacing\": true,\n  \"embeddedLanguageFormatting\": \"auto\",\n  \"endOfLine\": \"lf\",\n  \"htmlWhitespaceSensitivity\": \"css\",\n  \"insertPragma\": false,\n  \"jsxBracketSameLine\": false,\n  \"jsxSingleQuote\": false,\n  \"printWidth\": 100,\n  \"proseWrap\": \"preserve\",\n  \"quoteProps\": \"as-needed\",\n  \"requirePragma\": false,\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"all\",\n  \"useTabs\": false\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.organizeImports\": \"explicit\"\n  },\n  \"files.exclude\": {\n    \"**/.idea\": true,\n    \"**/build\": true,\n    \"**/dist\": true,\n    \"**/node_modules\": true\n  },\n  \"search.exclude\": {\n    \"**/.idea\": true,\n    \"**/build\": true,\n    \"**/dist\": true,\n    \"**/node_modules\": true\n  },\n  \"files.watcherExclude\": {\n    \"**/.idea/**\": true,\n    \"**/build/**\": true,\n    \"**/dist/**\": true,\n    \"**/node_modules/**\": true\n  }\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2011-2025 oldj\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\" markdown=\"1\">\n  <sup>Special thanks to:</sup>\n  <br>\n  <a href=\"https://go.warp.dev/SwitchHosts\">\n    <img alt=\"Warp sponsorship\" width=\"400\" src=\"https://github.com/user-attachments/assets/bb4a0222-12bf-4c79-bb80-a8ed4672b801\" />\n  </a>\n\n### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts)\n\n[Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)<br>\n\n</div>\n\n---\n\n# SwitchHosts\n\n- [Polski](README.pl.md)\n- [简体中文](README.zh_hans.md)\n- [繁體中文](README.zh_hant.md)\n\nHomepage: [https://switchhosts.vercel.app](https://switchhosts.vercel.app)\n\nSwitchHosts is an App for managing hosts file, it is based on [Electron](http://electron.atom.io/), [React](https://facebook.github.io/react/), [Jotai](https://jotai.org/), [Mantine](https://mantine.dev/), etc.\n\n## Screenshot\n\n<img src=\"https://raw.githubusercontent.com/oldj/SwitchHosts/master/screenshots/sh_light.png\" alt=\"Capture\" width=\"960\">\n\n## Features\n\n- Switch hosts quickly\n- Syntax highlight\n- Remote hosts\n- Switch from system tray\n\n## Install\n\n### Download\n\nYou can download the source code and build it yourself, or download the built version from following\nlinks:\n\n- [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases)\n\nYou can also install the built version using the [package manager Chocolatey](https://community.chocolatey.org/packages/switchhosts):\n\n```powershell\nchoco install switchhosts\n```\n\n## Backup\n\nSwitchHosts stores data at `~/.SwitchHosts` (Or folder `.SwitchHosts` under the current user's home\npath on Windows), the `~/.SwitchHosts/data` folder contains data, while the `~/.SwitchHosts/config`\nfolder contains various configuration information.\n\n## Develop and build\n\n### Development\n\n- Install [Node.js](https://nodejs.org/)\n- Change to the folder `./`, run `npm install` to install dependented libraries\n- Run `npm run dev` to start the development server\n- Then run `npm run start` to start the app for developing or debuging\n\n### Build and package\n\n- It is recommended to use [electron-builder](https://github.com/electron-userland/electron-builder)\n  for packaging\n- Go to the `./` folder\n- Run `npm run build`\n- Run `npm run make`, if everything goes well, the packaged files will be in the `./dist` folder.\n- This command may take several minutes to finish when you run it the first time, as it needs time\n  to download dependent files. You can download the dependencies\n  manually [here](https://github.com/electron/electron/releases),\n  or [Taobao mirror](https://npmmirror.com/mirrors/electron/), then save the files to `~/.electron`\n  . You can check the [Electron Docs](http://electron.atom.io/docs/) for more infomation.\n\n```bash\n# build\nnpm run build\n\n# make\nnpm run make # the packed files will be in ./dist\n```\n\n## Copyright\n\nSwitchHosts is a free and open source software, it is released under the [Apache License](./LICENSE).\n"
  },
  {
    "path": "README.pl.md",
    "content": "<div align=\"center\" markdown=\"1\">\n  <sup>Special thanks to:</sup>\n  <br>\n  <a href=\"https://go.warp.dev/SwitchHosts\">\n    <img alt=\"Warp sponsorship\" width=\"400\" src=\"https://github.com/user-attachments/assets/bb4a0222-12bf-4c79-bb80-a8ed4672b801\" />\n  </a>\n\n### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts)\n[Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)<br>\n\n</div>\n\n---\n\n# SwitchHosts\n\n- [English](README.md)\n- [简体中文](README.zh_hans.md)\n- [繁體中文](README.zh_hant.md)\n\nStrona główna: [https://switchhosts.vercel.app](https://switchhosts.vercel.app)\n\nSwitchHosts to aplikacja do zarządzania plikiem hosts, zbudowana na bazie [Electron](http://electron.atom.io/), [React](https://facebook.github.io/react/), [Jotai](https://jotai.org/), [Mantine](https://mantine.dev/) i innych.\n\n## Zrzut ekranu\n\n<img src=\"https://raw.githubusercontent.com/oldj/SwitchHosts/master/screenshots/sh_light.png\" alt=\"Zrzut aplikacji\" width=\"960\">\n\n## Funkcje\n\n- Szybkie przełączanie hostów\n- Podświetlanie składni\n- Hosty zdalne\n- Przełączanie z paska systemowego\n\n## Instalacja\n\n### Pobieranie\n\nMożesz pobrać kod źródłowy i zbudować go samodzielnie, lub pobrać wbudowaną wersję z poniższych linków:\n\n- [Pobierz najnowszą wersję SwitchHosts (GitHub release)](https://github.com/oldj/SwitchHosts/releases)\n\nMożesz także zainstalować build używając [menedżera pakietów Chocolatey](https://community.chocolatey.org/packages/switchhosts):\n```powershell\nchoco install switchhosts\n```\n\n## Kopia zapasowa\n\nSwitchHosts przechowuje dane w `~/.SwitchHosts` (lub folder `.SwitchHosts` w ścieżce domowej bieżącego użytkownika na Windows), folder `~/.SwitchHosts/data` zawiera dane, podczas gdy folder `~/.SwitchHosts/config` zawiera różne informacje konfiguracyjne.\n\n## Tworzenie i budowanie\n\n### Tworzenie\n\n- Zainstaluj [Node.js](https://nodejs.org/)\n- Przejdź do folderu `./`, uruchom `npm install` aby zainstalować biblioteki zależności\n- Uruchom `npm run dev` aby uruchomić serwer deweloperski\n- Następnie uruchom `npm run start` aby uruchomić aplikację do tworzenia lub debugowania\n\n### Budowanie i pakowanie\n\n- Zaleca się użycie [electron-builder](https://github.com/electron-userland/electron-builder) do budowania\n- Przejdź do folderu `./`\n- Uruchom `npm run build`\n- Uruchom `npm run make`, jeśli wszystko pójdzie dobrze, spakowane pliki będą w folderze `./dist`.\n- Ta komenda może zająć kilka minut gdy uruchamiasz ją po raz pierwszy, ponieważ potrzebuje czasu na pobranie plików zależności. Możesz pobrać zależności ręcznie [tutaj](https://github.com/electron/electron/releases), lub [lustro Taobao](https://npmmirror.com/mirrors/electron/), a następnie zapisz pliki do `~/.electron`. Możesz sprawdzić [Dokumentację Electron](http://electron.atom.io/docs/) aby uzyskać więcej informacji.\n\n```bash\n# budowanie\nnpm run build\n\n# pakowanie\nnpm run make # spakowane pliki będą w ./dist\n```\n\n## Prawa autorskie\n\nSwitchHosts to wolne i otwarte oprogramowanie, wydane na licencji [Apache License](./LICENSE).\n"
  },
  {
    "path": "README.zh_hans.md",
    "content": "<div align=\"center\" markdown=\"1\">\n  <sup>Special thanks to:</sup>\n  <br>\n  <a href=\"https://go.warp.dev/SwitchHosts\">\n    <img alt=\"Warp sponsorship\" width=\"400\" src=\"https://github.com/user-attachments/assets/352a755a-6776-43fd-b324-19dc649747b2\" />\n  </a>\n\n### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts)\n[Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)<br>\n\n</div>\n\n---\n\n# SwitchHosts\n\n- [English](README.md)\n- [Polski](README.pl.md)\n- [繁體中文](README.zh_hant.md)\n\n项目主页：[https://switchhosts.vercel.app](https://switchhosts.vercel.app)\n\nSwitchHosts 是一个管理 hosts 文件的应用，基于 [Electron](http://electron.atom.io/)、[React](https://facebook.github.io/react/)、[Jotai](https://jotai.org/)、[Mantine](https://mantine.dev/) 等技术开发。\n\n## 截图\n\n<img src=\"https://raw.githubusercontent.com/oldj/SwitchHosts/master/screenshots/sh_light.png\" alt=\"Capture\" width=\"960\">\n\n## 功能特性\n\n- 快速切换 hosts 方案\n- hosts 语法高亮\n- 支持从网络加载远程 hosts 配置\n- 可从系统菜单栏图标快速切换 hosts\n\n## 安装\n\n### 下载\n\n你可以下载源码并自行构建，也可以从以下地址下载已构建好的版本：\n\n- [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases)\n\n你也可以通过 [Chocolatey 包管理器](https://community.chocolatey.org/packages/switchhosts)安装已构建好的版本：\n```powershell\nchoco install switchhosts\n```\n\n## 数据备份\n\nSwitchHosts 的数据文件存储于 `~/.SwitchHosts` (Windows 下存储于用户个人文件夹下的 `.SwitchHosts` 文件夹），\n其中 `~/.SwitchHosts/data` 文件夹包含数据，`~/.SwitchHosts/config` 文件夹包含各项配置信息。\n\n## 开发以及构建\n\n### 开发\n\n- 安装 [Node.js](https://nodejs.org/)\n- 在项目根目录 `./` 下，运行 `npm install` 命令安装依赖\n- 运行 `npm run dev` 命令启动开发服务\n- 运行 `npm run start` 启动 App，即可开始开发及调试\n\n### 构建及打包\n\n- 推荐使用 [electron-builder](https://github.com/electron-userland/electron-builder) 进行打包\n- 转到项目根目录 './'\n- 运行 `npm run build`\n- 运行 `npm run make`，如果一切顺利，可在 `./dist` 目录下找到打包后的文件\n- 首次运行可能需要花费一些时间，因为需要下载相关依赖文件。你也可以从 [这儿](https://github.com/electron/electron/releases)\n  或者 [淘宝镜像](https://npmmirror.com/mirrors/electron/) 手动下载，并保存到 `~/.electron`\n  目录下。更多信息可访问 [Electron 文档](http://electron.atom.io/docs/)。\n\n```bash\n# build\nnpm run build\n\n# make\nnpm run make # the packed files will be in ./dist\n```\n\n## 版权\n\nSwitchHosts 是一个免费开源软件，基于 Apache-2.0 协议发布。\n"
  },
  {
    "path": "README.zh_hant.md",
    "content": "<div align=\"center\" markdown=\"1\">\n  <sup>Special thanks to:</sup>\n  <br>\n  <a href=\"https://go.warp.dev/SwitchHosts\">\n    <img alt=\"Warp sponsorship\" width=\"400\" src=\"https://github.com/user-attachments/assets/352a755a-6776-43fd-b324-19dc649747b2\" />\n  </a>\n\n### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts)\n\n[Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)<br>\n\n</div>\n\n---\n\n# SwitchHosts\n\n- [English](README.md)\n- [Polski](README.pl.md)\n- [简体中文](README.zh_hans.md)\n\n項目主頁：[https://switchhosts.vercel.app](https://switchhosts.vercel.app)\n\nSwitchHosts 是一個管理 hosts 檔案的應用程式，基於 [Electron](http://electron.atom.io/)、[React](https://facebook.github.io/react/)、[Jotai](https://jotai.org/)、[Mantine](https://mantine.dev/) 等技術開發。\n\n## 螢幕截圖\n\n<img src=\"https://raw.githubusercontent.com/oldj/SwitchHosts/master/screenshots/sh_light.png\" alt=\"Capture\" width=\"960\">\n\n## 功能特性\n\n- 快速切換 hosts 方案\n- hosts 語法高亮顯示\n- 支援從網路載入遠程 hosts 設定\n- 可從系統菜單欄圖是快速切換 hosts\n\n## 安裝\n\n### 下載\n\n你可以下載原始碼並自行建置，也可以從以下網址下載已經建置好的版本：\n\n- [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases)\n\n你也可以通過 [Chocolatey 包管理器](https://community.chocolatey.org/packages/switchhosts)安裝已經建置好的版本：\n\n```powershell\nchoco install switchhosts\n```\n\n## 數據備份\n\nSwitchHosts 的數據文件儲存於 `~/.SwitchHosts` (Windows 下儲存使用者個人文件裡的 `.SwitchHosts` 資料夾），\n其中 `~/.SwitchHosts/data` 資料夾包含數據，`~/.SwitchHosts/config` 資料夾包含各種設定。\n\n## 開發及建置\n\n### 開發\n\n- 安裝 [Node.js](https://nodejs.org/)\n- 在項目根目錄 `./` 下，執行 `npm install` 指令安裝前置\n- 執行 `npm run dev` 指令啟動開發服務\n- 執行 `npm run start` 啟動應用程式，即可開始開發及測試\n\n### 打包\n\n- 推薦使用 [electron-builder](https://github.com/electron-userland/electron-builder) 進行打包\n- 轉到項目根目錄 './'\n- 執行 `npm run build`\n- 執行 `npm run make`，如果一切順利，可在 `./dist` 目錄下找到打包後的檔案\n- 首次執行可能需要花費一點時間，因為需要下載相關的前置檔案。你也可以從 [這裡](https://github.com/electron/electron/releases)\n  手動下載，並儲存到 `~/.electron`目錄下。更多資訊可以參考 [Electron 文檔](http://electron.atom.io/docs/)。\n\n```bash\n# build\nnpm run build\n# make\nnpm run make # the packed files will be in ./dist\n```\n\n## 版權聲明\n\nSwitchHosts 是一個免費開源軟體，基於 Apache-2.0 開源協議發佈。\n"
  },
  {
    "path": "alfred/Readme.txt",
    "content": "SwitchHosts! is an App for switching hosts quickly.\n\nHomepage: https://oldj.github.io/SwitchHosts/"
  },
  {
    "path": "alfred/info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>bundleid</key>\n\t<string>switchhosts.oldj.net</string>\n\t<key>category</key>\n\t<string>Tools</string>\n\t<key>connections</key>\n\t<dict>\n\t\t<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>destinationuid</key>\n\t\t\t\t<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>\n\t\t\t\t<key>modifiers</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>modifiersubtext</key>\n\t\t\t\t<string></string>\n\t\t\t\t<key>vitoclose</key>\n\t\t\t\t<false/>\n\t\t\t</dict>\n\t\t</array>\n\t</dict>\n\t<key>createdby</key>\n\t<string>oldj</string>\n\t<key>description</key>\n\t<string>Switch hosts quickly!</string>\n\t<key>disabled</key>\n\t<false/>\n\t<key>name</key>\n\t<string>SwitchHosts</string>\n\t<key>objects</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>config</key>\n\t\t\t<dict>\n\t\t\t\t<key>concurrently</key>\n\t\t\t\t<false/>\n\t\t\t\t<key>escaping</key>\n\t\t\t\t<integer>102</integer>\n\t\t\t\t<key>script</key>\n\t\t\t\t<string>curl 'http://127.0.0.1:50761/api/toggle?id={query}'</string>\n\t\t\t\t<key>scriptargtype</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>scriptfile</key>\n\t\t\t\t<string></string>\n\t\t\t\t<key>type</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t</dict>\n\t\t\t<key>type</key>\n\t\t\t<string>alfred.workflow.action.script</string>\n\t\t\t<key>uid</key>\n\t\t\t<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>\n\t\t\t<key>version</key>\n\t\t\t<integer>2</integer>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>config</key>\n\t\t\t<dict>\n\t\t\t\t<key>alfredfiltersresults</key>\n\t\t\t\t<false/>\n\t\t\t\t<key>alfredfiltersresultsmatchmode</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>argumenttreatemptyqueryasnil</key>\n\t\t\t\t<false/>\n\t\t\t\t<key>argumenttrimmode</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>argumenttype</key>\n\t\t\t\t<integer>1</integer>\n\t\t\t\t<key>escaping</key>\n\t\t\t\t<integer>68</integer>\n\t\t\t\t<key>keyword</key>\n\t\t\t\t<string>swh</string>\n\t\t\t\t<key>queuedelaycustom</key>\n\t\t\t\t<integer>3</integer>\n\t\t\t\t<key>queuedelayimmediatelyinitially</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>queuedelaymode</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>queuemode</key>\n\t\t\t\t<integer>1</integer>\n\t\t\t\t<key>runningsubtext</key>\n\t\t\t\t<string>loading...</string>\n\t\t\t\t<key>script</key>\n\t\t\t\t<string>function makeItems(items) {\n  return items.map(item =&gt; {\n    return {\n      uid: item.id,\n      title: item.title,\n      arg: item.id,\n      icon: {path: item.on ? 'on.png' : 'off.png'},\n      subtitle: (item.content || '').split('\\n')[0],\n    }\n  })\n}\n\nfunction run(argv) {\n  const server = 'http://127.0.0.1:50761'\n  // console.log(argv)\n  const queryURL = $.NSURL.URLWithString(`${server}/api/list`)\n  const requestData = $.NSData.dataWithContentsOfURL(queryURL)\n  const requestString = $.NSString.alloc.initWithDataEncoding(requestData, $.NSUTF8StringEncoding).js\n\n  let result\n  try {\n    result = JSON.parse(requestString)\n    result.data = result.data.filter((item)=&gt;item.title.includes(\"{query}\"))\n  } catch (e) {\n    console.log(e)\n    return JSON.stringify({\n      items: [{\n        uid: '0',\n        title: `API Error: ${server}`,\n        subtitle: 'Make sure SwitchHosts is running and the HTTP API interface is enabled.',\n        valid: false,\n      }]\n    })\n  }\n\n  if (result.success) {\n    return JSON.stringify({\n      items: makeItems(result.data)\n    })\n  }\n\n  return JSON.stringify({\n    items: [{\n      uid: '0',\n      title: `Error: ${result.message || result.code || 'Unknown'}`,\n      valid: false,\n    }]\n  })\n}</string>\n\t\t\t\t<key>scriptargtype</key>\n\t\t\t\t<integer>0</integer>\n\t\t\t\t<key>scriptfile</key>\n\t\t\t\t<string></string>\n\t\t\t\t<key>subtext</key>\n\t\t\t\t<string>Switch hosts quickly!</string>\n\t\t\t\t<key>title</key>\n\t\t\t\t<string>Show hosts..</string>\n\t\t\t\t<key>type</key>\n\t\t\t\t<integer>7</integer>\n\t\t\t\t<key>withspace</key>\n\t\t\t\t<true/>\n\t\t\t</dict>\n\t\t\t<key>type</key>\n\t\t\t<string>alfred.workflow.input.scriptfilter</string>\n\t\t\t<key>uid</key>\n\t\t\t<string>E4D66445-FD72-47A2-9EE6-7232A2BADE29</string>\n\t\t\t<key>version</key>\n\t\t\t<integer>3</integer>\n\t\t</dict>\n\t</array>\n\t<key>readme</key>\n\t<string>This workflow is for the SwitchHosts App.\n\nhttps://switchhosts.vercel.app</string>\n\t<key>uidata</key>\n\t<dict>\n\t\t<key>78D17FD5-9628-4901-A01A-511528D5FC14</key>\n\t\t<dict>\n\t\t\t<key>xpos</key>\n\t\t\t<integer>340</integer>\n\t\t\t<key>ypos</key>\n\t\t\t<integer>30</integer>\n\t\t</dict>\n\t\t<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>\n\t\t<dict>\n\t\t\t<key>xpos</key>\n\t\t\t<integer>120</integer>\n\t\t\t<key>ypos</key>\n\t\t\t<integer>30</integer>\n\t\t</dict>\n\t</dict>\n\t<key>variablesdontexport</key>\n\t<array/>\n\t<key>version</key>\n\t<string>1.3.0</string>\n\t<key>webaddress</key>\n\t<string>https://switchhosts.vercel.app</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/package.json",
    "content": "{\n  \"name\": \"switchhosts\",\n  \"productName\": \"SwitchHosts\",\n  \"version\": \"4.3.0.6137\",\n  \"description\": \"Switch hosts quickly!\",\n  \"main\": \"./main.js\",\n  \"author\": {\n    \"name\": \"oldj\",\n    \"email\": \"oldj.wu@gmail.com\",\n    \"url\": \"https://github.com/oldj/SwitchHosts\"\n  },\n  \"homepage\": \"https://switchhosts.vercel.app\",\n  \"scripts\": {},\n  \"license\": \"Apache-2.0\",\n  \"dependencies\": {}\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"start\": \"cross-env NODE_ENV=development electron ./build/main.js\",\n    \"pretest\": \"rimraf ./test/tmp\",\n    \"test\": \"vitest --config ./vitest.config.mts --watch=false\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"clean:dist\": \"rimraf ./dist/*\",\n    \"clean:build\": \"rimraf ./build/*\",\n    \"dev\": \"npm run clean:build && concurrently --kill-others-on-fail --prefix-colors auto --names main,renderer \\\"npm run dev:main\\\" \\\"npm run dev:renderer\\\"\",\n    \"dev:main\": \"vite build --watch --config ./vite.main.config.mts\",\n    \"dev:renderer\": \"vite --config ./vite.render.config.mts\",\n    \"version:up\": \"node scripts/version-up.mjs\",\n    \"_build\": \"npm run version:up && npm run _build:release\",\n    \"_build:release\": \"npm run clean:build && concurrently --kill-others-on-fail --prefix-colors auto --names main,renderer \\\"npm run build:main\\\" \\\"npm run build:renderer\\\"\",\n    \"build:main\": \"cross-env NODE_ENV=production vite build --config ./vite.main.config.mts\",\n    \"build:renderer\": \"cross-env NODE_ENV=production vite build --config ./vite.render.config.mts\",\n    \"build\": \"npm run _build\",\n    \"build:release\": \"npm run _build:release\",\n    \"make\": \"node scripts/make.mjs\",\n    \"make:dev\": \"cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=dev npm run make\",\n    \"make:linux\": \"cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=linux npm run make\",\n    \"make:win\": \"cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=win npm run make\",\n    \"release:upload\": \"node ./scripts/upload-release.mjs\",\n    \"release:upload:dry-run\": \"cross-env DRY_RUN=1 node ./scripts/upload-release.mjs\",\n    \"publish\": \"npm run release:upload\"\n  },\n  \"dependencies\": {\n    \"@hono/node-server\": \"^1.19.11\",\n    \"axios\": \"1.13.6\",\n    \"compare-versions\": \"6.1.1\",\n    \"dayjs\": \"1.11.19\",\n    \"electron-updater\": \"6.8.3\",\n    \"electron-window-state\": \"5.0.3\",\n    \"hono\": \"^4.12.7\",\n    \"lodash\": \"4.17.23\",\n    \"md5\": \"2.3.0\",\n    \"md5-file\": \"5.0.0\",\n    \"mkdirp\": \"3.0.1\",\n    \"potdb\": \"2.6.6\",\n    \"tslib\": \"2.8.1\",\n    \"uuid\": \"13.0.0\"\n  },\n  \"devDependencies\": {\n    \"@electron/notarize\": \"^3.1.1\",\n    \"@mantine/core\": \"^8.3.16\",\n    \"@mantine/hooks\": \"^8.3.16\",\n    \"@tabler/icons-react\": \"3.38.0\",\n    \"@types/assert\": \"1.5.11\",\n    \"@types/lodash\": \"4.17.24\",\n    \"@types/md5\": \"2.3.6\",\n    \"@types/mkdirp\": \"2.0.0\",\n    \"@types/node\": \"25.3.3\",\n    \"@types/react\": \"19.2.14\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"@types/semver\": \"7.7.1\",\n    \"@types/uuid\": \"11.0.0\",\n    \"@vitejs/plugin-react\": \"^5.1.4\",\n    \"ahooks\": \"3.9.6\",\n    \"chalk\": \"^5.6.2\",\n    \"clsx\": \"2.1.1\",\n    \"codejar\": \"^4.3.0\",\n    \"codejar-linenumbers\": \"^1.0.1\",\n    \"concurrently\": \"9.2.1\",\n    \"cross-env\": \"10.1.0\",\n    \"dotenv\": \"17.3.1\",\n    \"electron\": \"39.5.1\",\n    \"electron-builder\": \"26.8.1\",\n    \"execa\": \"9.6.1\",\n    \"fs-extra\": \"11.3.3\",\n    \"jotai\": \"2.18.0\",\n    \"prettier\": \"3.8.1\",\n    \"pretty-bytes\": \"7.1.0\",\n    \"progress\": \"^2.0.3\",\n    \"react\": \"19.2.4\",\n    \"react-dom\": \"19.2.4\",\n    \"react-icons\": \"5.6.0\",\n    \"react-router\": \"7.13.1\",\n    \"rimraf\": \"^6.1.3\",\n    \"sass\": \"1.97.3\",\n    \"smooth-scroll-into-view-if-needed\": \"2.0.2\",\n    \"typescript\": \"5.9.3\",\n    \"vite\": \"7.3.1\",\n    \"vite-plugin-static-copy\": \"3.2.0\",\n    \"vite-tsconfig-paths\": \"6.1.1\",\n    \"vitest\": \"^3.2.4\"\n  }\n}\n"
  },
  {
    "path": "scripts/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n        <true/>\n        <key>com.apple.security.cs.allow-jit</key>\n        <true/>\n    </dict>\n</plist>\n"
  },
  {
    "path": "scripts/hooks/artifactBuildCompleted.mjs",
    "content": "import { notarize } from '@electron/notarize'\nimport path from 'node:path'\nimport { isEnvFlagEnabled } from '../libs/build-env.mjs'\nimport { getNotarizeOptions } from './notarize-options.mjs'\n\nexport default async function artifactBuildCompleted(context) {\n  const { file, packager } = context\n\n  if (!file || path.extname(file) !== '.dmg') {\n    return\n  }\n\n  if (packager?.platform?.name !== 'mac') {\n    return\n  }\n\n  if (process.env.MAKE_FOR === 'dev' || isEnvFlagEnabled(process.env.SKIP_NOTARIZATION)) {\n    console.log(`skip notarization for ${path.basename(file)}.`)\n    return\n  }\n\n  const options = await getNotarizeOptions(file)\n  if (!options) {\n    throw new Error(`Notarization credentials are missing for ${path.basename(file)}.`)\n  }\n\n  console.log('in artifactBuildCompleted, notarize dmg...')\n  console.log(`dmgPath: ${file}`)\n  await notarize(options)\n  console.log(`Notarize done for ${path.basename(file)}.`)\n}\n"
  },
  {
    "path": "scripts/hooks/notarize-options.mjs",
    "content": "import { execFile } from 'node:child_process'\nimport { isEnvFlagEnabled } from '../libs/build-env.mjs'\n\nfunction getPasswordFromKeychain(account, service) {\n  return new Promise((resolve, reject) => {\n    execFile(\n      'security',\n      ['find-generic-password', '-a', account, '-s', service, '-w'],\n      (error, stdout, stderr) => {\n        if (error) {\n          reject(new Error(stderr || error.message))\n          return\n        }\n\n        resolve(stdout.trim())\n      },\n    )\n  })\n}\n\nexport async function prepareNotarizeEnv(env = process.env) {\n  if (isEnvFlagEnabled(env.SKIP_NOTARIZATION) || env.MAKE_FOR === 'dev') {\n    return env\n  }\n\n  if (!env.APPLE_TEAM_ID && env.TEAM_ID) {\n    env.APPLE_TEAM_ID = env.TEAM_ID\n  }\n\n  const hasCompleteCredentials =\n    !!env.APPLE_KEYCHAIN_PROFILE ||\n    (!!env.APPLE_API_KEY && !!env.APPLE_API_KEY_ID && !!env.APPLE_API_ISSUER) ||\n    (!!env.APPLE_ID && !!env.APPLE_APP_SPECIFIC_PASSWORD && !!env.APPLE_TEAM_ID)\n\n  if (hasCompleteCredentials || !env.APPLE_ID) {\n    return env\n  }\n\n  try {\n    env.APPLE_APP_SPECIFIC_PASSWORD = await getPasswordFromKeychain(\n      env.APPLE_ID,\n      `Apple Notarize: ${env.APPLE_ID}`,\n    )\n  } catch (error) {\n    console.log(`Legacy notarization keychain lookup skipped: ${error.message}`)\n  }\n\n  return env\n}\n\nexport function hasNotarizeCredentials(env = process.env) {\n  return Boolean(\n    env.APPLE_KEYCHAIN_PROFILE ||\n      (env.APPLE_API_KEY && env.APPLE_API_KEY_ID && env.APPLE_API_ISSUER) ||\n      (env.APPLE_ID && env.APPLE_APP_SPECIFIC_PASSWORD && env.APPLE_TEAM_ID),\n  )\n}\n\nexport async function getNotarizeOptions(appPath, env = process.env) {\n  await prepareNotarizeEnv(env)\n\n  const {\n    APPLE_API_KEY: appleApiKey,\n    APPLE_API_KEY_ID: appleApiKeyId,\n    APPLE_API_ISSUER: appleApiIssuer,\n    APPLE_ID: appleId,\n    APPLE_APP_SPECIFIC_PASSWORD: appleIdPassword,\n    APPLE_KEYCHAIN: keychain,\n    APPLE_KEYCHAIN_PROFILE: keychainProfile,\n    APPLE_TEAM_ID: teamId,\n  } = env\n\n  const tool = 'notarytool'\n\n  if (appleId || appleIdPassword) {\n    if (!appleId) {\n      throw new Error('APPLE_ID env var needs to be set')\n    }\n    if (!appleIdPassword) {\n      throw new Error('APPLE_APP_SPECIFIC_PASSWORD env var needs to be set')\n    }\n    if (!teamId) {\n      throw new Error('APPLE_TEAM_ID env var needs to be set')\n    }\n    return { tool, appPath, appleId, appleIdPassword, teamId }\n  }\n\n  if (appleApiKey || appleApiKeyId || appleApiIssuer) {\n    if (!appleApiKey || !appleApiKeyId || !appleApiIssuer) {\n      throw new Error('Env vars APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER need to be set')\n    }\n    return { tool, appPath, appleApiKey, appleApiKeyId, appleApiIssuer }\n  }\n\n  if (keychainProfile) {\n    return {\n      tool,\n      appPath,\n      keychainProfile,\n      ...(keychain ? { keychain } : {}),\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "scripts/libs/build-env.mjs",
    "content": "export function hasValue(value) {\n  return typeof value === 'string' ? value.trim() !== '' : Boolean(value)\n}\n\nexport function getFirstConfiguredEnv(env, names) {\n  for (const name of names) {\n    if (hasValue(env[name])) {\n      return env[name].trim()\n    }\n  }\n\n  return null\n}\n\nexport function isEnvFlagEnabled(value) {\n  if (!hasValue(value)) {\n    return false\n  }\n\n  if (typeof value === 'string') {\n    const normalized = value.trim().toLowerCase()\n    if (['0', 'false', 'no', 'off', ''].includes(normalized)) {\n      return false\n    }\n\n    if (['1', 'true', 'yes', 'on'].includes(normalized)) {\n      return true\n    }\n  }\n\n  return Boolean(value)\n}\n"
  },
  {
    "path": "scripts/libs/build-log.mjs",
    "content": "import chalk from 'chalk'\nimport dayjs from 'dayjs'\n\nexport const PLATFORM_LABELS = {\n  mac: 'macOS',\n  win: 'Windows',\n  linux: 'Linux',\n}\n\nconst PLATFORM_COLORS = {\n  mac: chalk.magenta,\n  win: chalk.cyan,\n  linux: chalk.green,\n}\n\nexport function formatTimestamp(date = new Date()) {\n  return dayjs(date).format('YYYY-MM-DD HH:mm:ss')\n}\n\nfunction formatLogLine(message) {\n  return `${formatTimestamp()} ${message}`\n}\n\nexport function formatDuration(ms) {\n  const totalSeconds = Math.floor(ms / 1000)\n  const hours = Math.floor(totalSeconds / 3600)\n  const minutes = Math.floor((totalSeconds % 3600) / 60)\n  const seconds = totalSeconds % 60\n\n  if (hours > 0) {\n    return `${hours}h ${minutes}m ${seconds}s`\n  }\n\n  if (minutes > 0) {\n    return `${minutes}m ${seconds}s`\n  }\n\n  return `${seconds}s`\n}\n\nexport function logBanner(message) {\n  console.log(chalk.bold.blue(`\\n${formatLogLine(`=== ${message} ===`)}`))\n}\n\nexport function logStep(message) {\n  console.log(chalk.blue(formatLogLine(`-> ${message}`)))\n}\n\nexport function logSuccess(message) {\n  console.log(chalk.green(formatLogLine(`✓ ${message}`)))\n}\n\nexport function logWarning(message) {\n  console.log(chalk.yellow(formatLogLine(`! ${message}`)))\n}\n\nexport function logPlatform(platform, message) {\n  const color = PLATFORM_COLORS[platform] || chalk.white\n  const label = PLATFORM_LABELS[platform] || platform\n  console.log(color(formatLogLine(`[${label}] ${message}`)))\n}\n"
  },
  {
    "path": "scripts/libs/build-plan.mjs",
    "content": "import { Arch } from 'builder-util'\nimport path from 'node:path'\nimport { PLATFORM_LABELS, formatDuration, logBanner, logPlatform } from './build-log.mjs'\n\nexport const PLATFORM_ORDER = ['mac', 'win', 'linux']\n\nfunction resolvePlatformName(name) {\n  const map = {\n    darwin: 'mac',\n    linux: 'linux',\n    mac: 'mac',\n    win: 'win',\n    win32: 'win',\n    windows: 'win',\n  }\n\n  return map[name] || null\n}\n\nfunction formatArch(arch) {\n  if (arch == null) {\n    return 'unknown'\n  }\n\n  return Arch[arch] || String(arch)\n}\n\nexport function getBuildPlan(makeFor, targetPlatformsConfigs) {\n  if (makeFor === 'dev') {\n    return [{ platform: 'mac', targets: targetPlatformsConfigs.mac.mac }]\n  }\n\n  if (makeFor === 'mac') {\n    return [{ platform: 'mac', targets: targetPlatformsConfigs.mac.mac }]\n  }\n\n  if (makeFor === 'win') {\n    return [{ platform: 'win', targets: targetPlatformsConfigs.win.win }]\n  }\n\n  if (makeFor === 'linux') {\n    return [{ platform: 'linux', targets: targetPlatformsConfigs.linux.linux }]\n  }\n\n  return PLATFORM_ORDER.map((platform) => ({\n    platform,\n    targets: targetPlatformsConfigs.all[platform],\n  }))\n}\n\nexport function createBuildTracker({ plan, compression, macBuildState, winBuildState, artifactBuildCompletedHook }) {\n  // Track platform timing through electron-builder hooks while the outer loop\n  // runs one platform build at a time for cleaner, non-interleaved logging.\n  const stats = new Map(\n    plan.map(({ platform, targets }) => [\n      platform,\n      {\n        targets,\n        startedAt: 0,\n        finishedAt: 0,\n      },\n    ]),\n  )\n\n  function getStat(platform) {\n    if (!stats.has(platform)) {\n      stats.set(platform, {\n        targets: [],\n        startedAt: 0,\n        finishedAt: 0,\n      })\n    }\n\n    return stats.get(platform)\n  }\n\n  function markStarted(platform) {\n    const stat = getStat(platform)\n\n    if (!stat.startedAt) {\n      stat.startedAt = Date.now()\n      logBanner(`Build ${PLATFORM_LABELS[platform]}`)\n      logPlatform(platform, `targets: ${stat.targets.join(', ')}`)\n      logPlatform(platform, `compression: ${compression}`)\n      if (platform === 'mac') {\n        logPlatform(platform, `code signing: ${macBuildState.sign ? 'enabled' : 'disabled'}`)\n        logPlatform(platform, `notarization: ${macBuildState.notarize ? 'enabled' : 'disabled'}`)\n      } else if (platform === 'win') {\n        logPlatform(platform, `code signing: ${winBuildState.sign ? 'enabled' : 'disabled'}`)\n      } else {\n        logPlatform(platform, 'notarization: disabled')\n      }\n    }\n\n    return stat\n  }\n\n  function markFinished(platform) {\n    const stat = getStat(platform)\n    stat.finishedAt = Date.now()\n    return stat\n  }\n\n  return {\n    hooks: {\n      beforePack(context) {\n        const platform = resolvePlatformName(context.electronPlatformName)\n        if (!platform) {\n          return\n        }\n\n        markStarted(platform)\n        // beforePack fires for each arch-specific app bundle preparation.\n        logPlatform(platform, `packaging app bundle for ${formatArch(context.arch)}...`)\n      },\n\n      afterPack(context) {\n        const platform = resolvePlatformName(context.electronPlatformName)\n        if (!platform) {\n          return\n        }\n\n        markFinished(platform)\n        logPlatform(platform, `app bundle ready for ${formatArch(context.arch)}`)\n      },\n\n      async artifactBuildCompleted(context) {\n        const platform = resolvePlatformName(context.packager?.platform?.name)\n        if (platform) {\n          markStarted(platform)\n        }\n\n        // Reuse the DMG notarization hook from the packaging config so logging and\n        // timing stay in one place while the notarization logic remains isolated.\n        const artifactFile = context.file || ''\n        const isMacDmg = platform === 'mac' && path.extname(artifactFile) === '.dmg'\n        if (isMacDmg && !macBuildState.notarize) {\n          logPlatform(platform, `skipping dmg notarization: ${path.basename(artifactFile)}`)\n        } else {\n          await artifactBuildCompletedHook(context)\n        }\n\n        if (!platform) {\n          return\n        }\n\n        markFinished(platform)\n        const targetName = context.target?.name || path.extname(artifactFile).slice(1)\n        logPlatform(platform, `artifact ready (${targetName}): ${path.basename(artifactFile)}`)\n      },\n    },\n\n    printSummary() {\n      logBanner('Build Summary')\n      for (const { platform } of plan) {\n        const stat = getStat(platform)\n        const elapsed = stat.startedAt && stat.finishedAt ? stat.finishedAt - stat.startedAt : 0\n        logPlatform(platform, `elapsed: ${elapsed > 0 ? formatDuration(elapsed) : 'n/a'}`)\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "scripts/libs/build-state.mjs",
    "content": "import { hasNotarizeCredentials, prepareNotarizeEnv } from '../hooks/notarize-options.mjs'\nimport { getFirstConfiguredEnv, hasValue, isEnvFlagEnabled } from './build-env.mjs'\n\nfunction hasSigningIdentityEnv(env = process.env) {\n  return hasValue(env.IDENTITY)\n}\n\nfunction describeNotarizationSetup(env = process.env) {\n  if (hasValue(env.APPLE_KEYCHAIN_PROFILE)) {\n    return 'APPLE_KEYCHAIN_PROFILE'\n  }\n\n  if (hasValue(env.APPLE_API_KEY) || hasValue(env.APPLE_API_KEY_ID) || hasValue(env.APPLE_API_ISSUER)) {\n    return 'APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER'\n  }\n\n  if (\n    hasValue(env.APPLE_ID) ||\n    hasValue(env.APPLE_APP_SPECIFIC_PASSWORD) ||\n    hasValue(env.APPLE_TEAM_ID) ||\n    hasValue(env.TEAM_ID)\n  ) {\n    return 'APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID'\n  }\n\n  return null\n}\n\nexport async function resolveMacBuildState(plan, env = process.env) {\n  const includesMac = plan.some(({ platform }) => platform === 'mac')\n  const notarizationForcedOff = env.MAKE_FOR === 'dev' || isEnvFlagEnabled(env.SKIP_NOTARIZATION)\n\n  const state = {\n    includesMac,\n    sign: false,\n    notarize: false,\n    logLevel: 'step',\n    message: 'macOS signing configuration check skipped',\n  }\n\n  if (!includesMac) {\n    state.message = 'skipping macOS signing configuration check'\n    return state\n  }\n\n  await prepareNotarizeEnv(env)\n\n  const hasIdentity = hasSigningIdentityEnv(env)\n  const hasNotary = hasNotarizeCredentials(env)\n  const configuredNotarySetup = describeNotarizationSetup(env)\n\n  if (notarizationForcedOff) {\n    if (hasIdentity) {\n      state.sign = true\n      state.logLevel = 'success'\n      state.message = `macOS code signing enabled via IDENTITY; notarization disabled by ${\n        env.MAKE_FOR === 'dev' ? 'MAKE_FOR=dev' : 'SKIP_NOTARIZATION'\n      }`\n    } else {\n      state.logLevel = 'warning'\n      state.message =\n        'IDENTITY is not configured; falling back to unsigned macOS artifacts because notarization is disabled.'\n    }\n\n    return state\n  }\n\n  if (hasIdentity && hasNotary) {\n    state.sign = true\n    state.notarize = true\n    state.logLevel = 'success'\n    state.message = `macOS signing and notarization enabled via IDENTITY + ${configuredNotarySetup}`\n    return state\n  }\n\n  const missing = []\n  if (!hasIdentity) {\n    missing.push('IDENTITY')\n  }\n  if (!hasNotary) {\n    missing.push(\n      'APPLE_KEYCHAIN_PROFILE or APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER or APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID',\n    )\n  }\n\n  state.logLevel = 'warning'\n  state.message =\n    `macOS signing/notarization config is missing or incomplete (${missing.join(', ')}). ` +\n    'Falling back to unsigned and unnotarized macOS artifacts.'\n\n  return state\n}\n\nexport function resolveWindowsBuildState(plan, env = process.env) {\n  const includesWin = plan.some(({ platform }) => platform === 'win')\n  const certificateSubjectName = getFirstConfiguredEnv(env, [\n    'WIN_CERTIFICATE_SUBJECT_NAME',\n    'WINDOWS_CERTIFICATE_SUBJECT_NAME',\n    'WIN_CERT_SUBJECT_NAME',\n  ])\n  const configuredPublisherName = getFirstConfiguredEnv(env, ['WIN_PUBLISHER_NAME', 'WINDOWS_PUBLISHER_NAME'])\n  const publisherName = configuredPublisherName || certificateSubjectName\n\n  const state = {\n    includesWin,\n    sign: false,\n    logLevel: 'step',\n    message: 'skipping Windows signing configuration check',\n    publisherName,\n    certificateSubjectName,\n  }\n\n  if (!includesWin) {\n    state.message = 'skipping Windows signing configuration check'\n    return state\n  }\n\n  if (certificateSubjectName) {\n    state.sign = true\n    state.logLevel = 'success'\n    state.message =\n      configuredPublisherName\n        ? 'Windows code signing enabled via WIN_CERTIFICATE_SUBJECT_NAME and WIN_PUBLISHER_NAME.'\n        : 'Windows code signing enabled via WIN_CERTIFICATE_SUBJECT_NAME; publisherName defaults to the certificate subject name.'\n    return state\n  }\n\n  if (configuredPublisherName) {\n    state.logLevel = 'warning'\n    state.message =\n      'Windows signing config is incomplete (missing WIN_CERTIFICATE_SUBJECT_NAME or WINDOWS_CERTIFICATE_SUBJECT_NAME or WIN_CERT_SUBJECT_NAME). ' +\n      'Skipping Windows code signing for this build.'\n    return state\n  }\n\n  state.message =\n    'Windows code signing disabled by default. Set WIN_CERTIFICATE_SUBJECT_NAME to enable it; WIN_PUBLISHER_NAME is optional.'\n  return state\n}\n"
  },
  {
    "path": "scripts/libs/my-exec.mjs",
    "content": "import { spawn } from 'node:child_process'\n\nexport default function myExec(cmd, ...args) {\n  return new Promise((resolve, reject) => {\n    const run = spawn(cmd, args)\n\n    let out = ''\n\n    run.stdout.on('data', (data) => {\n      console.log(`[stdout]: ${data.toString().trimEnd()}`)\n      out += data.toString()\n    })\n\n    run.stderr.on('data', (data) => {\n      console.log(`[stderr]: ${data.toString().trimEnd()}`)\n    })\n\n    run.on('exit', function (code) {\n      console.log('child process exited with code ' + code.toString())\n      if (code === 0) {\n        resolve(out)\n      } else {\n        reject(code)\n      }\n    })\n  })\n}\n"
  },
  {
    "path": "scripts/make.mjs",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport chalk from 'chalk'\nimport { config as loadEnv } from 'dotenv'\nimport fse from 'fs-extra'\nimport { createRequire } from 'node:module'\nimport { homedir } from 'node:os'\nimport path from 'node:path'\nimport artifactBuildCompletedHook from './hooks/artifactBuildCompleted.mjs'\nimport { PLATFORM_LABELS, formatDuration, logBanner, logPlatform, logStep, logSuccess, logWarning } from './libs/build-log.mjs'\nimport { createBuildTracker, getBuildPlan } from './libs/build-plan.mjs'\nimport { resolveMacBuildState, resolveWindowsBuildState } from './libs/build-state.mjs'\nimport { resolveGithubRepository } from './release-config.mjs'\nimport { APP_NAME, distDir, electronLanguages, rootDir } from './vars.mjs'\n\nloadEnv()\n\n// Use CommonJS require for local JSON/package reads so the script stays portable\n// across Node runtimes without relying on JSON import assertions.\nconst require = createRequire(import.meta.url)\nconst version = require('../src/version.json')\n\nconst TARGET_PLATFORMS_CONFIGS = {\n  mac: {\n    mac: ['dmg:x64', 'dmg:arm64'],\n  },\n  win: {\n    win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64'],\n  },\n  linux: {\n    linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'],\n  },\n  all: {\n    mac: ['dmg:x64', 'dmg:arm64', 'zip:universal'],\n    win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64', 'zip:x64' /* , 'appx:x64'*/],\n    linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'],\n  },\n}\n\nconst { APP_BUNDLE_ID, IDENTITY, MAKE_FOR } = process.env\nconst appId = APP_BUNDLE_ID || 'SwitchHosts'\nconst fullVersion = `${version[0]}.${version[1]}.${version[2]}.${version[3]}`\nconst publishMode = process.env.PUBLISH_POLICY || 'never'\nconst githubRepository = resolveGithubRepository(process.env)\nconst WINDOWS_TIMESTAMP_SERVER = 'http://rfc3161timestamp.globalsign.com/advanced'\n\nfunction createBuilderConfig(hooks, macBuildState, winBuildState) {\n  // Build the full electron-builder config in one place so every entrypoint\n  // (`make`, `make:*`) stays on the same packaging pipeline.\n  return {\n    ...cfgCommon,\n    appId,\n    productName: APP_NAME,\n    mac: {\n      type: 'distribution',\n      category: 'public.app-category.productivity',\n      icon: 'assets/app.icns',\n      gatekeeperAssess: false,\n      electronLanguages,\n      identity: macBuildState.sign ? IDENTITY : null,\n      hardenedRuntime: true,\n      entitlements: 'scripts/entitlements.mac.plist',\n      entitlementsInherit: 'scripts/entitlements.mac.plist',\n      extendInfo: {\n        ITSAppUsesNonExemptEncryption: false,\n        CFBundleLocalizations: electronLanguages,\n        CFBundleDevelopmentRegion: 'en',\n      },\n      artifactName: '${productName}-v' + fullVersion + '-${arch}-mac.${ext}',\n      ...(macBuildState.notarize ? {} : { notarize: false }),\n    },\n    dmg: {\n      background: 'assets/dmg-bg.png',\n      iconSize: 160,\n      window: {\n        width: 600,\n        height: 420,\n      },\n      contents: [\n        {\n          x: 150,\n          y: 200,\n        },\n        {\n          x: 450,\n          y: 200,\n          type: 'link',\n          path: '/Applications',\n        },\n      ],\n      sign: macBuildState.sign,\n      artifactName: '${productName}-v' + fullVersion + '-mac-${arch}.${ext}',\n    },\n    win: {\n      icon: 'assets/icon.ico',\n      verifyUpdateCodeSignature: winBuildState.sign,\n      signAndEditExecutable: winBuildState.sign,\n      // NSIS/portable targets still try to sign final `.exe` artifacts unless\n      // we explicitly exclude them when Windows signing is disabled.\n      ...(winBuildState.sign ? {} : { signExts: ['!.exe'] }),\n      ...(winBuildState.sign\n        ? {\n            signtoolOptions: {\n              signingHashAlgorithms: ['sha256'],\n              publisherName: winBuildState.publisherName,\n              certificateSubjectName: winBuildState.certificateSubjectName,\n              timeStampServer: WINDOWS_TIMESTAMP_SERVER,\n              rfc3161TimeStampServer: WINDOWS_TIMESTAMP_SERVER,\n            },\n          }\n        : {}),\n      artifactName: '${productName}-v' + fullVersion + '-win-${arch}.${ext}',\n    },\n    nsis: {\n      installerIcon: 'assets/installer-icon.ico',\n      oneClick: false,\n      allowToChangeInstallationDirectory: true,\n      deleteAppDataOnUninstall: false,\n      shortcutName: 'SwitchHosts',\n      artifactName: '${productName}-v' + fullVersion + '-win-${arch}-installer.${ext}',\n    },\n    portable: {\n      artifactName: '${productName}-v' + fullVersion + '-win-${arch}-portable.${ext}',\n    },\n    linux: {\n      icon: 'assets/app.icns',\n      artifactName: '${productName}-v' + fullVersion + '-linux-${arch}.${ext}',\n      category: 'Utility',\n      synopsis: 'An App for hosts management and switching.',\n      desktop: {\n        entry: {\n          Name: 'SwitchHosts',\n          Type: 'Application',\n          GenericName: 'An App for hosts management and switching.',\n        },\n      },\n    },\n    publish: {\n      // Keep the GitHub provider configured so electron-builder emits update metadata\n      // for GitHub Releases, while the actual asset upload stays in scripts/upload-release.mjs.\n      provider: 'github',\n      owner: githubRepository.owner,\n      repo: githubRepository.repo,\n      releaseType: 'draft',\n      vPrefixedTagName: true,\n    },\n    beforePack: hooks.beforePack,\n    afterPack: hooks.afterPack,\n    artifactBuildCompleted: hooks.artifactBuildCompleted,\n  }\n}\n\nif (!APP_BUNDLE_ID) {\n  logWarning('APP_BUNDLE_ID is not set, falling back to appId \"SwitchHosts\".')\n}\nlogStep(`APP_BUNDLE_ID: ${APP_BUNDLE_ID || '(fallback: SwitchHosts)'}`)\n\nconst cfgCommon = {\n  copyright: `Copyright © ${new Date().getFullYear()}`,\n  buildVersion: version[3].toString(),\n  directories: {\n    buildResources: 'build',\n    app: 'build',\n    output: 'dist',\n  },\n  electronDownload: {\n    cache: path.join(homedir(), '.electron'),\n    mirror: 'https://registry.npmmirror.com/-/binary/electron/',\n  },\n  asar: true,\n  compression: 'maximum',\n}\n\nconst beforeMake = async () => {\n  const t0 = Date.now()\n  logBanner('Prepare Build Directory')\n\n  // Start every package run from a clean dist directory to avoid mixing artifacts\n  // from different target sets or previous versions.\n  fse.removeSync(distDir)\n  fse.ensureDirSync(distDir)\n  logStep(`dist cleaned: ${distDir}`)\n\n  const toCopy = [[path.join(rootDir, 'assets', 'app.png'), path.join(rootDir, 'build', 'assets', 'app.png')]]\n\n  toCopy.map(([src, target]) => {\n    fse.copySync(src, target)\n  })\n  logStep(`copied build assets: ${toCopy.map(([src]) => path.basename(src)).join(', ')}`)\n\n  let pkgBase = require(path.join(rootDir, 'package.json'))\n  let pkgApp = require(path.join(rootDir, 'app', 'package.json'))\n\n  // Refresh the app package manifest inside build/ so electron-builder always\n  // packages the current dependency set and release version.\n  pkgApp.name = APP_NAME\n  pkgApp.version = version.slice(0, 3).join('.')\n  pkgApp.dependencies = pkgBase.dependencies\n\n  fse.writeFileSync(\n    path.join(rootDir, 'build', 'package.json'),\n    JSON.stringify(pkgApp, null, 2),\n    'utf-8',\n  )\n  logSuccess(`build/package.json refreshed in ${formatDuration(Date.now() - t0)}`)\n}\n\nconst afterMake = async () => {\n  const t0 = Date.now()\n  logBanner('Finalize Packaging')\n\n  // Reserved for post-build cleanup or metadata fixes if packaging needs them later.\n  logSuccess(`post-build steps finished in ${formatDuration(Date.now() - t0)}`)\n}\n\nconst doMake = async () => {\n  // Resolve the requested platform set first so every later step can log against\n  // the same plan and timing model.\n  const compression = MAKE_FOR === 'dev' ? 'store' : 'maximum'\n  cfgCommon.compression = compression\n  const plan = getBuildPlan(MAKE_FOR, TARGET_PLATFORMS_CONFIGS)\n  const macBuildState = await resolveMacBuildState(plan)\n  const winBuildState = resolveWindowsBuildState(plan)\n  const tracker = createBuildTracker({\n    plan,\n    compression,\n    macBuildState,\n    winBuildState,\n    artifactBuildCompletedHook,\n  })\n\n  logBanner('Build Plan')\n  logStep(`MAKE_FOR: ${MAKE_FOR || 'all'}`)\n  logStep(`version: ${fullVersion}`)\n  logStep(`appId: ${appId}`)\n  logStep(`compression: ${cfgCommon.compression}`)\n  logStep(`publish: ${publishMode}`)\n  logStep(`platforms: ${plan.map(({ platform }) => PLATFORM_LABELS[platform]).join(', ')}`)\n  if (macBuildState.includesMac) {\n    if (macBuildState.logLevel === 'warning') {\n      logWarning(macBuildState.message)\n    } else if (macBuildState.logLevel === 'success') {\n      logSuccess(macBuildState.message)\n    } else {\n      logStep(macBuildState.message)\n    }\n  }\n  if (winBuildState.includesWin) {\n    if (winBuildState.logLevel === 'warning') {\n      logWarning(winBuildState.message)\n    } else if (winBuildState.logLevel === 'success') {\n      logSuccess(winBuildState.message)\n    } else {\n      logStep(winBuildState.message)\n    }\n  }\n\n  if (macBuildState.notarize) {\n    logStep('notarization environment prepared')\n  } else if (macBuildState.includesMac) {\n    logStep('running macOS packaging without notarization')\n  } else {\n    logStep('skipping macOS notarization preparation')\n  }\n\n  logStep('loading electron-builder...')\n  const eb = await import('electron-builder')\n  const builder = eb.default || eb\n  logSuccess('electron-builder loaded')\n\n  // Build one platform per invocation so electron-builder's own logs stay grouped\n  // and easy to read even when each platform expands to multiple arch/target jobs.\n  for (const { platform, targets } of plan) {\n    logPlatform(platform, 'starting electron-builder run...')\n    await builder.build({\n      [platform]: targets,\n      publish: publishMode,\n      config: createBuilderConfig(tracker.hooks, macBuildState, winBuildState),\n    })\n    logPlatform(platform, 'electron-builder run finished.')\n  }\n\n  tracker.printSummary()\n}\n\nasync function main() {\n  const t0 = Date.now()\n  try {\n    // The top-level flow is intentionally linear: prepare inputs, run packaging,\n    // then finish with summary output and any future cleanup.\n    await beforeMake()\n    await doMake()\n    await afterMake()\n\n    logBanner('Done')\n    logSuccess(`total elapsed: ${formatDuration(Date.now() - t0)}`)\n  } catch (e) {\n    logBanner('Build Failed')\n    console.error(chalk.red(e?.stack || String(e)))\n    console.log(chalk.red(`total elapsed before failure: ${formatDuration(Date.now() - t0)}`))\n    process.exit(1)\n  }\n}\n\nawait main()\n"
  },
  {
    "path": "scripts/release-config.mjs",
    "content": "import { createRequire } from 'node:module'\n\nconst require = createRequire(import.meta.url)\nconst version = require('../src/version.json')\n\nexport const DEFAULT_GITHUB_REPOSITORY = 'oldj/SwitchHosts'\n\nexport function getReleaseVersion() {\n  return version.slice(0, 3).join('.')\n}\n\nexport function getFullVersion() {\n  return `${version[0]}.${version[1]}.${version[2]}.${version[3]}`\n}\n\nexport function getReleaseTag(env = process.env) {\n  const expectedTag = `v${getReleaseVersion()}`\n  const tag = env.RELEASE_TAG || expectedTag\n\n  // Keep GitHub Release tags aligned with the app's public semver so\n  // the uploader cannot silently publish assets under a mismatched tag.\n  if (tag !== expectedTag) {\n    throw new Error(`RELEASE_TAG must be \"${expectedTag}\", got \"${tag}\".`)\n  }\n\n  return tag\n}\n\nexport function resolveGithubRepository(env = process.env) {\n  const rawRepository =\n    env.GH_RELEASE_REPOSITORY || env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY\n\n  const match = /^([^/\\s]+)\\/([^/\\s]+)$/.exec(rawRepository || '')\n  if (!match) {\n    throw new Error(\n      `Invalid GitHub repository \"${rawRepository}\". Expected the format \"owner/repo\".`,\n    )\n  }\n\n  const [, owner, repo] = match\n\n  return {\n    owner,\n    repo,\n    fullName: `${owner}/${repo}`,\n  }\n}\n\nexport function isReleaseArtifactFile(fileName, fullVersion = getFullVersion()) {\n  if (!fileName || fileName.startsWith('.')) {\n    return false\n  }\n\n  // builder-debug.yml is useful locally, but publishing it would only clutter the release page.\n  if (fileName === 'builder-debug.yml') {\n    return false\n  }\n\n  // latest*.yml files are required by electron-updater to discover GitHub-hosted updates.\n  if (/^latest.*\\.ya?ml$/i.test(fileName)) {\n    return true\n  }\n\n  return fileName.includes(`v${fullVersion}`)\n}\n"
  },
  {
    "path": "scripts/upload-diagnostics.mjs",
    "content": "function getCauseField(cause, field) {\n  if (!cause || !(field in cause)) return null\n  const value = cause[field]\n  return value === null || value === undefined ? null : String(value)\n}\n\nfunction normalizeTarget(target) {\n  if (!target) {\n    return null\n  }\n\n  if (typeof target === 'string') {\n    return target\n  }\n\n  if (typeof target === 'object' && target !== null) {\n    if ('pathname' in target && typeof target.pathname === 'string') {\n      const search = 'search' in target && typeof target.search === 'string' ? target.search : ''\n      return `${target.pathname}${search}`\n    }\n\n    if ('href' in target && typeof target.href === 'string') {\n      return target.href\n    }\n  }\n\n  return String(target)\n}\n\nfunction getCause(error) {\n  if (!(error instanceof Error)) {\n    return null\n  }\n\n  if (typeof error.cause === 'object' && error.cause !== null) {\n    return error.cause\n  }\n\n  return null\n}\n\nfunction pickEnumerableFields(value) {\n  if (!value || typeof value !== 'object') {\n    return null\n  }\n\n  const entries = Object.entries(value)\n    .filter(([, entryValue]) => {\n      return entryValue === null || [ 'string', 'number', 'boolean' ].includes(typeof entryValue)\n    })\n\n  return entries.length > 0 ? Object.fromEntries(entries) : null\n}\n\nexport function extractErrorDetails(error) {\n  const normalizedError = error instanceof Error ? error : new Error(String(error))\n  const cause = getCause(normalizedError)\n\n  return {\n    causeCode: getCauseField(cause, 'code'),\n    causeErrno: getCauseField(cause, 'errno'),\n    causeHostname: getCauseField(cause, 'hostname'),\n    causeMessage: getCauseField(cause, 'message'),\n    causeSyscall: getCauseField(cause, 'syscall'),\n    errorMessage: normalizedError.message,\n    errorName: normalizedError.name || 'Error',\n    rawCause: pickEnumerableFields(cause),\n    stack: normalizedError.stack || null,\n  }\n}\n\nexport function buildDiagnostic({\n  attempt,\n  error,\n  fileIndex = null,\n  fileName = null,\n  httpStatus = null,\n  maxAttempts,\n  method,\n  progressSnapshot = null,\n  retryable,\n  stage,\n  target = null,\n}) {\n  const errorDetails = extractErrorDetails(error)\n\n  return {\n    attempt,\n    causeCode: errorDetails.causeCode,\n    causeErrno: errorDetails.causeErrno,\n    causeHostname: errorDetails.causeHostname,\n    causeMessage: errorDetails.causeMessage,\n    causeSyscall: errorDetails.causeSyscall,\n    currentFileBytes: progressSnapshot?.currentFileBytes ?? null,\n    errorMessage: errorDetails.errorMessage,\n    errorName: errorDetails.errorName,\n    fileIndex,\n    fileName,\n    httpStatus,\n    maxAttempts,\n    method,\n    retryable: Boolean(retryable),\n    stage,\n    target: normalizeTarget(target),\n    totalFiles: progressSnapshot?.totalFiles ?? null,\n    totalUploadedBytes: progressSnapshot?.totalUploadedBytes ?? null,\n  }\n}\n\nexport function formatDiagnosticSummary(diagnostic) {\n  const subject = diagnostic.fileName || diagnostic.target || diagnostic.stage\n  const details = [ `attempt ${diagnostic.attempt}/${diagnostic.maxAttempts}` ]\n\n  if (diagnostic.fileIndex != null && diagnostic.totalFiles) {\n    details.push(`file ${diagnostic.fileIndex}/${diagnostic.totalFiles}`)\n  }\n\n  if (diagnostic.httpStatus) {\n    details.push(`status=${diagnostic.httpStatus}`)\n  }\n\n  if (diagnostic.causeCode) {\n    details.push(`cause=${diagnostic.causeCode}`)\n  }\n\n  if (diagnostic.causeMessage) {\n    details.push(`message=${diagnostic.causeMessage}`)\n  } else if (diagnostic.errorMessage) {\n    details.push(`message=${diagnostic.errorMessage}`)\n  }\n\n  return `${diagnostic.stage} failed for ${subject} (${details.join(', ')})`\n}\n\nexport function formatRetrySummary(diagnostic, delayLabel) {\n  const subject = diagnostic.fileName || diagnostic.target || diagnostic.stage\n  const details = [ `attempt ${Math.min(diagnostic.attempt + 1, diagnostic.maxAttempts)}/${diagnostic.maxAttempts}` ]\n\n  if (diagnostic.fileIndex != null && diagnostic.totalFiles) {\n    details.unshift(`file ${diagnostic.fileIndex}/${diagnostic.totalFiles}`)\n  }\n\n  if (diagnostic.httpStatus) {\n    details.push(`status=${diagnostic.httpStatus}`)\n  }\n\n  if (diagnostic.causeCode) {\n    details.push(`cause=${diagnostic.causeCode}`)\n  }\n\n  details.push(`in ${delayLabel}`)\n\n  return `retrying ${diagnostic.stage} ${subject} (${details.join(', ')})`\n}\n\nexport function buildDebugPayload(diagnostic, error) {\n  const errorDetails = extractErrorDetails(error)\n\n  return {\n    diagnostic,\n    error: {\n      cause: errorDetails.rawCause,\n      errorMessage: errorDetails.errorMessage,\n      errorName: errorDetails.errorName,\n      stack: errorDetails.stack,\n    },\n  }\n}\n\nexport function attachDiagnostic(error, diagnostic) {\n  const normalizedError = error instanceof Error ? error : new Error(String(error))\n  normalizedError.diagnostic = diagnostic\n  return normalizedError\n}\n"
  },
  {
    "path": "scripts/upload-progress.mjs",
    "content": "import prettyBytes from 'pretty-bytes'\nimport ProgressBar from 'progress'\n\nconst PROGRESS_BAR_FORMAT = '[:bar]'\nconst PROGRESS_BAR_WIDTH = 24\n\nfunction clamp(value, min, max) {\n  return Math.min(Math.max(value, min), max)\n}\n\nexport function formatPercent(value) {\n  return `${clamp(value, 0, 100).toFixed(1)}%`\n}\n\nexport function formatEta(seconds) {\n  if (!Number.isFinite(seconds) || seconds < 0) {\n    return '--:--'\n  }\n\n  const roundedSeconds = Math.ceil(seconds)\n  const hours = Math.floor(roundedSeconds / 3600)\n  const minutes = Math.floor((roundedSeconds % 3600) / 60)\n  const secs = roundedSeconds % 60\n\n  if (hours > 0) {\n    return [ hours, minutes, secs ].map((value) => String(value).padStart(2, '0')).join(':')\n  }\n\n  return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`\n}\n\nexport function truncateFileName(fileName, maxLength = 36) {\n  if (fileName.length <= maxLength) {\n    return fileName\n  }\n\n  if (maxLength <= 3) {\n    return fileName.slice(0, maxLength)\n  }\n\n  const extensionIndex = fileName.lastIndexOf('.')\n  const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ''\n  const suffixLength = clamp(extension.length + 10, 8, maxLength - 3)\n  const prefixLength = Math.max(maxLength - suffixLength - 3, 1)\n\n  return `${fileName.slice(0, prefixLength)}...${fileName.slice(-suffixLength)}`\n}\n\nexport function formatProgressMessage(snapshot) {\n  return (\n    `progress ${formatPercent(snapshot.totalPercent)} ` +\n    `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +\n    `current ${formatPercent(snapshot.currentFilePercent)} ` +\n    `speed ${snapshot.speedLabel} ` +\n    `eta ${snapshot.etaLabel} ` +\n    `${snapshot.transferredLabel}/${snapshot.totalLabel} ` +\n    `${snapshot.displayFileName}`\n  )\n}\n\nexport function fitFileNameToWidth(fileName, availableWidth, fallbackMaxLength = 36) {\n  if (!Number.isFinite(availableWidth)) {\n    return fileName\n  }\n\n  if (availableWidth <= 0) {\n    return truncateFileName(fileName, Math.max(fallbackMaxLength, 8))\n  }\n\n  if (fileName.length <= availableWidth) {\n    return fileName\n  }\n\n  return truncateFileName(fileName, Math.max(Math.floor(availableWidth), 8))\n}\n\nexport function formatTtyProgressLines(snapshot, barText, columns) {\n  const firstLine = `upload ${barText} ${formatPercent(snapshot.totalPercent)} ${snapshot.transferredLabel}/${snapshot.totalLabel}`\n  const secondLinePrefix =\n    `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +\n    `current ${formatPercent(snapshot.currentFilePercent)} ` +\n    `speed ${snapshot.speedLabel} ` +\n    `eta ${snapshot.etaLabel} `\n  const displayFileName = fitFileNameToWidth(\n    snapshot.currentFileName || snapshot.displayFileName,\n    typeof columns === 'number' ? columns - secondLinePrefix.length : undefined,\n  )\n\n  return [\n    firstLine,\n    `${secondLinePrefix}${displayFileName}`,\n  ]\n}\n\nfunction buildSnapshot(state, now) {\n  const elapsedSeconds =\n    state.startedAt === null ? 0 : Math.max((now() - state.startedAt) / 1000, 0)\n  const speedBytesPerSecond =\n    elapsedSeconds > 0 ? state.totalUploadedBytes / elapsedSeconds : 0\n  const remainingBytes = Math.max(state.totalBytes - state.totalUploadedBytes, 0)\n  const etaSeconds =\n    remainingBytes === 0 ? 0 : speedBytesPerSecond > 0 ? remainingBytes / speedBytesPerSecond : null\n  const totalPercent =\n    state.totalBytes === 0 ? (state.finished ? 100 : 0) : (state.totalUploadedBytes / state.totalBytes) * 100\n  const currentFilePercent =\n    state.currentFileSize === 0\n      ? state.currentFileComplete\n        ? 100\n        : 0\n      : (state.currentFileBytes / state.currentFileSize) * 100\n\n  return {\n    currentFileBytes: state.currentFileBytes,\n    currentFileIndex: state.currentFileIndex,\n    currentFileName: state.currentFileName,\n    currentFilePercent: clamp(currentFilePercent, 0, 100),\n    currentFileSize: state.currentFileSize,\n    displayFileName: state.currentFileName || '-',\n    etaLabel: formatEta(etaSeconds),\n    etaSeconds,\n    speedBytesPerSecond,\n    speedLabel: `${prettyBytes(speedBytesPerSecond)}/s`,\n    totalBytes: state.totalBytes,\n    totalFiles: state.totalFiles,\n    totalPercent: clamp(totalPercent, 0, 100),\n    totalUploadedBytes: state.totalUploadedBytes,\n    totalLabel: prettyBytes(state.totalBytes),\n    transferredLabel: prettyBytes(state.totalUploadedBytes),\n  }\n}\n\nfunction createCaptureStream(columns = 120) {\n  let buffer = ''\n\n  return {\n    clearBuffer() {\n      buffer = ''\n    },\n    clearLine() {},\n    columns,\n    cursorTo() {\n      buffer = ''\n    },\n    isTTY: true,\n    moveCursor() {},\n    write(chunk) {\n      buffer += chunk\n      return true\n    },\n    get value() {\n      return buffer\n    },\n  }\n}\n\nexport function createUploadProgressTracker({\n  totalBytes,\n  totalFiles,\n  isTTY = Boolean(process.stdout.isTTY),\n  log = console.log,\n  now = () => Date.now(),\n  percentStep = 5,\n  ProgressBarClass = ProgressBar,\n  stream = process.stdout,\n  throttleMs = 1000,\n} = {}) {\n  const state = {\n    currentFileBytes: 0,\n    currentFileComplete: false,\n    currentFileIndex: 0,\n    currentFileName: '',\n    currentFileSize: 0,\n    finished: false,\n    startedAt: null,\n    totalBytes,\n    totalFiles,\n    totalUploadedBytes: 0,\n  }\n\n  let lastLoggedAt = -Infinity\n  let lastLoggedBucket = -1\n  let hasRendered = false\n\n  const progressTotal = Math.max(totalBytes, 1)\n  const barCaptureStream = createCaptureStream()\n  const bar =\n    isTTY && totalFiles > 0\n      ? new ProgressBarClass(PROGRESS_BAR_FORMAT, {\n          clear: false,\n          complete: '=',\n          incomplete: ' ',\n          renderThrottle: 100,\n          stream: barCaptureStream,\n          total: progressTotal,\n          width: PROGRESS_BAR_WIDTH,\n        })\n      : null\n\n  function safeClearLine(direction = 0) {\n    stream.clearLine?.(direction)\n  }\n\n  function safeCursorTo(column = 0) {\n    stream.cursorTo?.(column)\n  }\n\n  function safeMoveCursor(dx = 0, dy = 0) {\n    stream.moveCursor?.(dx, dy)\n  }\n\n  function getBarText(snapshot, force = false) {\n    if (!bar) {\n      return ''\n    }\n\n    const ratio = progressTotal > 0 ? clamp(snapshot.totalUploadedBytes / progressTotal, 0, 1) : 0\n    barCaptureStream.clearBuffer()\n\n    if (force) {\n      bar.update(ratio)\n      bar.render(undefined, true)\n    } else {\n      bar.update(ratio)\n    }\n\n    return barCaptureStream.value || bar.lastDraw || '[]'\n  }\n\n  function clearTTYRender() {\n    if (!hasRendered || !stream.isTTY) {\n      return\n    }\n\n    safeClearLine(0)\n    safeCursorTo(0)\n    safeMoveCursor(0, -1)\n    safeClearLine(0)\n    safeCursorTo(0)\n  }\n\n  function renderTTY(force = false) {\n    const snapshot = getSnapshot()\n    const barText = getBarText(snapshot, force)\n    const [ firstLine, secondLine ] = formatTtyProgressLines(snapshot, barText, stream.columns)\n\n    if (hasRendered) {\n      clearTTYRender()\n    }\n\n    stream.write(firstLine)\n    safeClearLine(1)\n    stream.write('\\n')\n    stream.write(secondLine)\n    safeClearLine(1)\n\n    hasRendered = true\n    return snapshot\n  }\n\n  function terminateTTYRender() {\n    if (!hasRendered || !stream.isTTY) {\n      return\n    }\n\n    stream.write('\\n')\n  }\n\n  function ensureStarted() {\n    if (state.startedAt === null) {\n      state.startedAt = now()\n    }\n  }\n\n  function getSnapshot() {\n    return buildSnapshot(state, now)\n  }\n\n  function logSnapshot(force = false) {\n    const snapshot = getSnapshot()\n    const currentBucket =\n      percentStep > 0 ? Math.floor(snapshot.totalPercent / percentStep) : Number.POSITIVE_INFINITY\n\n    if (\n      !force &&\n      now() - lastLoggedAt < throttleMs &&\n      currentBucket <= lastLoggedBucket\n    ) {\n      return snapshot\n    }\n\n    lastLoggedAt = now()\n    lastLoggedBucket = currentBucket\n    log(formatProgressMessage(snapshot))\n\n    return snapshot\n  }\n\n  function render(force = false) {\n    if (bar) {\n      return renderTTY(force)\n    }\n\n    return logSnapshot(force)\n  }\n\n  function advance(deltaBytes) {\n    if (deltaBytes <= 0) {\n      return getSnapshot()\n    }\n\n    ensureStarted()\n\n    const remainingFile = Math.max(state.currentFileSize - state.currentFileBytes, 0)\n    const remainingTotal = Math.max(state.totalBytes - state.totalUploadedBytes, 0)\n    const safeDelta = Math.min(deltaBytes, remainingFile, remainingTotal)\n\n    if (safeDelta <= 0) {\n      return getSnapshot()\n    }\n\n    state.currentFileBytes += safeDelta\n    state.totalUploadedBytes += safeDelta\n\n    return render()\n  }\n\n  function startFile(file, fileIndex) {\n    ensureStarted()\n    state.currentFileBytes = 0\n    state.currentFileComplete = false\n    state.currentFileIndex = fileIndex\n    state.currentFileName = file.name\n    state.currentFileSize = file.size\n\n    return render(true)\n  }\n\n  function completeFile() {\n    const remainingBytes = Math.max(state.currentFileSize - state.currentFileBytes, 0)\n    if (remainingBytes > 0) {\n      advance(remainingBytes)\n    }\n\n    state.currentFileComplete = true\n    return render(true)\n  }\n\n  function resetCurrentFile() {\n    state.totalUploadedBytes = clamp(\n      state.totalUploadedBytes - state.currentFileBytes,\n      0,\n      Math.max(state.totalBytes, 0),\n    )\n    state.currentFileBytes = 0\n    state.currentFileComplete = false\n\n    return render(true)\n  }\n\n  function finish() {\n    state.finished = true\n\n    if (bar) {\n      const snapshot = renderTTY(true)\n      terminateTTYRender()\n      return snapshot\n    }\n\n    return logSnapshot(true)\n  }\n\n  function fail(fileName = state.currentFileName) {\n    const snapshot = getSnapshot()\n    if (bar) {\n      renderTTY(true)\n      terminateTTYRender()\n    }\n\n    log(\n      `upload failed at file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +\n        `${truncateFileName(fileName || snapshot.currentFileName || '-')} ` +\n        `(${formatPercent(snapshot.currentFilePercent)} current, ` +\n        `${formatPercent(snapshot.totalPercent)} total, ` +\n        `${snapshot.speedLabel}, eta ${snapshot.etaLabel}, ` +\n        `${snapshot.transferredLabel}/${snapshot.totalLabel})`,\n    )\n  }\n\n  function interrupt(message) {\n    if (bar && hasRendered) {\n      clearTTYRender()\n      stream.write(message)\n      stream.write('\\n')\n      renderTTY(true)\n      return\n    }\n\n    log(message)\n  }\n\n  return {\n    advance,\n    completeFile,\n    fail,\n    finish,\n    getSnapshot,\n    interrupt,\n    resetCurrentFile,\n    startFile,\n  }\n}\n"
  },
  {
    "path": "scripts/upload-release.mjs",
    "content": "import chalk from 'chalk'\nimport { config as loadEnv } from 'dotenv'\nimport { createReadStream, promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { Transform } from 'node:stream'\nimport { fileURLToPath } from 'node:url'\nimport prettyBytes from 'pretty-bytes'\nimport {\n  getFullVersion,\n  getReleaseTag,\n  getReleaseVersion,\n  isReleaseArtifactFile,\n  resolveGithubRepository,\n} from './release-config.mjs'\nimport {\n  attachDiagnostic,\n  buildDebugPayload,\n  buildDiagnostic,\n  formatDiagnosticSummary,\n  formatRetrySummary,\n} from './upload-diagnostics.mjs'\nimport { createUploadProgressTracker } from './upload-progress.mjs'\n\nloadEnv()\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\nconst rootDir = path.normalize(path.join(__dirname, '..'))\nconst distDir = path.join(rootDir, 'dist')\n\nconst dryRun = process.env.DRY_RUN === '1' || process.argv.includes('--dry-run')\nconst token = process.env.GH_TOKEN\nconst repository = resolveGithubRepository(process.env)\nconst releaseTag = getReleaseTag(process.env)\nconst releaseVersion = getReleaseVersion()\nconst fullVersion = getFullVersion()\nconst retryAttempts = Math.max(\n  1,\n  Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_ATTEMPTS, 10) || 3,\n)\nconst retryBaseDelayMs = Math.max(\n  250,\n  Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_BASE_DELAY_MS, 10) || 1500,\n)\nconst retryMaxDelayMs = Math.max(\n  retryBaseDelayMs,\n  Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_MAX_DELAY_MS, 10) || 10000,\n)\nconst retryableStatusCodes = new Set([ 408, 409, 425, 429, 500, 502, 503, 504 ])\nconst debugDiagnostics = process.env.RELEASE_UPLOAD_DEBUG === '1'\n\nfunction log(message) {\n  console.log(`[release:upload] ${message}`)\n}\n\nfunction logFileList(files) {\n  log('files:')\n  files.forEach((file) => {\n    console.log(`  - ${file.name} (${prettyBytes(file.size)})`)\n  })\n}\n\nfunction getArtifactVersion(fileName) {\n  const match = /-v(\\d+\\.\\d+\\.\\d+\\.\\d+)-/.exec(fileName)\n  return match ? match[1] : null\n}\n\nfunction sleep(ms) {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms)\n  })\n}\n\nfunction getRetryDelayMs(attempt) {\n  return Math.min(retryBaseDelayMs * 2 ** Math.max(attempt - 1, 0), retryMaxDelayMs)\n}\n\nfunction formatRetryDelay(ms) {\n  return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`\n}\n\nfunction isRetryableStatus(status) {\n  return retryableStatusCodes.has(status)\n}\n\nfunction isRetryableFetchError(error) {\n  if (!(error instanceof Error)) {\n    return false\n  }\n\n  const code =\n    typeof error.cause === 'object' && error.cause !== null && 'code' in error.cause\n      ? String(error.cause.code || '')\n      : ''\n  const message = `${error.message} ${code}`.toLowerCase()\n\n  return (\n    message.includes('fetch failed') ||\n    message.includes('network') ||\n    message.includes('timeout') ||\n    message.includes('econnreset') ||\n    message.includes('eai_again') ||\n    message.includes('enotfound') ||\n    message.includes('econnrefused') ||\n    message.includes('socket')\n  )\n}\n\nfunction getProgressSnapshot(progressTracker) {\n  return progressTracker?.getSnapshot() ?? null\n}\n\nfunction logDiagnosticDebug(error) {\n  if (!debugDiagnostics) {\n    return\n  }\n\n  const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null\n  const payload = buildDebugPayload(diagnostic, error)\n  console.error(chalk.gray('[release:upload] debug diagnostic:'))\n  console.error(chalk.gray(JSON.stringify(payload, null, 2)))\n}\n\nasync function readReleaseFiles() {\n  const entries = await fs.readdir(distDir, { withFileTypes: true })\n  const files = entries.filter((entry) => entry.isFile())\n  const mismatchedVersionedFiles = files\n    .map((entry) => entry.name)\n    .filter((fileName) => {\n      const artifactVersion = getArtifactVersion(fileName)\n      return artifactVersion && artifactVersion !== fullVersion\n    })\n\n  if (mismatchedVersionedFiles.length > 0) {\n    throw new Error(\n      `Cannot prepare GitHub Release assets for version ${fullVersion}.\\n` +\n        `Found old build artifacts in dist/: ${mismatchedVersionedFiles.join(', ')}\\n` +\n        `This usually means src/version.json was updated after the last package build, so only latest*.yml still matches.\\n` +\n        `Please rebuild the app for version ${fullVersion}, or clean dist/ before uploading.`,\n    )\n  }\n\n  // Keep the asset picker strict so repeated uploads remain deterministic across machines.\n  const selectedFiles = files\n    .filter((entry) => isReleaseArtifactFile(entry.name, fullVersion))\n    .map((entry) => ({\n      name: entry.name,\n      filePath: path.join(distDir, entry.name),\n    }))\n    .sort((a, b) => a.name.localeCompare(b.name))\n\n  return Promise.all(\n    selectedFiles.map(async (file) => ({\n      ...file,\n      size: (await fs.stat(file.filePath)).size,\n    })),\n  )\n}\n\nasync function githubRequest(\n  pathname,\n  { method = 'GET', body, headers = {}, stage = 'github-request', fileName = null } = {},\n) {\n  const requestUrl = `https://api.github.com${pathname}`\n\n  for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {\n    let response\n\n    try {\n      response = await fetch(requestUrl, {\n        method,\n        headers: {\n          Accept: 'application/vnd.github+json',\n          Authorization: `Bearer ${token}`,\n          'User-Agent': 'SwitchHosts-release-uploader',\n          'X-GitHub-Api-Version': '2022-11-28',\n          ...headers,\n        },\n        body,\n      })\n    } catch (error) {\n      const diagnostic = buildDiagnostic({\n        attempt,\n        error,\n        fileName,\n        maxAttempts: retryAttempts,\n        method,\n        retryable: isRetryableFetchError(error),\n        stage,\n        target: pathname,\n      })\n\n      if (attempt >= retryAttempts || !isRetryableFetchError(error)) {\n        throw attachDiagnostic(error, diagnostic)\n      }\n\n      const delayMs = getRetryDelayMs(attempt)\n      log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))\n      await sleep(delayMs)\n      continue\n    }\n\n    if (!response.ok) {\n      const text = await response.text()\n      const error = new Error(`${method} ${pathname} failed: ${response.status} ${text}`)\n      const diagnostic = buildDiagnostic({\n        attempt,\n        error,\n        fileName,\n        httpStatus: response.status,\n        maxAttempts: retryAttempts,\n        method,\n        retryable: isRetryableStatus(response.status),\n        stage,\n        target: pathname,\n      })\n\n      if (attempt < retryAttempts && isRetryableStatus(response.status)) {\n        const delayMs = getRetryDelayMs(attempt)\n        log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))\n        await sleep(delayMs)\n        continue\n      }\n\n      throw attachDiagnostic(error, diagnostic)\n    }\n\n    if (response.status === 204) {\n      return null\n    }\n\n    return response.json()\n  }\n\n  throw new Error(`${method} ${pathname} failed after ${retryAttempts} attempts.`)\n}\n\nasync function findReleaseByTag() {\n  let page = 1\n  const maxPages = 20\n\n  while (page <= maxPages) {\n    // The list API is used here because draft releases are not reliably addressable\n    // through the single-release-by-tag endpoint.\n    const releases = await githubRequest(\n      `/repos/${repository.owner}/${repository.repo}/releases?per_page=100&page=${page}`,\n      {\n        stage: 'find-release',\n      },\n    )\n\n    const found = releases.find((release) => release.tag_name === releaseTag)\n    if (found) {\n      return found\n    }\n\n    if (releases.length < 100) {\n      return null\n    }\n\n    page += 1\n  }\n}\n\nasync function createDraftRelease() {\n  return githubRequest(`/repos/${repository.owner}/${repository.repo}/releases`, {\n    method: 'POST',\n    stage: 'create-release',\n    headers: {\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      tag_name: releaseTag,\n      name: releaseTag,\n      draft: true,\n      prerelease: false,\n      generate_release_notes: false,\n    }),\n  })\n}\n\nfunction getUploadUrl(release) {\n  return release.upload_url.replace(/\\{.*$/, '')\n}\n\nasync function deleteAsset(assetId, assetName) {\n  await githubRequest(`/repos/${repository.owner}/${repository.repo}/releases/assets/${assetId}`, {\n    method: 'DELETE',\n    stage: 'delete-asset',\n    fileName: assetName,\n  })\n}\n\nasync function tryDeleteAssetByName(releaseId, assetName) {\n  try {\n    const assets = await githubRequest(\n      `/repos/${repository.owner}/${repository.repo}/releases/${releaseId}/assets?per_page=100`,\n      { stage: 'list-assets', fileName: assetName },\n    )\n    const match = assets?.find((asset) => asset.name === assetName)\n    if (match) {\n      await deleteAsset(match.id, assetName)\n    }\n  } catch (_) {\n    // Best-effort cleanup — don't block the retry if this fails.\n  }\n}\n\nasync function uploadAsset(uploadUrl, file, { fileIndex, releaseId, progressTracker } = {}) {\n  const url = new URL(uploadUrl)\n  url.searchParams.set('name', file.name)\n  progressTracker?.startFile(file, fileIndex)\n\n  for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {\n    const fileStream = createReadStream(file.filePath)\n    const trackedStream = fileStream.pipe(\n      new Transform({\n        transform(chunk, encoding, callback) {\n          progressTracker?.advance(chunk.byteLength)\n          callback(null, chunk)\n        },\n      }),\n    )\n\n    let response\n\n    try {\n      response = await fetch(url, {\n        method: 'POST',\n        headers: {\n          Accept: 'application/vnd.github+json',\n          Authorization: `Bearer ${token}`,\n          'Content-Length': String(file.size),\n          'Content-Type': 'application/octet-stream',\n          'User-Agent': 'SwitchHosts-release-uploader',\n          'X-GitHub-Api-Version': '2022-11-28',\n        },\n        body: trackedStream,\n        duplex: 'half',\n      })\n    } catch (error) {\n      fileStream.destroy()\n      trackedStream.destroy()\n      const diagnostic = buildDiagnostic({\n        attempt,\n        error,\n        fileIndex,\n        fileName: file.name,\n        maxAttempts: retryAttempts,\n        method: 'POST',\n        progressSnapshot: getProgressSnapshot(progressTracker),\n        retryable: isRetryableFetchError(error),\n        stage: 'upload-asset',\n        target: url,\n      })\n\n      if (attempt < retryAttempts && isRetryableFetchError(error)) {\n        const delayMs = getRetryDelayMs(attempt)\n        await tryDeleteAssetByName(releaseId, file.name)\n        progressTracker?.resetCurrentFile()\n        progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)\n        await sleep(delayMs)\n        continue\n      }\n\n      progressTracker?.fail(file.name)\n      throw attachDiagnostic(error, diagnostic)\n    }\n\n    if (!response.ok) {\n      fileStream.destroy()\n      trackedStream.destroy()\n      const text = await response.text()\n      const error = new Error(`Upload failed for ${file.name}: ${response.status} ${text}`)\n      const diagnostic = buildDiagnostic({\n        attempt,\n        error,\n        fileIndex,\n        fileName: file.name,\n        httpStatus: response.status,\n        maxAttempts: retryAttempts,\n        method: 'POST',\n        progressSnapshot: getProgressSnapshot(progressTracker),\n        retryable: isRetryableStatus(response.status),\n        stage: 'upload-asset',\n        target: url,\n      })\n\n      if (attempt < retryAttempts && isRetryableStatus(response.status)) {\n        const delayMs = getRetryDelayMs(attempt)\n        await tryDeleteAssetByName(releaseId, file.name)\n        progressTracker?.resetCurrentFile()\n        progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)\n        await sleep(delayMs)\n        continue\n      }\n\n      progressTracker?.fail(file.name)\n      throw attachDiagnostic(error, diagnostic)\n    }\n\n    progressTracker?.completeFile()\n    return response.json()\n  }\n\n  const exhaustedError = new Error(`Upload failed for ${file.name} after ${retryAttempts} attempts.`)\n  progressTracker?.fail(file.name)\n  throw attachDiagnostic(\n    exhaustedError,\n    buildDiagnostic({\n      attempt: retryAttempts,\n      error: exhaustedError,\n      fileIndex,\n      fileName: file.name,\n      maxAttempts: retryAttempts,\n      method: 'POST',\n      progressSnapshot: getProgressSnapshot(progressTracker),\n      retryable: false,\n      stage: 'upload-asset',\n      target: url,\n    }),\n  )\n}\n\nasync function main() {\n  const files = await readReleaseFiles()\n  const totalFiles = files.length\n  const totalBytes = files.reduce((sum, file) => sum + file.size, 0)\n\n  if (files.length === 0) {\n    throw new Error(`No release artifacts found in ${distDir} for version ${fullVersion}.`)\n  }\n\n  log(`repository: ${repository.fullName}`)\n  log(`release version: ${releaseVersion}`)\n  log(`release tag: ${releaseTag}`)\n  log(`artifacts: ${totalFiles} files, ${prettyBytes(totalBytes)}`)\n  logFileList(files)\n\n  if (dryRun) {\n    log('dry run enabled, skipping GitHub API calls.')\n    return\n  }\n\n  if (!token) {\n    throw new Error('GH_TOKEN is required unless DRY_RUN=1 is set.')\n  }\n\n  let release = await findReleaseByTag()\n  if (!release) {\n    log(`release ${releaseTag} not found, creating draft release...`)\n    release = await createDraftRelease()\n  } else {\n    log(`using existing release ${releaseTag} (draft=${release.draft}, prerelease=${release.prerelease})`)\n  }\n\n  const uploadUrl = getUploadUrl(release)\n  const existingAssets = new Map(release.assets.map((asset) => [asset.name, asset]))\n  const progressTracker = createUploadProgressTracker({\n    totalBytes,\n    totalFiles,\n    log,\n  })\n  const logUploadStatus = (message) => progressTracker.interrupt(`[release:upload] ${message}`)\n\n  for (const [ index, file ] of files.entries()) {\n    const existingAsset = existingAssets.get(file.name)\n    if (existingAsset) {\n      // Replace same-name assets so different machines can safely append\n      // or refresh artifacts for the same draft release.\n      logUploadStatus(`replacing existing asset ${file.name}`)\n      await deleteAsset(existingAsset.id, file.name)\n    } else {\n      logUploadStatus(`uploading new asset ${file.name}`)\n    }\n\n    await uploadAsset(uploadUrl, file, {\n      fileIndex: index + 1,\n      releaseId: release.id,\n      progressTracker,\n    })\n  }\n\n  progressTracker.finish()\n  log(`done: ${release.html_url}`)\n}\n\ntry {\n  await main()\n} catch (error) {\n  const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null\n  const message = diagnostic ? formatDiagnosticSummary(diagnostic) : error instanceof Error ? error.message : String(error)\n  console.error(chalk.red(`[release:upload] ${message}`))\n  logDiagnosticDebug(error)\n  process.exit(1)\n}\n"
  },
  {
    "path": "scripts/vars.mjs",
    "content": "import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\nconst rootDir = path.normalize(path.join(__dirname, '..'))\nconst distDir = path.normalize(path.join(__dirname, '..', 'dist'))\n\nconst APP_NAME = 'SwitchHosts'\n\nconst electronLanguages = ['en', 'fr', 'zh_CN', 'de', 'ja', 'tr', 'ko']\n\nexport { APP_NAME, distDir, electronLanguages, rootDir }\n"
  },
  {
    "path": "scripts/version-up.mjs",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\nconst rootDir = path.dirname(__dirname)\nconst versionFile = path.join(rootDir, 'src', 'version.json')\nconst appPackageFile = path.join(rootDir, 'app', 'package.json')\nconst version = JSON.parse(fs.readFileSync(versionFile, 'utf8'))\nconst appPackage = JSON.parse(fs.readFileSync(appPackageFile, 'utf8'))\n\nconst versionInc = (v) => {\n  return ++v\n}\n\nversion[3] = versionInc(version[3])\n\nconsole.log(`version -> ${version.slice(0, 3).join('.')}(${version[3]})`)\nfs.writeFileSync(versionFile, `[${version.join(', ')}]`)\n\nappPackage.version = version.slice(0, 3).join('.') + '.' + version[3]\nfs.writeFileSync(\n  appPackageFile,\n  JSON.stringify(appPackage, null, 2),\n  'utf8',\n)\n"
  },
  {
    "path": "src/common/acknowledgements.ts",
    "content": "/**\n * acknowledgements\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default [\n  { name: 'oldj', link: 'https://github.com/oldj' },\n  { name: 'Allen.M', link: 'https://github.com/allenm' },\n  { name: 'Charles Tang', link: 'https://github.com/charlestang' },\n  { name: 'WuJianjun', link: 'https://github.com/stotem' },\n  { name: 'Elf Sundae', link: 'https://github.com/ElfSundae' },\n  { name: 'zhu yu', link: 'https://github.com/codeyu' },\n  { name: '胖梁', link: 'https://github.com/pangliang' },\n  { name: 'CaffreySun', link: 'https://github.com/CaffreySun' },\n  { name: 'Xmader', link: 'https://github.com/Xmader' },\n  { name: 'Dean Zhang', link: 'https://github.com/zhanggang807' },\n  { name: 'CloverNet', link: 'https://github.com/CloverNet' },\n  { name: 'ReAlign', link: 'https://github.com/ReAlign' },\n  { name: 'Kangyi Cui', link: 'https://github.com/cuikangyi' },\n  { name: 'AKIRA', link: 'https://github.com/akrha' },\n  { name: 'Constaline', link: 'https://github.com/Constaline' },\n  { name: 'TooBug', link: 'https://github.com/TooBug' },\n  { name: 'Lussac', link: 'https://github.com/LussacZheng' },\n  { name: 'Aktilor', link: 'https://github.com/Aktilor' },\n  { name: 'LiangLong', link: 'https://github.com/xxccll' },\n  { name: 'ClDaniel1', link: 'https://github.com/ClDaniel1' },\n  { name: 'Aaron Xie', link: 'https://github.com/Aaron00101010' },\n  { name: 'Stefan Berger', link: 'https://github.com/bergo' },\n  { name: 'EmeryWan', link: 'https://github.com/EmeryWan' },\n  { name: 'ClDaniel1', link: 'https://github.com/ClDaniel1' },\n  { name: 'moonheart', link: 'https://github.com/moonheart' },\n  { name: 'Wang Weitao', link: 'https://github.com/watonyweng' },\n  { name: 'kamatte', link: 'https://github.com/kamatte-me' },\n  { name: 'Yuyao Nie', link: 'https://github.com/nieyuyao' },\n  { name: 'Xav83', link: 'https://github.com/Xav83' },\n  { name: 'Mango Jelly Pudding', link: 'https://github.com/EvanHsieh0415' },\n  { name: 'Alex Zappa', link: 'https://github.com/reatlat' },\n  { name: 'shenshen', link: 'https://github.com/imshenshen' },\n  { name: 'ChunRen Zhang', link: 'https://github.com/rayatn1011' },\n  { name: 'Barış Uzun', link: 'https://github.com/barisuzunn' },\n  { name: 'Hwang In-wook', link: 'https://github.com/wooklab' },\n]\n"
  },
  {
    "path": "src/common/constants.ts",
    "content": "/**\n * constants\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport const server_url = 'https://switchhosts.vercel.app'\nexport const homepage_url = `${server_url}/home/`\nexport const download_url = `${server_url}/download/`\nexport const source_url = 'https://github.com/oldj/SwitchHosts'\nexport const feedback_url = 'https://github.com/oldj/SwitchHosts/issues'\nexport const http_api_port = 50761\n"
  },
  {
    "path": "src/common/data.d.ts",
    "content": "import { ITreeNodeData } from './tree'\n\nexport type HostsType = 'local' | 'remote' | 'group' | 'folder'\nexport type FolderModeType = 0 | 1 | 2 // 0: 默认; 1: 单选; 2: 多选\n\nexport interface IHostsListObject {\n  id: string\n  title?: string\n  on?: boolean\n  type?: HostsType\n\n  // remote\n  url?: string\n  last_refresh?: string\n  last_refresh_ms?: number\n  refresh_interval?: number // 单位：秒\n\n  // group\n  include?: string[]\n\n  // folder\n  folder_mode?: FolderModeType\n  folder_open?: boolean\n  children?: IHostsListObject[]\n\n  is_sys?: boolean\n\n  [key: string]: any\n}\n\nexport interface IHostsContentObject {\n  id: string\n  content: string\n\n  [key: string]: any\n}\n\nexport interface ITrashcanObject {\n  data: IHostsListObject\n  add_time_ms: number\n  parent_id: string | null\n}\n\nexport interface ITrashcanListObject extends ITrashcanObject, ITreeNodeData {\n  id: string\n  children?: ITrashcanListObject[]\n  is_root?: boolean\n  type?: HostsType | 'trashcan'\n\n  [key: string]: any\n}\n\nexport interface IHostsHistoryObject {\n  id: string\n  content: string\n  add_time_ms: number\n  label?: string\n}\n\nexport type VersionType = [number, number, number, number]\n\nexport interface IHostsBasicData {\n  list: IHostsListObject[]\n  trashcan: ITrashcanObject[]\n  version: VersionType\n}\n\nexport interface IOperationResult {\n  success: boolean\n  message?: string\n  data?: any\n  code?: string | number\n}\n\nexport interface ICommandRunResult {\n  _id?: string\n  success: boolean\n  stdout: string\n  stderr: string\n  add_time_ms: number\n}\n"
  },
  {
    "path": "src/common/default_configs.ts",
    "content": "import { LocaleName } from '@common/i18n'\nimport { FolderModeType } from './data.d'\n\nexport type WriteModeType = null | 'overwrite' | 'append'\nexport type ThemeType = 'light' | 'dark' | 'system'\nexport type ProtocolType = 'http' | 'https'\nexport type DefaultLocaleType = LocaleName | undefined\n\nconst configs = {\n  // UI\n  left_panel_show: true,\n  left_panel_width: 270,\n  use_system_window_frame: false,\n\n  // preferences\n  write_mode: 'append' as WriteModeType,\n  history_limit: 50,\n  locale: undefined as DefaultLocaleType,\n  theme: 'light' as ThemeType,\n  choice_mode: 2 as FolderModeType,\n  show_title_on_tray: false,\n  hide_at_launch: false,\n  send_usage_data: false,\n  cmd_after_hosts_apply: '',\n  remove_duplicate_records: false,\n  hide_dock_icon: false,\n  use_proxy: false,\n  proxy_protocol: 'http' as ProtocolType,\n  proxy_host: '',\n  proxy_port: 0,\n  http_api_on: false,\n  http_api_only_local: true,\n  tray_mini_window: true,\n  multi_chose_folder_switch_all: false,\n\n  // Legacy key: it now controls background update checks, while the actual\n  // download remains a manual action in the UI.\n  auto_download_update: true,\n\n  // other\n  env: 'PROD' as 'PROD' | 'DEV',\n}\n\nexport type ConfigsType = typeof configs\n\nexport default configs\n"
  },
  {
    "path": "src/common/events.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default {\n  active_main_window: 'active_main_window',\n  add_new: 'add_new',\n  browser_link: 'browser_link',\n  close_find: 'close_find',\n  cmd_run_result: 'cmd_run_result',\n  config_updated: 'config_updated',\n  edit_hosts_info: 'edit_hosts_info',\n  hosts_content_changed: 'hosts_content_changed',\n  hosts_refreshed: 'hosts_refreshed',\n  hosts_refreshed_by_id: 'hosts_refreshed_by_id',\n  move_to_trashcan: 'move_to_trashcan',\n  new_version: 'new_version',\n  reload_list: 'reload_list',\n  select_hosts: 'select_hosts',\n  set_hosts_on_status: 'set_hosts_on_status',\n  show_about: 'show_about',\n  show_history: 'show_history',\n  show_preferences: 'show_preferences',\n  show_set_write_mode: 'show_set_write_mode',\n  show_source: 'show_source',\n  show_sudo_password_input: 'show_sudo_password_input',\n  system_hosts_updated: 'system_hosts_updated',\n  toggle_comment: 'toggle_comment',\n  toggle_developer_tools: 'toggle_developer_tools',\n  toggle_item: 'toggle_item',\n  toggle_left_panel: 'toggle_left_panel',\n  tray_list_updated: 'tray:list_updated',\n  update_download_progress: 'update_download_progress',\n  update_downloaded: 'update_downloaded',\n  write_hosts_to_system: 'write_hosts_to_system',\n}\n"
  },
  {
    "path": "src/common/hostsFn.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { FolderModeType, IHostsBasicData, IHostsListObject } from '@common/data'\nimport lodash from 'lodash'\n\ntype PartHostsObjectType = Partial<IHostsListObject> & { id: string }\n\ntype Predicate = (obj: IHostsListObject) => boolean\n\nexport const flatten = (list: IHostsListObject[]): IHostsListObject[] => {\n  let new_list: IHostsListObject[] = []\n\n  list.map((item) => {\n    new_list.push(item)\n    if (item.children) {\n      new_list = [...new_list, ...flatten(item.children)]\n    }\n  })\n\n  return new_list\n}\n\nexport const cleanHostsList = (data: IHostsBasicData): IHostsBasicData => {\n  let list = flatten(data.list)\n\n  list.map((item) => {\n    if (item.type === 'folder' && !Array.isArray(item.children)) {\n      item.children = [] as IHostsListObject[]\n    }\n\n    if (item.type === 'group' && !Array.isArray(item.include)) {\n      item.include = [] as string[]\n    }\n\n    if (item.type === 'folder' || item.type === 'group') {\n      item.content = ''\n    }\n  })\n\n  return data\n}\n\nexport const findItemById = (\n  list: IHostsListObject[],\n  id: string,\n): IHostsListObject | undefined => {\n  return flatten(list).find((item) => item.id === id)\n}\n\nexport const updateOneItem = (\n  list: IHostsListObject[],\n  item: PartHostsObjectType,\n): IHostsListObject[] => {\n  let new_list: IHostsListObject[] = lodash.cloneDeep(list)\n\n  let i = findItemById(new_list, item.id)\n  if (i) {\n    Object.assign(i, item)\n  }\n\n  return new_list\n}\n\nconst isInTopLevel = (list: IHostsListObject[], id: string): boolean => {\n  return list.findIndex((i) => i.id === id) > -1\n}\n\nexport const setOnStateOfItem = (\n  list: IHostsListObject[],\n  id: string,\n  on: boolean,\n  default_choice_mode: FolderModeType = 0,\n  multi_chose_folder_switch_all: boolean = false,\n): IHostsListObject[] => {\n  let new_list: IHostsListObject[] = lodash.cloneDeep(list)\n\n  let item = findItemById(new_list, id)\n  if (!item) return new_list\n\n  item.on = on\n\n  let itemIsInTopLevel = isInTopLevel(list, id)\n  if (multi_chose_folder_switch_all) {\n    item = switchFolderChild(item, on)\n    !itemIsInTopLevel && switchItemParentIsON(new_list, item, on)\n  }\n\n  if (!on) {\n    return new_list\n  }\n\n  if (itemIsInTopLevel) {\n    if (default_choice_mode === 1) {\n      new_list.map((item) => {\n        if (item.id !== id) {\n          item.on = false\n          if (multi_chose_folder_switch_all) {\n            item = switchFolderChild(item, false)\n          }\n        }\n      })\n    }\n  } else {\n    let parent = getParentOfItem(new_list, id)\n    if (parent) {\n      let folder_mode = parent.folder_mode || default_choice_mode\n      if (folder_mode === 1 && parent.children) {\n        // 单选模式\n        parent.children.map((item) => {\n          if (item.id !== id) {\n            item.on = false\n            if (multi_chose_folder_switch_all) {\n              item = switchFolderChild(item, false)\n            }\n          }\n        })\n      }\n    }\n  }\n\n  return new_list\n}\n\nexport const switchItemParentIsON = (\n  list: IHostsListObject[],\n  item: IHostsListObject,\n  on: boolean,\n) => {\n  let parent = getParentOfItem(list, item.id)\n\n  if (parent) {\n    if (parent.folder_mode === 1) {\n      return\n    }\n    if (!on) {\n      parent.on = on\n    } else if (parent.children) {\n      let parentOn = true\n      parent.children.forEach((item) => {\n        if (!item.on) {\n          parentOn = false\n        }\n      })\n      parent.on = parentOn\n    }\n\n    let itemIsInTopLevel = isInTopLevel(list, parent.id)\n    if (!itemIsInTopLevel) {\n      switchItemParentIsON(list, parent, on)\n    }\n  }\n}\n\nexport const switchFolderChild = (item: IHostsListObject, on: boolean): IHostsListObject => {\n  if (item.type != 'folder') {\n    return item\n  }\n  let folder_mode = item.folder_mode\n  if (folder_mode === 1) {\n    return item\n  }\n\n  if (item.children) {\n    item.children.forEach((item) => {\n      item.on = on\n      if (item.type == 'folder') {\n        item = switchFolderChild(item, on)\n      }\n    })\n  }\n\n  return item\n}\n\nexport const deleteItemById = (list: IHostsListObject[], id: string) => {\n  let idx = list.findIndex((item) => item.id === id)\n  if (idx >= 0) {\n    list.splice(idx, 1)\n    return\n  }\n\n  list.map((item) => deleteItemById(item.children || [], id))\n}\n\n// export const getNextSelectedItem = (list: IHostsListObject[], id: string): IHostsListObject | undefined => {\n//   let flat = flatten(list)\n//   let idx = flat.findIndex(item => item.id === id)\n//\n//   return flat[idx + 1] || flat[idx - 1]\n// }\n\nexport const getNextSelectedItem = (\n  tree: IHostsListObject[],\n  predicate: Predicate,\n): IHostsListObject | undefined => {\n  let flat = flatten(tree)\n  let idx_1 = -1\n  let idx_2 = -1\n\n  flat.map((i, idx) => {\n    if (predicate(i)) {\n      if (idx_1 === -1) {\n        idx_1 = idx\n      }\n      idx_2 = idx\n    }\n  })\n\n  return flat[idx_2 + 1] || flat[idx_1 - 1]\n}\n\nexport const getParentOfItem = (\n  list: IHostsListObject[],\n  item_id: string,\n): IHostsListObject | undefined => {\n  if (list.find((i) => i.id === item_id)) {\n    // is in the top level\n    return\n  }\n\n  let flat = flatten(list)\n  for (let p of flat) {\n    if (p.children && p.children.find((i) => i.id === item_id)) {\n      return p\n    }\n  }\n}\n"
  },
  {
    "path": "src/common/i18n/index.ts",
    "content": "/**\n * index\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport en from './languages/en'\nimport zh from './languages/zh'\nimport zh_hant from './languages/zh-hant'\nimport fr from './languages/fr'\nimport de from './languages/de'\nimport ja from './languages/ja'\nimport tr from './languages/tr'\nimport ko from './languages/ko'\nimport pl from './languages/pl'\nimport { LanguageDict, LanguageKey } from '@common/types'\n\nexport const languages = {\n  en,\n  zh,\n  cn: zh,\n  'zh-CN': zh,\n  zh_hant: zh_hant,\n  'zh-TW': zh_hant,\n  fr,\n  de,\n  ja,\n  tr,\n  ko,\n  pl,\n}\n\nexport type LocaleName = keyof typeof languages\n\nexport class I18N {\n  locale: LocaleName\n  lang: LanguageDict\n\n  constructor(locale: LocaleName = 'en') {\n    this.locale = locale\n\n    const _this = this\n\n    this.lang = new Proxy(\n      {},\n      {\n        get(obj, key: LanguageKey) {\n          return _this.trans(key)\n        },\n      },\n    ) as LanguageDict\n  }\n\n  trans(key: LanguageKey, words?: string[]) {\n    let lang = languages[this.locale]\n\n    let s: string = ''\n\n    if (key in lang) {\n      s = lang[key].toString()\n    }\n\n    if (words) {\n      words.map((w, idx) => {\n        let reg = new RegExp(`\\{\\s*${idx}\\s*}`)\n        s = s.replace(reg, w)\n      })\n    }\n\n    return s\n  }\n}\n"
  },
  {
    "path": "src/common/i18n/languages/de.ts",
    "content": "/**\n * @author: bergo\n * @homepage: https://bergo.dev\n */\n\nimport { LanguageDict } from '@common/types'\n\nconst lang: LanguageDict = {\n  _app_name: 'SwitchHosts',\n  _key: 'de',\n  _name: 'Deutsch',\n  about: 'Über',\n  acknowledgement: 'Danksagung',\n  advanced: 'Erweitert',\n  all: 'Alle',\n  append: 'Anhängen',\n  auto_refresh: 'Automatisch aktualisieren',\n  btn_cancel: 'Abbrechen',\n  btn_ok: 'OK',\n  change: 'Ändern',\n  check_update: 'Aktualisierung prüfen',\n  choice_mode: 'Auswahlmodus',\n  choice_mode_default: 'Standard',\n  choice_mode_desc:\n    'Gilt nur für das oberste Element, jeder Ordner kann seinen eigenen Auswahlmodus festlegen.',\n  choice_mode_multiple: 'Mehrfach',\n  choice_mode_single: 'Einfach',\n  choices: 'Auswahlen',\n  chosen: 'Ausgewählt',\n  clear_history: 'Verlauf löschen',\n  click_to_open: 'Klicken zum Öffnen',\n  close: 'Schließen',\n  colon: ': ',\n  commands: 'Befehle',\n  commands_help: 'Die folgenden Systembefehle werden ausgeführt, wenn Hosts angewendet werden:',\n  commands_title: 'Befehl nach dem Anlegen eines Hosts',\n  comment_current_line: 'Aktuelle Zeile kommentieren',\n  content: 'Inhalt',\n  copy: 'Kopieren',\n  cut: 'Ausschneiden',\n  day: 'Tag',\n  days: 'Tage',\n  delete: 'Löschen',\n  download: 'Herunterladen',\n  edit: 'Bearbeiten',\n  export: 'Exportieren',\n  export_done: 'Der Export ist abgeschlossen.',\n  fail: 'Fehlgeschlagen!',\n  feedback: 'Rückmeldung',\n  file: 'Datei',\n  find: 'Suchen',\n  find_all: 'Alles suchen',\n  find_and_replace: 'Suchen und ersetzen',\n  find_history: 'Verlauf suchen',\n  folder: 'Ordner',\n  front: 'Vorderseite',\n  general: 'Allgemein',\n  group: 'Gruppe',\n  help: 'Hilfe',\n  hide: 'Ausblenden',\n  hide_at_launch: 'Beim Start ausblenden',\n  hide_dock_icon: 'Dock-Symbol ausblenden',\n  hide_history: 'Verlauf ausblenden',\n  hide_others: 'Andere ausblenden',\n  homepage: 'Startseite',\n  host: 'Gastgeber',\n  hosts_add: 'Neue Hosts hinzufügen',\n  hosts_delete: 'Diesen Host löschen',\n  hosts_delete_confirm: 'Sind Sie sicher, dass Sie die aktuellen Hosts löschen wollen?',\n  hosts_edit: 'Hosts bearbeiten',\n  hosts_title: 'Titel des Hosts',\n  hosts_type: 'Hosts Typ',\n  hosts_updated: 'Die Hosts-Datei wurde aktualisiert.',\n  hour: 'Stunde',\n  hours: 'Stunden',\n  http_api_on: 'HTTP-API eingeschaltet',\n  http_api_on_desc:\n    'Läuft auf Port {0}, kann von Software von Drittanbietern wie Alfred verwendet werden, um den Host zu wechseln.',\n  http_api_only_local: 'HTTP-API hört nur auf 127.0.0.1',\n  ignore_case: 'Groß- und Kleinschreibung ignorieren',\n  import: 'Importieren',\n  import_done: 'Der Import ist abgeschlossen.',\n  import_fail: 'Der Import ist fehlgeschlagen!',\n  import_from_url: 'Importieren von URL',\n  is_latest_version_inform: 'Super, Sie haben die neueste Version!',\n  check_update_failed: 'Suche nach Updates fehlgeschlagen!',\n  update_download_now: 'Update herunterladen',\n  update_install_now: 'Installieren und neu starten',\n  update_downloading_desc: 'Version {0} wird heruntergeladen: {1}',\n  update_ready_desc: 'Version {0} wurde heruntergeladen und kann jetzt installiert werden.',\n  item_found: '{0} Einträge gefunden.',\n  items: 'items',\n  items_found: '{0} Einträge gefunden.',\n  language: 'Sprache',\n  last_refresh: 'Letzte Aktualisierung: ',\n  latest_version_desc: 'Die neueste Version ist: {0}',\n  line: 'Zeile',\n  lines: 'Zeilen',\n  loading: 'Loading...',\n  local: 'Lokal',\n  match: 'Übereinstimmung',\n  migrate_confirm:\n    'SwitchHosts v4.0 verwendet ein neues Datenspeicherformat, möchten Sie alte Daten in das neue Format migrieren?',\n  migrate_data: 'Daten migrieren',\n  minimize: 'Minimieren',\n  minute: 'Minute',\n  minutes: 'Minuten',\n  move_items_to_trashcan: 'Verschiebe {0} Objekte in den Mülleimer',\n  move_to_trashcan: 'In die Mülltonne verschieben',\n  multi_chose_folder_switch_all: 'Mehrfachauswahl-Ordnerschalter zur Steuerung von Unterelementen',\n  need_to_relaunch: 'Muss neu gestartet werden',\n  need_to_relaunch_after_setting_changed:\n    'Die Einstellungen wurden geändert und werden erst nach einem Neustart der App wirksam.',\n  never: 'Niemals',\n  new: 'Neu',\n  new_version_found: 'Neue Version gefunden',\n  next: 'Nächste',\n  no_access_to_hosts: 'Keine Berechtigung zum Schreiben in die Hosts-Datei.',\n  no_record: 'Kein Datensatz',\n  overwrite: 'Überschreiben',\n  password: 'Passwort',\n  paste: 'Einfügen',\n  port: 'Anschluss',\n  preferences: 'Präferenzen',\n  previous: 'Vorhergehend',\n  protocol: 'Protokoll',\n  proxy: 'Proxy',\n  quit: 'Beenden',\n  read_only: 'Nur Lesen',\n  redo: 'Wiederherstellen',\n  refresh: 'Auffrischen',\n  regexp: 'Regulärer Ausdruck',\n  reload: 'Neu laden',\n  remote: 'Entfernt',\n  remove_duplicate_records: 'Doppelte Datensätze entfernen',\n  remove_duplicate_records_desc:\n    'Wenn eine Domain auf mehrere IPs verweist, wird nur die erste wirksam, die folgenden werden in Kommentare umgewandelt.',\n  replace: 'Ersetzen',\n  replace_all: 'Ersetze alle',\n  replace_history: 'Historie ersetzen',\n  reset: 'Zurücksetzen',\n  reset_data_dir_confirm:\n    'Sind Sie sicher, dass Sie den Datenordner an der Standardadresse ({0}) wiederherstellen wollen?',\n  reset_zoom: 'Zoom zurücksetzen',\n  search: 'Suchen',\n  select_all: 'Alles auswählen',\n  selected: 'Ausgewählt',\n  show_dock_icon: 'Dock-Symbol anzeigen',\n  show_history: 'Historie anzeigen',\n  show_main_window: 'Hauptfenster anzeigen',\n  show_title_on_tray: 'Titel auf dem Tablett anzeigen',\n  source_code: 'Quellcode',\n  success: 'Erfolg!',\n  sudo_prompt_title: 'Geben Sie Ihr sudo-Passwort ein',\n  system_hosts: 'System-Hosts',\n  system_hosts_history: 'Historische Versionen der System-Hosts',\n  system_hosts_history_delete_confirm: 'Sind Sie sicher, dass Sie dieses Element löschen wollen?',\n  system_hosts_history_help:\n    'Wenn die Gesamtzahl der historischen Einträge diese Grenze überschreitet, wird der älteste Eintrag gelöscht.',\n  system_hosts_history_limit: 'Maximale Anzahl von Datensätzen: ',\n  test: 'Test',\n  theme: 'Thema',\n  theme_dark: 'Dunkel',\n  theme_light: 'Hell',\n  title: 'Titel',\n  to_show_source: 'Durch Doppelklick wird der Quellcode angezeigt.',\n  toggle_developer_tools: 'Entwicklerwerkzeuge einschalten',\n  toggle_dock_icon: 'Das Dock-Symbol einschalten',\n  toggle_full_screen: 'Vollbildmodus einschalten',\n  trashcan: 'Mülleimer',\n  trashcan_clear: 'Den Mülleimer leeren',\n  trashcan_clear_confirm: 'Sind Sie sicher, dass Sie den Mülleimer leeren wollen?',\n  trashcan_delete_confirm: 'Möchten Sie dieses Objekt vollständig löschen?',\n  trashcan_restore: 'Wiederherstellen',\n  tray_mini_window: 'Taskleistensymbol-Verknüpfung',\n  undo: 'Rückgängig machen',\n  unhide: 'Einblenden',\n  untitled: 'Ohne Titel',\n  url_placeholder: 'http:// oder https:// oder file://',\n  usage_data_agree: 'Ja, übermitteln Sie anonymisierte Nutzungsdaten',\n  usage_data_help:\n    'Möchten Sie uns helfen, SwitchHosts zu verbessern, indem Sie regelmäßig anonyme Nutzungsdaten übermitteln?',\n  usage_data_title: 'Machen Sie SwitchHosts besser!',\n  use_proxy: 'Proxy verwenden',\n  use_system_window_frame:\n    'Verwenden Sie den Systemfensterrahmen, ein Neustart der Anwendung ist erforderlich',\n  view: 'Ansicht',\n  where_is_my_data: 'Wo sind meine Daten gespeichert?',\n  where_is_my_hosts: 'Wo ist meine Hosts-Datei?',\n  window: 'Fenster',\n  write_mode: 'Schreibmodus',\n  write_mode_append_help:\n    'Hängen Sie die neuen Datensätze an das Ende der Hosts-Datei des Systems an.',\n  write_mode_overwrite_help: 'Überschreibt die Systemhosts-Datei mit den neuen Datensätzen.',\n  write_mode_set: 'Schreibmodus einstellen',\n  your_data_is: 'Ihre Datendateien sind gespeichert in:',\n  your_hosts_file_is: 'Ihre Hosts-Datei befindet sich in:',\n  zoom: 'Vergrößern',\n  zoom_in: 'Vergrößern',\n  zoom_out: 'Herauszoomen',\n}\n\nexport default lang\n"
  },
  {
    "path": "src/common/i18n/languages/en.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default {\n  _app_name: 'SwitchHosts',\n  _key: 'en',\n  _name: 'English',\n  about: 'About',\n  acknowledgement: 'Acknowledgement',\n  advanced: 'Advanced',\n  all: 'All',\n  append: 'Append',\n  auto_refresh: 'Auto refresh',\n  btn_cancel: 'Cancel',\n  btn_ok: 'OK',\n  change: 'Change',\n  check_update: 'Check update',\n  choice_mode: 'Choice mode',\n  choice_mode_default: 'Default',\n  choice_mode_desc:\n    'Only valid for the topmost item, each folder can set its own choice mode.',\n  choice_mode_multiple: 'Multiple',\n  choice_mode_single: 'Single',\n  choices: 'Choices',\n  chosen: 'Chosen',\n  clear_history: 'Clear history',\n  click_to_open: 'Click to open',\n  close: 'Close',\n  colon: ': ',\n  commands: 'Commands',\n  commands_help:\n    'The following system commands will be executed when Hosts applied:',\n  commands_title: 'Command after hosts are applied',\n  comment_current_line: 'Comment current line',\n  content: 'Content',\n  copy: 'Copy',\n  cut: 'Cut',\n  day: 'day',\n  days: 'days',\n  delete: 'Delete',\n  download: 'Download',\n  edit: 'Edit',\n  export: 'Export',\n  export_done: 'The export is complete.',\n  fail: 'Fail!',\n  feedback: 'Feedback',\n  file: 'File',\n  find: 'Find',\n  find_all: 'Find all',\n  find_and_replace: 'Find and replace',\n  find_history: 'Find history',\n  folder: 'Folder',\n  front: 'Front',\n  general: 'General',\n  group: 'Group',\n  help: 'Help',\n  hide: 'Hide',\n  hide_at_launch: 'Hide at launch',\n  hide_dock_icon: 'Hide the dock icon',\n  hide_history: 'Hide history',\n  hide_others: 'Hide others',\n  homepage: 'Homepage',\n  host: 'Host',\n  hosts_add: 'Add new hosts',\n  hosts_delete: 'Delete this hosts',\n  hosts_delete_confirm: 'Are you sure you want to delete the current hosts?',\n  hosts_edit: 'Edit hosts',\n  hosts_title: 'Hosts title',\n  hosts_type: 'Hosts type',\n  hosts_updated: 'The Hosts file has been updated.',\n  hour: 'hour',\n  hours: 'hours',\n  http_api_on: 'HTTP API on',\n  http_api_on_desc:\n    'Runs on port {0}, can be used by third-party software such as Alfred to switch hosts.',\n  http_api_only_local: 'HTTP API only listen 127.0.0.1',\n  ignore_case: 'Ignore case',\n  import: 'Import',\n  import_done: 'The import is complete.',\n  import_fail: 'Import failed!',\n  import_from_url: 'Import from URL',\n  is_latest_version_inform: 'Great, you are running the latest version!',\n  check_update_failed: 'Check for updates failed!',\n  update_download_now: 'Download update',\n  update_install_now: 'Install and restart',\n  update_downloading_desc: 'Downloading version {0}: {1}',\n  update_ready_desc: 'Version {0} has been downloaded and is ready to install.',\n  item_found: '{0} item found.',\n  items: 'items',\n  items_found: '{0} items found.',\n  language: 'Language',\n  last_refresh: 'Last refresh: ',\n  latest_version_desc: 'The latest version is: {0}',\n  line: 'line',\n  lines: 'lines',\n  loading: 'Loading...',\n  local: 'Local',\n  match: 'Match',\n  migrate_confirm:\n    'SwitchHosts v4.0 uses a new data storage format, do you want to migrate old data to the new format?',\n  migrate_data: 'Migrate data',\n  minimize: 'Minimize',\n  minute: 'minute',\n  minutes: 'minutes',\n  move_items_to_trashcan: 'Move {0} items to trashcan',\n  move_to_trashcan: 'Move to trashcan',\n  multi_chose_folder_switch_all:\n    'multi-select folder switch to control sub-items',\n  need_to_relaunch: 'Need to relaunch',\n  need_to_relaunch_after_setting_changed:\n    'The setting has been changed and will take effect after the app is restarted.',\n  never: 'Never',\n  new: 'New',\n  new_version_found: 'New version found',\n  next: 'Next',\n  no_access_to_hosts: 'No permission to write to the Hosts file.',\n  no_record: 'No record',\n  overwrite: 'Overwrite',\n  password: 'Password',\n  paste: 'Paste',\n  port: 'Port',\n  preferences: 'Preferences',\n  previous: 'Previous',\n  protocol: 'Protocol',\n  proxy: 'Proxy',\n  quit: 'Quit',\n  read_only: 'Read only',\n  redo: 'Redo',\n  refresh: 'Refresh',\n  regexp: 'Regular expression',\n  reload: 'Reload',\n  remote: 'Remote',\n  remove_duplicate_records: 'Remove duplicate records',\n  remove_duplicate_records_desc:\n    'If a domain points to multiple IPs, only the first one will take effect, and the following ones will be converted into comments.',\n  replace: 'Replace',\n  replace_all: 'Replace all',\n  replace_history: 'Replace history',\n  reset: 'Reset',\n  reset_data_dir_confirm:\n    'Are you sure you want to restore the data folder to the default address ({0})?',\n  reset_zoom: 'Reset zoom',\n  search: 'Search',\n  select_all: 'Select all',\n  selected: 'Selected',\n  show_dock_icon: 'Show the dock icon',\n  show_history: 'Show history',\n  show_main_window: 'Show main window',\n  show_title_on_tray: 'Show title on tray',\n  source_code: 'Souce code',\n  success: 'Success!',\n  sudo_prompt_title: 'Input your sudo password',\n  system_hosts: 'System Hosts',\n  system_hosts_history: 'History versions of the System Hosts',\n  system_hosts_history_delete_confirm:\n    'Are you sure you want to delete this item?',\n  system_hosts_history_help:\n    'If the total number of historical records exceeds this limit, the oldest record will be deleted.',\n  system_hosts_history_limit: 'Maximum number of records: ',\n  test: 'Test',\n  theme: 'Theme',\n  theme_dark: 'Dark',\n  theme_light: 'Light',\n  title: 'Title',\n  to_show_source: 'Double-click to show the source code.',\n  toggle_developer_tools: 'Toggle Developer Tools',\n  toggle_dock_icon: 'Toggle the dock icon',\n  toggle_full_screen: 'Toggle full screen',\n  trashcan: 'Trashcan',\n  trashcan_clear: 'Empty the trashcan',\n  trashcan_clear_confirm: 'Are you sure you want to empty the trashcan?',\n  trashcan_delete_confirm: 'Do you want to delete this item completely?',\n  trashcan_restore: 'Restore',\n  tray_mini_window: 'taskbar icon shortcut',\n  undo: 'Undo',\n  unhide: 'Unhide',\n  untitled: 'Untitled',\n  url_placeholder: 'http:// or https:// or file://',\n  usage_data_agree: 'Yes, submit anonymized usage data',\n  usage_data_help:\n    'Would you like to help us improve SwitchHosts by periodically submitting anonymous usage data?',\n  usage_data_title: 'Make SwitchHosts better!',\n  use_proxy: 'Use proxy',\n  use_system_window_frame:\n    'Use system window frame, application restart is required',\n  view: 'View',\n  where_is_my_data: 'Where is my data stored?',\n  where_is_my_hosts: 'Where is my hosts file?',\n  window: 'Window',\n  write_mode: 'Write mode',\n  write_mode_append_help:\n    'Append the new records to the end of the system hosts file.',\n  write_mode_overwrite_help:\n    'Overwrite the system hosts file with the new records.',\n  write_mode_set: 'Set the write mode',\n  your_data_is: 'Your data files are stored in:',\n  your_hosts_file_is: 'Your hosts file is located at:',\n  zoom: 'Zoom',\n  zoom_in: 'Zoom in',\n  zoom_out: 'Zoom out',\n}\n"
  },
  {
    "path": "src/common/i18n/languages/fr.ts",
    "content": "/**\n * @author: Aktilor\n * @homepage: https://github.com/Aktilor\n */\n\nimport { LanguageDict } from '@common/types'\n\nconst lang: LanguageDict = {\n  _app_name: 'SwitchHosts',\n  _key: 'fr',\n  _name: 'Français',\n  about: 'À propos',\n  acknowledgement: 'Remerciements',\n  advanced: 'Avancé',\n  all: 'Tout',\n  append: 'Ajouter',\n  auto_refresh: 'Rafraîchissement automatique',\n  btn_cancel: 'Annuler',\n  btn_ok: 'OK',\n  change: 'Changer',\n  check_update: 'Vérifier les mises à jour',\n  choice_mode: 'Choice mode',\n  choice_mode_default: 'Défaut',\n  choice_mode_desc:\n    \"Uniquement valable pour l'élément le plus haut, chaque dossier peut définir son propre mode.\",\n  choice_mode_multiple: 'Multiple',\n  choice_mode_single: 'Seul',\n  choices: 'Choix',\n  chosen: 'Choisi',\n  clear_history: \"Effacer l'historique\",\n  click_to_open: 'Cliquer pour ouvrir',\n  close: 'Fermer',\n  colon: ' : ',\n  commands: 'Commandes',\n  commands_help: \"Les commandes systèmes suivantes seront exécutées quand l'hosts sera activé :\",\n  commands_title: \"Commandes une fois qu'un hosts est activé\",\n  comment_current_line: 'Commenter cette ligne',\n  content: 'Contenu',\n  copy: 'Copier',\n  cut: 'Couper',\n  day: 'jour',\n  days: 'jours',\n  delete: 'Supprimer',\n  download: 'Télécharger',\n  edit: 'Éditer',\n  export: 'Exporter',\n  export_done: \"L'export est terminé.\",\n  fail: 'Échec !',\n  feedback: 'Laisser un commentaire',\n  file: 'Fichier',\n  find: 'Rechercher',\n  find_all: 'Rechercher tout',\n  find_and_replace: 'Rechercher et remplacer',\n  find_history: 'Historique des recherches',\n  folder: 'Dossier',\n  front: 'Front',\n  general: 'Général',\n  group: 'Groupe',\n  help: 'Aide',\n  hide: 'Cacher',\n  hide_at_launch: 'Cacher au lancement',\n  hide_dock_icon: \"Cacher l'icone dans le Dock\",\n  hide_history: \"Cacher l'historique\",\n  hide_others: 'Cacher les autres',\n  homepage: \"Page d'accueil\",\n  host: 'Host',\n  hosts_add: 'Ajouter un nouvel hosts',\n  hosts_delete: 'Supprimer cet hosts',\n  hosts_delete_confirm: 'Êtes-vous sûr de vouloir supprimer cet hosts?',\n  hosts_edit: \"Éditer l'hosts\",\n  hosts_title: \"Titre de l'hosts\",\n  hosts_type: \"Type d'hosts\",\n  hosts_updated: 'Le fichier hosts a été mis à jour',\n  hour: 'heure',\n  hours: 'heures',\n  http_api_on: 'Activer HTTP API',\n  http_api_on_desc:\n    \"Actif sur le port {0}, peut être utilisé par un logiciel tier comme Alfred pour changer d'hosts\",\n  http_api_only_local: \"L'API HTTP n'écoute que sur 127.0.0.1\",\n  ignore_case: 'Ignorer la casse',\n  import: 'Importer',\n  import_done: \"L'importation est terminée\",\n  import_fail: \"Échec de l'importation !\",\n  import_from_url: \"Importer à partir d'une URL\",\n  is_latest_version_inform: 'Super, vous avez la dernière version !',\n  check_update_failed: 'La vérification des mises à jour a échoué !',\n  update_download_now: 'Télécharger la mise à jour',\n  update_install_now: 'Installer et redémarrer',\n  update_downloading_desc: 'Téléchargement de la version {0} : {1}',\n  update_ready_desc: 'La version {0} a été téléchargée et est prête à être installée.',\n  item_found: '{0} élément trouvé.',\n  items: 'éléments',\n  items_found: '{0} éléments trouvés.',\n  language: 'Langage',\n  last_refresh: 'Dernier rafraîchissement : ',\n  latest_version_desc: 'La dernière version est : {0}',\n  line: 'ligne',\n  lines: 'lignes',\n  loading: 'Chargement...',\n  local: 'Local',\n  match: 'Correspondance',\n  migrate_confirm:\n    'SwitchHosts v4.0 utilise un nouveau format de stockage des données, voulez-vous migrer les anciennes données dans ce nouveau format ?',\n  migrate_data: 'Migrer les données',\n  minimize: 'Réduire',\n  minute: 'minute',\n  minutes: 'minutes',\n  move_items_to_trashcan: 'Déplacer {0} éléments dans la corbeille',\n  move_to_trashcan: 'Déplacer dans la corbeille',\n  multi_chose_folder_switch_all:\n    'Commutateur de dossier à sélection multiple pour contrôler les sous-éléments',\n  need_to_relaunch: 'Besoin de redémarrer',\n  need_to_relaunch_after_setting_changed:\n    \"Le paramètre a été modifié et prendra effet après le redémarrage de l'application.\",\n  never: 'Jamais',\n  new: 'Nouveau',\n  new_version_found: 'Nouvelle version trouvée',\n  next: 'Suivant',\n  no_access_to_hosts: 'Aucune autorisation pour écrire dans le fichier hosts.',\n  no_record: 'Aucun enregistrement',\n  overwrite: 'Écraser',\n  password: 'Mot de passe',\n  paste: 'Coller',\n  port: 'Port',\n  preferences: 'Préférences',\n  previous: 'Précédent',\n  protocol: 'Protocol',\n  proxy: 'Proxy',\n  quit: 'Quitter',\n  read_only: 'Lecture seule',\n  redo: 'Rétablir',\n  refresh: 'Rafraîchir',\n  regexp: 'Expression régulière',\n  reload: 'Recharger',\n  remote: 'Distant',\n  remove_duplicate_records: 'Supprimer les enregistrements doublons',\n  remove_duplicate_records_desc:\n    'Si un domaine pointe sur plusieurs IPs, seulement la première sera prise en compte, et les autres seront converties en commentaires.',\n  replace: 'Remplacer',\n  replace_all: 'Tout remplacer',\n  replace_history: \"Remplacer l'historique\",\n  reset: 'Réinitialiser',\n  reset_data_dir_confirm:\n    \"Êtes-vous sûr de vouloir réinitialiser le dossier de données à l'adresse par défaut?({0})?\",\n  reset_zoom: 'Réinitialiser le zoom',\n  search: 'Rechercher',\n  select_all: 'Tout sélectionner',\n  selected: 'Sélectionné',\n  show_dock_icon: \"Afficher l'icone dans le Dock\",\n  show_history: \"Afficher l'historique\",\n  show_main_window: 'Afficher la fenêtre principale',\n  show_title_on_tray: 'Afficher le titre dans la barre des menus',\n  source_code: 'Code source',\n  success: 'Succès !',\n  sudo_prompt_title: 'Entrez votre mot de passe sudo',\n  system_hosts: 'Hosts du système',\n  system_hosts_history: 'Historique des versions hosts du système',\n  system_hosts_history_delete_confirm: 'Êtes-vous sûr de vouloir supprimer cet élément ?',\n  system_hosts_history_help:\n    \"Si le nombre total d'enregistrements dépasse cette limite, l'enregistrement le plus ancien sera supprimé.\",\n  system_hosts_history_limit: \"Nombre max. d'enregistrements : \",\n  test: 'Test',\n  theme: 'Thème',\n  theme_dark: 'Sombre',\n  theme_light: 'Clair',\n  title: 'Titre',\n  to_show_source: 'Double-cliquez pour afficher le code source',\n  toggle_developer_tools: 'Afficher/Cacher le Developer Tools',\n  toggle_dock_icon: \"Afficher/Cacher l'icone dans le Dock\",\n  toggle_full_screen: 'Activer/Désactiver le plein écran',\n  trashcan: 'Corbeille',\n  trashcan_clear: 'Vider la corbeille',\n  trashcan_clear_confirm: 'Êtes-vous sûr de vouloir vider la corbeille ?',\n  trashcan_delete_confirm: 'Voulez-vous supprimer définitivement cet élément ?',\n  trashcan_restore: 'Restaurer',\n  tray_mini_window: \"raccourci de l'icône de la barre des tâches\",\n  undo: 'Annuler',\n  unhide: 'Démasquer',\n  untitled: 'Sans titre',\n  url_placeholder: 'http:// ou https:// ou file://',\n  usage_data_agree: \"Oui, soumettre de manière anonyme mes données d'utilisation\",\n  usage_data_help:\n    \"Voulez-vous nous aider à améliorer SwitchHosts en soumettant périodiquement vos données d'utilisation de manière anonyme ?\",\n  usage_data_title: 'Rendez SwitchHosts meilleur !',\n  use_proxy: 'Utiliser un proxy',\n  use_system_window_frame:\n    \"Utiliser le cadre de la fenêtre système, le redémarrage de l'application est requis\",\n  view: 'Vue',\n  where_is_my_data: 'Où sont stockées mes données ?',\n  where_is_my_hosts: 'Où est mon fichier hosts ?',\n  window: 'Fenêtre',\n  write_mode: \"Mode d'écriture\",\n  write_mode_append_help:\n    \"Ajoutez les nouveaux enregistrements à la fin du fichier d'hôtes système.\",\n  write_mode_overwrite_help:\n    \"Écrasez le fichier d'hôtes système avec les nouveaux enregistrements.\",\n  write_mode_set: \"Définir le mode d'écriture\",\n  your_data_is: 'Les fichiers contenant vos données sont stockés ici :',\n  your_hosts_file_is: 'Votre fichier hosts est situé ici :',\n  zoom: 'Zoom',\n  zoom_in: 'Zoommer',\n  zoom_out: 'Dézoommer',\n}\n\nexport default lang\n"
  },
  {
    "path": "src/common/i18n/languages/ja.ts",
    "content": "/**\n * @author: kamatte\n * @homepage: https://kamatte.me\n */\n\nimport { LanguageDict } from '@common/types'\n\nconst lang: LanguageDict = {\n  _app_name: 'SwitchHosts',\n  _key: 'ja',\n  _name: '日本語',\n  about: 'SwitchHosts について',\n  acknowledgement: '謝辞',\n  advanced: '詳細設定',\n  all: 'すべて',\n  append: '追記',\n  auto_refresh: '自動更新',\n  btn_cancel: 'キャンセル',\n  btn_ok: 'OK',\n  change: '変更',\n  check_update: 'アップデートを確認',\n  choice_mode: '選択モード',\n  choice_mode_default: 'デフォルト',\n  choice_mode_desc:\n    '最上位階層のhostsにのみ有効で、各フォルダーでは独自に選択モードを設定できます。',\n  choice_mode_multiple: '複数',\n  choice_mode_single: '単一',\n  choices: '選択',\n  chosen: '選択済み',\n  clear_history: '履歴をクリア',\n  click_to_open: 'クリックして開く',\n  close: '閉じる',\n  colon: ': ',\n  commands: 'コマンド',\n  commands_help: 'hostsが適用されたとき、以下のシステムコマンドを実行します:',\n  commands_title: 'hosts適用後のコマンド',\n  comment_current_line: '現在の行をコメントアウト',\n  content: '内容',\n  copy: 'コピー',\n  cut: '切り取り',\n  day: '日',\n  days: '日',\n  delete: '削除',\n  download: 'ダウンロード',\n  edit: '編集',\n  export: 'エクスポート',\n  export_done: 'エクスポートが完了しました。',\n  fail: '失敗',\n  feedback: 'フィードバック',\n  file: 'ファイル',\n  find: '検索',\n  find_all: 'すべて検索',\n  find_and_replace: '検索と置換',\n  find_history: '検索履歴',\n  folder: 'フォルダー',\n  front: '前面',\n  general: '一般',\n  group: 'グループ',\n  help: 'ヘルプ',\n  hide: '非表示',\n  hide_at_launch: '起動時に非表示',\n  hide_dock_icon: 'Dockアイコンを非表示',\n  hide_history: '履歴を非表示',\n  hide_others: 'その他を非表示にする',\n  homepage: 'ホームページ',\n  host: 'ホスト',\n  hosts_add: 'hostsを追加',\n  hosts_delete: 'hostsを削除',\n  hosts_delete_confirm: 'このhostsを削除してもよろしいですか？',\n  hosts_edit: 'hostsを編集',\n  hosts_title: 'hostsタイトル',\n  hosts_type: 'hostsタイプ',\n  hosts_updated: 'hostsを更新しました。',\n  hour: '時間',\n  hours: '時間',\n  http_api_on: 'HTTP APIを有効化',\n  http_api_on_desc:\n    '{0}番ポートで実行され、Alfredなどのサードパーティソフトウェアでhostsを切り替えるために使用できます。',\n  http_api_only_local: 'HTTP APIを 127.0.0.1 のみでリッスンする',\n  ignore_case: '大文字と小文字を区別しない',\n  import: 'インポート',\n  import_done: 'インポートが完了しました。',\n  import_fail: 'インポートに失敗しました！',\n  import_from_url: 'URLからインポート',\n  is_latest_version_inform: 'ご利用のバージョンは最新です！',\n  check_update_failed: 'アップデートの確認に失敗しました！',\n  update_download_now: '更新をダウンロード',\n  update_install_now: 'インストールして再起動',\n  update_downloading_desc: 'バージョン {0} をダウンロード中: {1}',\n  update_ready_desc: 'バージョン {0} のダウンロードが完了し、インストールできます。',\n  item_found: '{0}件見つかりました。',\n  items: '件',\n  items_found: '{0}件見つかりました。',\n  language: '言語',\n  last_refresh: '最終更新: ',\n  latest_version_desc: '最新バージョン: {0}',\n  line: '行',\n  lines: '行',\n  loading: '読み込み中...',\n  local: 'ローカル',\n  match: '一致',\n  migrate_confirm:\n    'SwitchHosts v4.0は新しいデータ保存形式を使用します。古いデータを新しい形式に移行しますか？',\n  migrate_data: 'データ移行',\n  minimize: '最小化',\n  minute: '分',\n  minutes: '分',\n  move_items_to_trashcan: '{0}件をごみ箱に入れる',\n  move_to_trashcan: 'ゴミ箱に入れる',\n  multi_chose_folder_switch_all: 'フォルダーの切り替えで配下のアイテムを一括操作',\n  need_to_relaunch: '再起動が必要です',\n  need_to_relaunch_after_setting_changed:\n    '変更された設定はアプリケーションの再起動後に有効になります。',\n  never: 'なし',\n  new: '新規',\n  new_version_found: '新しいバージョンが見つかりました',\n  next: '次へ',\n  no_access_to_hosts: 'hostsファイルの書き込み権限がありません。',\n  no_record: 'なし',\n  overwrite: '上書き',\n  password: 'パスワード',\n  paste: '貼り付け',\n  port: 'ポート',\n  preferences: '設定',\n  previous: '前へ',\n  protocol: 'プロトコル',\n  proxy: 'プロキシ',\n  quit: '終了',\n  read_only: '読み取り専用',\n  redo: 'やり直し',\n  refresh: '更新',\n  regexp: '正規表現',\n  reload: '再読み込み',\n  remote: 'リモート',\n  remove_duplicate_records: '重複レコードを削除',\n  remove_duplicate_records_desc:\n    '1つのドメインに複数のIPアドレスを指定している場合、先頭のIPアドレスのみが有効になり、以降のIPアドレスはコメントに変換されます。',\n  replace: '置換',\n  replace_all: 'すべて置換',\n  replace_history: '置換履歴',\n  reset: 'リセット',\n  reset_data_dir_confirm: 'データフォルダーの場所をデフォルト ({0}) に戻してもよろしいですか？',\n  reset_zoom: 'ズームをリセット',\n  search: '検索',\n  select_all: 'すべて選択',\n  selected: '選択済み',\n  show_dock_icon: 'Dockアイコンを表示',\n  show_history: '履歴を表示',\n  show_main_window: 'メインウィンドウを表示',\n  show_title_on_tray: 'トレイにタイトルを表示',\n  source_code: 'ソースコード',\n  success: '成功',\n  sudo_prompt_title: '管理者パスワードを入力してください',\n  system_hosts: 'システムhosts',\n  system_hosts_history: 'システムhostsのバージョン履歴',\n  system_hosts_history_delete_confirm: 'この履歴を削除してもよろしいですか？',\n  system_hosts_history_help: '履歴件数がこれを超えると、最も古い履歴が削除されます。',\n  system_hosts_history_limit: '履歴の最大件数: ',\n  test: 'テスト',\n  theme: 'テーマ',\n  theme_dark: 'ダーク',\n  theme_light: 'ライト',\n  title: 'タイトル',\n  to_show_source: 'ダブルクリックでソースコードを表示する。',\n  toggle_developer_tools: '開発者ツールの表示/非表示',\n  toggle_dock_icon: 'Dockアイコンの表示/非表示',\n  toggle_full_screen: 'フルスクリーン',\n  trashcan: 'ゴミ箱',\n  trashcan_clear: 'ゴミ箱を空にする',\n  trashcan_clear_confirm: 'ゴミ箱を空にしてもよろしいですか？',\n  trashcan_delete_confirm: 'この項目を完全に削除しますか？',\n  trashcan_restore: '戻す',\n  tray_mini_window: 'タスクバーアイコンショートカット',\n  undo: '元に戻す',\n  unhide: 'すべて表示',\n  untitled: '無題',\n  url_placeholder: 'http:// または https:// または file://',\n  usage_data_agree: 'はい、匿名の利用データを送信します。',\n  usage_data_help: '匿名の利用データを定期的に送信し、SwitchHostsの改善にご協力いただけませんか？',\n  usage_data_title: 'SwitchHostsの改善に協力する',\n  use_proxy: 'プロキシを使用',\n  use_system_window_frame:\n    'システムのウィンドウフレームを使用。アプリケーションの再起動が必要です。',\n  view: '表示',\n  where_is_my_data: 'データはどこに保存されますか？',\n  where_is_my_hosts: 'hostsファイルはどこにありますか？',\n  window: 'ウィンドウ',\n  write_mode: '書き込みモード',\n  write_mode_append_help: '新しいレコードをシステムhostsの末尾に追記します。',\n  write_mode_overwrite_help: '新しいレコードでシステムhostsを上書きします。',\n  write_mode_set: '書き込みモードを設定',\n  your_data_is: 'あなたのデータファイルはこちらに保存されています:',\n  your_hosts_file_is: 'あなたのhostsファイルはこちらにあります:',\n  zoom: 'ズーム',\n  zoom_in: '拡大',\n  zoom_out: '縮小',\n}\n\nexport default lang\n"
  },
  {
    "path": "src/common/i18n/languages/ko.ts",
    "content": "/**\n * @author: wooklab\n */\n\nexport default {\n  _app_name: 'SwitchHosts',\n  _key: 'ko',\n  _name: '한국어',\n  about: '정보',\n  acknowledgement: '승인',\n  advanced: '고급',\n  all: '전체',\n  append: '추가',\n  auto_refresh: '자동 새로고침',\n  btn_cancel: '취소',\n  btn_ok: '확인',\n  change: '수정',\n  check_update: '업데이트 확인',\n  choice_mode: '선택 모드',\n  choice_mode_default: '기본값',\n  choice_mode_desc:\n    '최상위 항목에만 유효하며, 각 폴더는 고유의 선택 모드를 설정할 수 있습니다.',\n  choice_mode_multiple: '다중모드',\n  choice_mode_single: '단일모드',\n  choices: '선택',\n  chosen: '선택됨',\n  clear_history: '이력 삭제',\n  click_to_open: '클릭하여 열기',\n  close: '닫기',\n  colon: ': ',\n  commands: '명령어',\n  commands_help:\n    '호스트를 적용하면 시스템에 명령어가 실행됩니다:',\n  commands_title: '호스트가 적용된 후 명령어',\n  comment_current_line: '주석 현재 줄',\n  content: '내용',\n  copy: '복사',\n  cut: '자르기',\n  day: '일',\n  days: '일',\n  delete: '삭제',\n  download: '다운로드',\n  edit: '수정',\n  export: '내보내기',\n  export_done: '내보내기가 완료되었습니다.',\n  fail: '실패!',\n  feedback: '피드백',\n  file: '파일',\n  find: '찾기',\n  find_all: '전체 찾기',\n  find_and_replace: '찾기 및 바꾸기',\n  find_history: '이력 찾기',\n  folder: '폴더',\n  front: '앞쪽',\n  general: '일반',\n  group: '그룹',\n  help: '도움말',\n  hide: '숨기기',\n  hide_at_launch: '시작 시 숨기기',\n  hide_dock_icon: '독(Dock) 아이콘 숨기기',\n  hide_history: '이력 숨기기',\n  hide_others: '다른 것 숨기기',\n  homepage: '홈페이지',\n  host: '호스트',\n  hosts_add: '새로운 호스트 추가',\n  hosts_delete: '이 호스트 삭제',\n  hosts_delete_confirm: '현재 호스트를 삭제하시겠습니까?',\n  hosts_edit: '호스트 수정',\n  hosts_title: '호스트 제목',\n  hosts_type: '호스트 유형',\n  hosts_updated: '호스트 파일이 갱신되었습니다.',\n  hour: '시간',\n  hours: '시간',\n  http_api_on: 'HTTP API 사용',\n  http_api_on_desc:\n    '포트 {0}에서 실행되며, Alfred와 같은 서드파티 소프트웨어를 통해 호스트를 전환하는데 사용할 수 있습니다.',\n  http_api_only_local: 'HTTP API 127.0.0.1만 수신',\n  ignore_case: '대소문자 무시',\n  import: '가져오기',\n  import_done: '가져오기가 완료되었습니다.',\n  import_fail: '가져오기 실패!',\n  import_from_url: 'URL에서 가져오기',\n  is_latest_version_inform: '좋아요, 최신 버전을 실행 중입니다.!',\n  check_update_failed: '업데이트 확인에 실패했습니다!',\n  update_download_now: '업데이트 다운로드',\n  update_install_now: '설치 후 다시 시작',\n  update_downloading_desc: '버전 {0} 다운로드 중: {1}',\n  update_ready_desc: '버전 {0} 다운로드가 완료되었으며 설치할 수 있습니다.',\n  item_found: '{0}개의 항목을 찾았습니다.',\n  items: '항목',\n  items_found: '{0}개의 항목들을 찾았습니다.',\n  language: '언어',\n  last_refresh: '마지막 새로고침: ',\n  latest_version_desc: '최신버전: {0}',\n  line: '줄',\n  lines: '줄들',\n  loading: '로딩중...',\n  local: '로컬',\n  match: '일치',\n  migrate_confirm:\n    'SwitchHosts v4.0는 새로운 데이터 저장 형식을 사용합니다. 이전 데이터를 새 형식으로 마이그레이션(이전)하시겠습니까?',\n  migrate_data: '데이터 이전',\n  minimize: '최소화',\n  minute: '분',\n  minutes: '분',\n  move_items_to_trashcan: '{0}개의 항목이 휴지통으로 이동',\n  move_to_trashcan: '휴지통으로 이동',\n  multi_chose_folder_switch_all:\n    '하위 항목을 제어할 다중 선택 폴더 스위치',\n  need_to_relaunch: '다시 시작해야 함',\n  need_to_relaunch_after_setting_changed:\n    '설정이 변경되었으며 앱을 다시 시작한 후에 적용됩니다.',\n  never: '절대',\n  new: '신규',\n  new_version_found: '새 버전을 찾았습니다',\n  next: '다음',\n  no_access_to_hosts: '호스트 파일 쓰기 권한이 없습니다.',\n  no_record: '레코드 없음',\n  overwrite: '덮어쓰기',\n  password: '패스워드',\n  paste: '붙여넣기',\n  port: '포트',\n  preferences: '설정',\n  previous: '이전',\n  protocol: '프로토콜',\n  proxy: '프록시',\n  quit: '종료',\n  read_only: '읽기 전용',\n  redo: '재실행',\n  refresh: '새로고침',\n  regexp: '정규식',\n  reload: '새로고침',\n  remote: '리모트',\n  remove_duplicate_records: '중복 레코드 삭제',\n  remove_duplicate_records_desc:\n    '만약 도메인이 여러 IP를 가리키는 경우, 첫 번째 IP만 적용되며, 나머지는 주석처리됩니다.',\n  replace: '대체하기',\n  replace_all: '전체 대체하기',\n  replace_history: '이력 교체',\n  reset: '재설정',\n  reset_data_dir_confirm:\n    '이 데이터 폴더를 기본 경로({0})로 복원하시겠습니까?',\n  reset_zoom: '확대 재설정',\n  search: '찾기',\n  select_all: '전체 선택',\n  selected: '선택',\n  show_dock_icon: '독(Dock) 아이콘 보기',\n  show_history: '이력 보기',\n  show_main_window: '메인 창 보기',\n  show_title_on_tray: '트레이에 제목 표시',\n  source_code: '소스코드',\n  success: '성공!',\n  sudo_prompt_title: 'sudo password 입력',\n  system_hosts: '시스템 호스트',\n  system_hosts_history: '시스템 호스트의 이력버전',\n  system_hosts_history_delete_confirm:\n    '이 항목을 삭제하시겠습니까?',\n  system_hosts_history_help:\n    '이력 개수가 이 제한을 초과하면 가장 오래된 이력부터 삭제됩니다.',\n  system_hosts_history_limit: '이력 최대 개수: ',\n  test: '테스트',\n  theme: '테마',\n  theme_dark: '다크',\n  theme_light: '라이트',\n  title: '제목',\n  to_show_source: '더블 클릭하여 소스코드를 표시합니다.',\n  toggle_developer_tools: '개발자 도구 전환',\n  toggle_dock_icon: '독(DocK) 아이콘 전환',\n  toggle_full_screen: '전체화면 전환',\n  trashcan: '휴지통',\n  trashcan_clear: '휴지통 비우기',\n  trashcan_clear_confirm: '휴지통을 비우시겠습니까?',\n  trashcan_delete_confirm: '이 항목을 완전히 삭제하시겠습니까?',\n  trashcan_restore: '복구',\n  tray_mini_window: '작업 표시줄 아이콘 바로가기',\n  undo: '실행취소',\n  unhide: '숨김해제',\n  untitled: '제목없음',\n  url_placeholder: 'http:// or https:// or file://',\n  usage_data_agree: '익명화된 사용 데이터 제출에 동의합니다',\n  usage_data_help:\n    '주기적으로 익명의 사용 데이터를 제출하여 SwitchHost를 개선하는 데 도움을 주시겠습니까?',\n  usage_data_title: 'SwitchHosts개선!',\n  use_proxy: '프록시 사용',\n  use_system_window_frame:\n    '시스템 창을 사용하려면, 프로그램 재시작이 필요합니다',\n  view: '뷰',\n  where_is_my_data: '내 데이터는 어디에 저장되나요?',\n  where_is_my_hosts: '내 호스트 파일은 어디에 있나요?',\n  window: '창',\n  write_mode: '쓰기 모드',\n  write_mode_append_help:\n    '시스템 호스트 파일 마지막 줄에 새 레코드를 추가합니다.',\n  write_mode_overwrite_help:\n    '시스템 호스트 파일에 새 레코드로 덮어씁니다.',\n  write_mode_set: '쓰기 모드 설정',\n  your_data_is: '데이터 저장 위치:',\n  your_hosts_file_is: '호스트 파일 위치:',\n  zoom: '확대',\n  zoom_in: '확대',\n  zoom_out: '축소',\n}\n"
  },
  {
    "path": "src/common/i18n/languages/pl.ts",
    "content": "/**\n * @author: piteriuz\n * @homepage: https://piotr.pienkowski.pl/\n */\n\nexport default {\n  _app_name: 'SwitchHosts',\n  _key: 'pl',\n  _name: 'Polski',\n  about: 'O aplikacji',\n  acknowledgement: 'Podziękowania',\n  advanced: 'Zaawansowane',\n  all: 'Wszystko',\n  append: 'Dołącz',\n  auto_refresh: 'Automatyczne odświeżanie',\n  btn_cancel: 'Anuluj',\n  btn_ok: 'OK',\n  change: 'Zmień',\n  check_update: 'Sprawdź aktualizacje',\n  choice_mode: 'Tryb wyboru',\n  choice_mode_default: 'Domyślny',\n  choice_mode_desc: 'Obowiązuje tylko dla elementu na górze, każdy folder może mieć własny tryb wyboru.',\n  choice_mode_multiple: 'Wiele',\n  choice_mode_single: 'Jeden',\n  choices: 'Wybory',\n  chosen: 'Wybrane',\n  clear_history: 'Wyczyść historię',\n  click_to_open: 'Kliknij, aby otworzyć',\n  close: 'Zamknij',\n  colon: ': ',\n  commands: 'Polecenia',\n  commands_help: 'Poniższe polecenia systemowe będą wykonane po zastosowaniu Hosts:',\n  commands_title: 'Polecenie po zastosowaniu hosts',\n  comment_current_line: 'Skomentuj bieżącą linię',\n  content: 'Zawartość',\n  copy: 'Kopiuj',\n  cut: 'Wytnij',\n  day: 'dzień',\n  days: 'dni',\n  delete: 'Usuń',\n  download: 'Pobierz',\n  edit: 'Edytuj',\n  export: 'Eksportuj',\n  export_done: 'Eksport został ukończony.',\n  fail: 'Błąd!',\n  feedback: 'Opinia',\n  file: 'Plik',\n  find: 'Znajdź',\n  find_all: 'Znajdź wszystkie',\n  find_and_replace: 'Znajdź i zamień',\n  find_history: 'Historia wyszukiwania',\n  folder: 'Folder',\n  front: 'Przód',\n  general: 'Ogólne',\n  group: 'Grupa',\n  help: 'Pomoc',\n  hide: 'Ukryj',\n  hide_at_launch: 'Ukryj przy uruchomieniu',\n  hide_dock_icon: 'Ukryj ikonę docka',\n  hide_history: 'Ukryj historię',\n  hide_others: 'Ukryj inne',\n  homepage: 'Strona główna',\n  host: 'Host',\n  hosts_add: 'Dodaj nowe hosty',\n  hosts_delete: 'Usuń ten hosts',\n  hosts_delete_confirm: 'Czy na pewno chcesz usunąć bieżące hosty?',\n  hosts_edit: 'Edytuj hosty',\n  hosts_title: 'Nazwa hosts',\n  hosts_type: 'Typ hosts',\n  hosts_updated: 'Plik Hosts został zaktualizowany.',\n  hour: 'godzina',\n  hours: 'godziny',\n  http_api_on: 'HTTP API włączone',\n  http_api_on_desc: 'Działa na porcie {0}, może być używane przez oprogramowanie stron trzecich, takie jak Alfred do przełączania hostów.',\n  http_api_only_local: 'HTTP API nasłuchuje tylko na 127.0.0.1',\n  ignore_case: 'Ignoruj wielkość liter',\n  import: 'Importuj',\n  import_done: 'Import został ukończony.',\n  import_fail: 'Import nie powiódł się!',\n  import_from_url: 'Importuj z adresu URL',\n  is_latest_version_inform: 'Świetnie, masz najnowszą wersję!',\n  check_update_failed: 'Sprawdzanie aktualizacji nie powiodło się!',\n  update_download_now: 'Pobierz aktualizację',\n  update_install_now: 'Zainstaluj i uruchom ponownie',\n  update_downloading_desc: 'Pobieranie wersji {0}: {1}',\n  update_ready_desc: 'Wersja {0} została pobrana i jest gotowa do instalacji.',\n  item_found: 'Znaleziono {0} element.',\n  items: 'elementy',\n  items_found: 'Znaleziono {0} elementów.',\n  language: 'Język',\n  last_refresh: 'Ostatnie odświeżenie: ',\n  latest_version_desc: 'Najnowsza wersja to: {0}',\n  line: 'linia',\n  lines: 'linie',\n  loading: 'Ładowanie...',\n  local: 'Lokalny',\n  match: 'Dopasuj',\n  migrate_confirm: 'SwitchHosts v4.0 używa nowego formatu przechowywania danych, czy chcesz migrować stare dane do nowego formatu?',\n  migrate_data: 'Migruj dane',\n  minimize: 'Minimalizuj',\n  minute: 'minuta',\n  minutes: 'minuty',\n  move_items_to_trashcan: 'Przenieś {0} elementy do kosza',\n  move_to_trashcan: 'Przenieś do kosza',\n  multi_chose_folder_switch_all: 'wielokrotny wybór folderu do kontroli podelementów',\n  need_to_relaunch: 'Wymagane ponowne uruchomienie',\n  need_to_relaunch_after_setting_changed: 'Ustawienie zostało zmienione i wejdzie w życie po ponownym uruchomieniu aplikacji.',\n  never: 'Nigdy',\n  new: 'Nowy',\n  new_version_found: 'Znaleziono nową wersję',\n  next: 'Dalej',\n  no_access_to_hosts: 'Brak uprawnień do zapisu w pliku Hosts.',\n  no_record: 'Brak rekordu',\n  overwrite: 'Nadpisz',\n  password: 'Hasło',\n  paste: 'Wklej',\n  port: 'Port',\n  preferences: 'Preferencje',\n  previous: 'Wstecz',\n  protocol: 'Protokół',\n  proxy: 'Proxy',\n  quit: 'Zamknij',\n  read_only: 'Tylko do odczytu',\n  redo: 'Powtórz',\n  refresh: 'Odśwież',\n  regexp: 'Wyrażenie regularne',\n  reload: 'Załaduj ponownie',\n  remote: 'Zdalny',\n  remove_duplicate_records: 'Usuń zduplikowane rekordy',\n  remove_duplicate_records_desc: 'Jeśli domena wskazuje na wiele adresów IP, tylko pierwszy będzie obowiązywać, a pozostałe zostaną skonwertowane na komentarze.',\n  replace: 'Zamień',\n  replace_all: 'Zamień wszystko',\n  replace_history: 'Historia zamiany',\n  reset: 'Resetuj',\n  reset_data_dir_confirm: 'Czy na pewno chcesz przywrócić folder danych do adresu domyślnego ({0})?',\n  reset_zoom: 'Resetuj powiększenie',\n  search: 'Szukaj',\n  select_all: 'Zaznacz wszystko',\n  selected: 'Wybrane',\n  show_dock_icon: 'Pokaż ikonę docka',\n  show_history: 'Pokaż historię',\n  show_main_window: 'Pokaż główne okno',\n  show_title_on_tray: 'Pokaż tytuł na pasku zadań',\n  source_code: 'Kod źródłowy',\n  success: 'Sukces!',\n  sudo_prompt_title: 'Wpisz swoje hasło sudo',\n  system_hosts: 'System Hosts',\n  system_hosts_history: 'Historyczne wersje System Hosts',\n  system_hosts_history_delete_confirm: 'Czy na pewno chcesz usunąć ten element?',\n  system_hosts_history_help: 'Jeśli całkowita liczba rekordów historycznych przekroczy ten limit, najstarszy rekord zostanie usunięty.',\n  system_hosts_history_limit: 'Maksymalna liczba rekordów: ',\n  test: 'Test',\n  theme: 'Motyw',\n  theme_dark: 'Ciemny',\n  theme_light: 'Jasny',\n  title: 'Tytuł',\n  to_show_source: 'Kliknij dwukrotnie, aby wyświetlić kod źródłowy.',\n  toggle_developer_tools: 'Przełącz narzędzia deweloperskie',\n  toggle_dock_icon: 'Przełącz ikonę docka',\n  toggle_full_screen: 'Przełącz pełny ekran',\n  trashcan: 'Kosz',\n  trashcan_clear: 'Opróżnij kosz',\n  trashcan_clear_confirm: 'Czy na pewno chcesz opróżnić kosz?',\n  trashcan_delete_confirm: 'Czy chcesz całkowicie usunąć ten element?',\n  trashcan_restore: 'Przywróć',\n  tray_mini_window: 'skrót ikony paska zadań',\n  undo: 'Cofnij',\n  unhide: 'Pokaż',\n  untitled: 'Bez tytułu',\n  url_placeholder: 'http:// lub https:// lub file://',\n  usage_data_agree: 'Tak, prześlij anonimowe dane użytkowania',\n  usage_data_help: 'Czy chcesz nam pomóc ulepszyć SwitchHosts, okresowo przesyłając anonimowe dane użytkowania?',\n  usage_data_title: 'Uczynić SwitchHosts lepszym!',\n  use_proxy: 'Użyj proxy',\n  use_system_window_frame: 'Używaj systemowych ramek okna, wymagane ponowne uruchomienie aplikacji',\n  view: 'Widok',\n  where_is_my_data: 'Gdzie są przechowywane moje dane?',\n  where_is_my_hosts: 'Gdzie znajduje się mój plik hosts?',\n  window: 'Okno',\n  write_mode: 'Tryb zapisu',\n  write_mode_append_help: 'Dołącz nowe rekordy na koniec systemowego pliku hosts.',\n  write_mode_overwrite_help: 'Nadpisz plik hosts systemu nowymi rekordami.',\n  write_mode_set: 'Ustaw tryb zapisu',\n  your_data_is: 'Twoje pliki danych są przechowywane w:',\n  your_hosts_file_is: 'Twój plik hosts znajduje się w:',\n  zoom: 'Powiększenie',\n  zoom_in: 'Powiększ',\n  zoom_out: 'Pomniejsz',\n}\n"
  },
  {
    "path": "src/common/i18n/languages/tr.ts",
    "content": "/**\n * @author: baris\n * @homepage: https://barisuzun.com.tr\n */\n\nexport default {\n  _app_name: 'SwitchHosts',\n  _key: 'tr',\n  _name: 'Türkçe',\n  about: 'Hakkında',\n  acknowledgement: 'Teşekkür',\n  advanced: 'Gelişmiş',\n  all: 'Tümü',\n  append: 'Ekle',\n  auto_refresh: 'Otomatik Yenile',\n  btn_cancel: 'İptal',\n  btn_ok: 'Tamam',\n  change: 'Değiştir',\n  check_update: 'Güncellemeleri Kontrol Et',\n  choice_mode: 'Seçim Modu',\n  choice_mode_default: 'Varsayılan',\n  choice_mode_desc: 'Sadece en üstteki öğe için geçerlidir, her klasör kendi seçim modunu ayarlayabilir.',\n  choice_mode_multiple: 'Çoklu',\n  choice_mode_single: 'Tekli',\n  choices: 'Seçenekler',\n  chosen: 'Seçildi',\n  clear_history: 'Geçmişi Temizle',\n  click_to_open: 'Açmak için tıkla',\n  close: 'Kapat',\n  colon: ': ',\n  commands: 'Komutlar',\n  commands_help: 'Hosts uygulandığında aşağıdaki sistem komutları çalıştırılacaktır:',\n  commands_title: 'Hosts uygulandıktan sonra komut',\n  comment_current_line: 'Mevcut satırı yorumla',\n  content: 'İçerik',\n  copy: 'Kopyala',\n  cut: 'Kes',\n  day: 'gün',\n  days: 'günler',\n  delete: 'Sil',\n  download: 'İndir',\n  edit: 'Düzenle',\n  export: 'Dışa Aktar',\n  export_done: 'Dışa aktarma tamamlandı.',\n  fail: 'Başarısız!',\n  feedback: 'Geri Bildirim',\n  file: 'Dosya',\n  find: 'Bul',\n  find_all: 'Hepsini Bul',\n  find_and_replace: 'Bul ve Değiştir',\n  find_history: 'Arama Geçmişi',\n  folder: 'Klasör',\n  front: 'Ön',\n  general: 'Genel',\n  group: 'Grup',\n  help: 'Yardım',\n  hide: 'Gizle',\n  hide_at_launch: 'Başlangıçta Gizle',\n  hide_dock_icon: 'Dock simgesini gizle',\n  hide_history: 'Geçmişi Gizle',\n  hide_others: 'Diğerlerini Gizle',\n  homepage: 'Anasayfa',\n  host: 'Host',\n  hosts_add: 'Yeni host ekle',\n  hosts_delete: 'Bu hostu sil',\n  hosts_delete_confirm: 'Mevcut hostu silmek istediğinizden emin misiniz?',\n  hosts_edit: 'Hostları düzenle',\n  hosts_title: 'Host Başlığı',\n  hosts_type: 'Host Türü',\n  hosts_updated: 'Host dosyası güncellendi.',\n  hour: 'saat',\n  hours: 'saatler',\n  http_api_on: 'HTTP API açık',\n  http_api_on_desc: '{0} portunda çalışır, Alfred gibi üçüncü parti yazılımlar tarafından hostları değiştirmek için kullanılabilir.',\n  http_api_only_local: 'HTTP API sadece 127.0.0.1’i dinler',\n  ignore_case: 'Büyük/Küçük Harf Duyarsız',\n  import: 'İçe Aktar',\n  import_done: 'İçe aktarma tamamlandı.',\n  import_fail: 'İçe aktarma başarısız!',\n  import_from_url: 'URL’den İçe Aktar',\n  is_latest_version_inform: 'Harika, en güncel sürümü kullanıyorsunuz!',\n  check_update_failed: 'Güncellemeleri kontrol etme başarısız!',\n  update_download_now: 'Güncellemeyi indir',\n  update_install_now: 'Yükle ve yeniden başlat',\n  update_downloading_desc: '{0} sürümü indiriliyor: {1}',\n  update_ready_desc: '{0} sürümü indirildi ve kuruluma hazır.',\n  item_found: '{0} öğe bulundu.',\n  items: 'öğeler',\n  items_found: '{0} öğe bulundu.',\n  language: 'Dil',\n  last_refresh: 'Son yenileme: ',\n  latest_version_desc: 'En son sürüm: {0}',\n  line: 'satır',\n  lines: 'satırlar',\n  loading: 'Yükleniyor...',\n  local: 'Yerel',\n  match: 'Eşleşme',\n  migrate_confirm:\n    'SwitchHosts v4.0 yeni bir veri depolama formatı kullanıyor, eski verileri yeni formata taşımak ister misiniz?',\n  migrate_data: 'Veri Taşı',\n  minimize: 'Küçült',\n  minute: 'dakika',\n  minutes: 'dakikalar',\n  move_items_to_trashcan: 'Çöp kutusuna {0} öğe taşı',\n  move_to_trashcan: 'Çöp Kutusuna Taşı',\n  multi_chose_folder_switch_all: 'çoklu seçim klasörü, alt öğelerin kontrolünü sağlar',\n  need_to_relaunch: 'Yeniden başlatılması gerekiyor',\n  need_to_relaunch_after_setting_changed: 'Ayar değiştirildi ve uygulama yeniden başlatıldıktan sonra etkili olacak.',\n  never: 'Asla',\n  new: 'Yeni',\n  new_version_found: 'Yeni sürüm bulundu',\n  next: 'Sonraki',\n  no_access_to_hosts: 'Hosts dosyasına yazma izni yok.',\n  no_record: 'Kayıt yok',\n  overwrite: 'Üzerine Yaz',\n  password: 'Parola',\n  paste: 'Yapıştır',\n  port: 'Port',\n  preferences: 'Tercihler',\n  previous: 'Önceki',\n  protocol: 'Protokol',\n  proxy: 'Proxy',\n  quit: 'Çıkış',\n  read_only: 'Salt Okunur',\n  redo: 'Yinele',\n  refresh: 'Yenile',\n  regexp: 'Düzenli İfade',\n  reload: 'Yeniden Yükle',\n  remote: 'Uzak',\n  remove_duplicate_records: 'Yinelenen kayıtları kaldır',\n  remove_duplicate_records_desc:\n    'Bir alan birden fazla IP\\'ye işaret ediyorsa, sadece ilk olanı etkili olacak ve sonrakiler yorum olarak dönüştürülecek.',\n  replace: 'Değiştir',\n  replace_all: 'Hepsini Değiştir',\n  replace_history: 'Geçmişi Değiştir',\n  reset: 'Sıfırla',\n  reset_data_dir_confirm: 'Veri klasörünü varsayılan adrese ({0}) geri yüklemek istediğinizden emin misiniz?',\n  reset_zoom: 'Yakınlaştırmayı Sıfırla',\n  search: 'Ara',\n  select_all: 'Hepsini Seç',\n  selected: 'Seçildi',\n  show_dock_icon: 'Dock simgesini göster',\n  show_history: 'Geçmişi Göster',\n  show_main_window: 'Ana pencereyi göster',\n  show_title_on_tray: 'Görev çubuğunda başlığı göster',\n  source_code: 'Kaynak Kod',\n  success: 'Başarılı!',\n  sudo_prompt_title: 'Sudo parolanızı girin',\n  system_hosts: 'Sistem Hostları',\n  system_hosts_history: 'Sistem Hostlarının geçmiş sürümleri',\n  system_hosts_history_delete_confirm: 'Bu öğeyi silmek istediğinizden emin misiniz?',\n  system_hosts_history_help: 'Toplam kayıt sayısı bu sınırları aşarsa, en eski kayıt silinecektir.',\n  system_hosts_history_limit: 'Maksimum kayıt sayısı: ',\n  test: 'Test',\n  theme: 'Tema',\n  theme_dark: 'Karanlık',\n  theme_light: 'Aydınlık',\n  title: 'Başlık',\n  to_show_source: 'Kaynak kodunu göstermek için çift tıklayın.',\n  toggle_developer_tools: 'Geliştirici Araçlarını Aç/Kapat',\n  toggle_dock_icon: 'Dock simgesini aç/kapat',\n  toggle_full_screen: 'Tam ekranı aç/kapat',\n  trashcan: 'Çöp Kutusu',\n  trashcan_clear: 'Çöp kutusunu boşalt',\n  trashcan_clear_confirm: 'Çöp kutusunu boşaltmak istediğinizden emin misiniz?',\n  trashcan_delete_confirm: 'Bu öğeyi tamamen silmek istiyor musunuz?',\n  trashcan_restore: 'Geri Yükle',\n  tray_mini_window: 'Görev çubuğu simgesi kısayolu',\n  undo: 'Geri Al',\n  unhide: 'Gizlemeyi Kaldır',\n  untitled: 'Başlıksız',\n  url_placeholder: 'http:// veya https:// veya file://',\n  usage_data_agree: 'Evet, anonimleştirilmiş kullanım verilerini gönder',\n  usage_data_help:\n    'Anonim kullanım verilerini periyodik olarak göndererek SwitchHosts\\'u iyileştirmemize yardımcı olmak ister misiniz?',\n  usage_data_title: 'SwitchHosts\\'u Daha İyi Yapın!',\n  use_proxy: 'Proxy Kullan',\n  use_system_window_frame: 'Sistem pencere çerçevesini kullanın, uygulamanın yeniden başlatılması gereklidir',\n  view: 'Görüntüle',\n  where_is_my_data: 'Verilerim nerede saklanıyor?',\n  where_is_my_hosts: 'Hosts dosyam nerede?',\n  window: 'Pencere',\n  write_mode: 'Yazma modu',\n  write_mode_append_help: 'Yeni kayıtları sistem hosts dosyasının sonuna ekleyin.',\n  write_mode_overwrite_help: 'Yeni kayıtlarla sistem hosts dosyasını üzerine yazın.',\n  write_mode_set: 'Yazma modunu ayarla',\n  your_data_is: 'Veri dosyalarınız şurada saklanıyor:',\n  your_hosts_file_is: 'Hosts dosyanız şu konumda bulunuyor:',\n  zoom: 'Yakınlaştır',\n  zoom_in: 'Yakınlaştır',\n  zoom_out: 'Uzaklaştır',\n}\n"
  },
  {
    "path": "src/common/i18n/languages/zh-hant.ts",
    "content": "/**\n * @author: rayatn1011\n * @homepage: https://github.com/rayatn1011\n */\n\nimport { LanguageDict } from '@common/types'\n\nconst lang: LanguageDict = {\n  _app_name: 'SwitchHosts',\n  _key: 'zh-hant',\n  _name: '中文',\n  about: '關於',\n  acknowledgement: '特別感謝',\n  advanced: '進階',\n  all: '全部',\n  append: '附加',\n  auto_refresh: '自動更新',\n  btn_cancel: '取消',\n  btn_ok: '確定',\n  change: '修改',\n  check_update: '檢查更新',\n  choice_mode: '選擇模式',\n  choice_mode_default: '預設',\n  choice_mode_desc: '只對頂層項目有效，每個資料夾可設定自己的選擇模式。',\n  choice_mode_multiple: '多選',\n  choice_mode_single: '單選',\n  choices: '選項',\n  chosen: '已選',\n  clear_history: '清除歷史紀錄',\n  click_to_open: '點擊開啟',\n  close: '關閉',\n  colon: '：',\n  commands: '指令',\n  commands_help: '每次 Hosts 應用後將執行下面的系統指令：',\n  commands_title: 'Hosts 應用後指令',\n  comment_current_line: '註解當前行',\n  content: '內容',\n  copy: '複製',\n  cut: '剪下',\n  day: '天',\n  days: '天',\n  delete: '刪除',\n  download: '下載',\n  edit: '編輯',\n  export: '匯出',\n  export_done: '匯出已完成。',\n  fail: '操作失敗！',\n  feedback: '意見回饋',\n  file: '檔案',\n  find: '尋找',\n  find_all: '尋找所有',\n  find_and_replace: '尋找並替換',\n  find_history: '尋找歷史',\n  folder: '資料夾',\n  front: '前置',\n  general: '一般',\n  group: '群組',\n  help: 'Help',\n  hide: '隱藏',\n  hide_at_launch: '啟動時隱藏',\n  hide_dock_icon: '隱藏工作列圖示',\n  hide_history: '隱藏歷史紀錄',\n  hide_others: '隱藏其他',\n  homepage: '首頁',\n  host: '主機',\n  hosts_add: '新增 hosts',\n  hosts_delete: '刪除當前方案',\n  hosts_delete_confirm: '確定要刪除當前方案嗎？',\n  hosts_edit: '編輯 hosts',\n  hosts_title: 'Hosts 標題',\n  hosts_type: 'Hosts 類型',\n  hosts_updated: 'Hosts 檔案已更新。',\n  hour: '小時',\n  hours: '小時',\n  http_api_on: '開啟 HTTP API',\n  http_api_on_desc: '運行於 {0} 通訊埠，可用於 Alfred 等第三方應用切換 hosts。',\n  http_api_only_local: 'HTTP API 僅監聽 127.0.0.1',\n  ignore_case: '忽略大小寫',\n  import: '匯入',\n  import_done: '匯入已完成。',\n  import_fail: '匯入失敗！',\n  import_from_url: '從 URL 匯入',\n  is_latest_version_inform: '太棒了，你正在執行的是最新版本！',\n  check_update_failed: '檢查更新失敗！',\n  update_download_now: '下載更新',\n  update_install_now: '安裝並重新啟動',\n  update_downloading_desc: '正在下載版本 {0}：{1}',\n  update_ready_desc: '版本 {0} 已下載完成，可以開始安裝。',\n  item_found: '{0} 項符合',\n  items: '項',\n  items_found: '{0} 項符合',\n  language: '語言',\n  last_refresh: '最後更新：',\n  latest_version_desc: '最新的版本為：{0}',\n  line: '行',\n  lines: '行',\n  loading: '載入中...',\n  local: '本地',\n  match: '符合',\n  migrate_confirm: 'SwitchHosts v4.0 使用了新的資料儲存格式，是否遷移舊資料到新格式？',\n  migrate_data: '遷移資料',\n  minimize: '最小化',\n  minute: '分鐘',\n  minutes: '分鐘',\n  move_items_to_trashcan: '移動 {0} 項到垃圾桶',\n  move_to_trashcan: '移到垃圾桶',\n  multi_chose_folder_switch_all: '多選資料夾開關控制子項目',\n  need_to_relaunch: '需要重啟',\n  need_to_relaunch_after_setting_changed: '設定已更改，應用重啟後生效。',\n  never: '永不',\n  new: '新建',\n  new_version_found: '發現新版本',\n  next: '下一個',\n  no_access_to_hosts: '沒有寫入 Hosts 檔案的權限。',\n  no_record: '沒有紀錄',\n  overwrite: '覆寫',\n  password: '密碼',\n  paste: '貼上',\n  port: '通訊埠',\n  preferences: '選項',\n  previous: '上一個',\n  protocol: '協議',\n  proxy: '代理',\n  quit: '退出',\n  read_only: '唯讀',\n  redo: '重做',\n  refresh: '更新',\n  regexp: '正規表達式',\n  reload: '重載',\n  remote: '遠端',\n  remove_duplicate_records: '移除重複的紀錄',\n  remove_duplicate_records_desc: '如果一個網域指向多個 IP，只有第一條會生效，剩下的將被轉為註解。',\n  replace: '替換',\n  replace_all: '替換全部',\n  replace_history: '替換歷史',\n  reset: '重設',\n  reset_data_dir_confirm: '確定要把資料夾重設為預設路徑嗎？({0})?',\n  reset_zoom: '重設縮放',\n  search: '搜尋',\n  select_all: '全選',\n  selected: '已選',\n  show_dock_icon: '顯示工作列圖示',\n  show_history: '顯示歷史紀錄',\n  show_main_window: '顯示主視窗',\n  show_title_on_tray: '在通知區域顯示標題',\n  source_code: '原始碼',\n  success: '操作成功！',\n  sudo_prompt_title: '請輸入你的登入密碼（sudo 密碼）',\n  system_hosts: '系統 Hosts',\n  system_hosts_history: '系統 Hosts 歷史版本',\n  system_hosts_history_delete_confirm: '確定要刪除該項紀錄嗎？',\n  system_hosts_history_help: '如果歷史紀錄的總數超過這個限制，最舊的紀錄將被刪除。',\n  system_hosts_history_limit: '最大紀錄數：',\n  test: '測試',\n  theme: '主題',\n  theme_dark: '深色',\n  theme_light: '亮色',\n  title: '標題',\n  to_show_source: '雙擊顯示原始碼。',\n  toggle_developer_tools: '切換開發者工具',\n  toggle_dock_icon: '顯示/隱藏任務列圖示',\n  toggle_full_screen: '切換全螢幕',\n  trashcan: '垃圾桶',\n  trashcan_clear: '清除垃圾桶',\n  trashcan_clear_confirm: '確定要清除垃圾桶嗎？',\n  trashcan_delete_confirm: '要完全清除本項嗎？',\n  trashcan_restore: '復原',\n  tray_mini_window: '任務列快捷視窗',\n  undo: '取消',\n  unhide: '取消隱藏',\n  untitled: '未命名',\n  url_placeholder: 'http:// 或 https:// 或 file://',\n  usage_data_agree: '好的，寄送匿名的使用資料',\n  usage_data_help:\n    '您願意寄送匿名的使用資料來幫助我們改善 SwitchHosts 嗎？資料中不會包含任何隱私資訊。',\n  usage_data_title: '幫助改善 SwitchHosts',\n  use_proxy: '使用代理',\n  use_system_window_frame: '使用系統視窗外框，需要重啟程式',\n  view: '視窗',\n  where_is_my_data: '我的資料儲存在哪裡？',\n  where_is_my_hosts: '我的 hosts 檔案在哪裡？',\n  window: 'Window',\n  write_mode: '寫入模式',\n  write_mode_append_help: '新紀錄將附加到現有系統 hosts 檔案後面。',\n  write_mode_overwrite_help: '新紀錄將覆寫現有系統 hosts 檔案。',\n  write_mode_set: '設定寫入模式',\n  your_data_is: '你的資料在：',\n  your_hosts_file_is: '你的 hosts 檔案在：',\n  zoom: '縮放',\n  zoom_in: '放大',\n  zoom_out: '縮小',\n}\n\nexport default lang\n"
  },
  {
    "path": "src/common/i18n/languages/zh.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { LanguageDict } from '@common/types'\n\nconst lang: LanguageDict = {\n  _app_name: 'SwitchHosts',\n  _key: 'zh',\n  _name: '中文',\n  about: '关于',\n  acknowledgement: '特别致谢',\n  advanced: '高级',\n  all: '全部',\n  append: '追加',\n  auto_refresh: '自动刷新',\n  btn_cancel: '取消',\n  btn_ok: '确定',\n  change: '更改',\n  check_update: '检查更新',\n  choice_mode: '选择模式',\n  choice_mode_default: '默认',\n  choice_mode_desc: '只对顶层项目生效，每个文件夹可设置自己的选择模式。',\n  choice_mode_multiple: '多选',\n  choice_mode_single: '单选',\n  choices: '选项',\n  chosen: '已选',\n  clear_history: '清除历史记录',\n  click_to_open: '点击打开',\n  close: '关闭',\n  colon: '：',\n  commands: '命令',\n  commands_help: '每次 Hosts 应用后将执行下面的系统命令：',\n  commands_title: 'Hosts 应用后命令',\n  comment_current_line: '注释当前行',\n  content: '内容',\n  copy: '复制',\n  cut: '剪切',\n  day: '天',\n  days: '天',\n  delete: '删除',\n  download: '下载',\n  edit: '编辑',\n  export: '导出',\n  export_done: '导出已完成。',\n  fail: '操作失败！',\n  feedback: '意见反馈',\n  file: '文件',\n  find: '查找',\n  find_all: '查找所有',\n  find_and_replace: '查找并替换',\n  find_history: '查找历史',\n  folder: '文件夹',\n  front: '前置',\n  general: '通用',\n  group: '组合',\n  help: 'Help',\n  hide: '隐藏',\n  hide_at_launch: '启动时隐藏',\n  hide_dock_icon: '隐藏任务栏图标',\n  hide_history: '隐藏历史记录',\n  hide_others: '隐藏其他',\n  homepage: '主页',\n  host: '主机',\n  hosts_add: '添加 hosts',\n  hosts_delete: '删除当前方案',\n  hosts_delete_confirm: '确实要删除当前方案吗？',\n  hosts_edit: '编辑 hosts',\n  hosts_title: 'Hosts 标题',\n  hosts_type: 'Hosts 类型',\n  hosts_updated: 'Hosts 文件已更新。',\n  hour: '小时',\n  hours: '小时',\n  http_api_on: '开启 HTTP API',\n  http_api_on_desc: '运行于 {0} 端口，可用于 Alfred 等第三方软件切换 hosts。',\n  http_api_only_local: 'HTTP API 仅监听 127.0.0.1',\n  ignore_case: '忽略大小写',\n  import: '导入',\n  import_done: '导入已完成。',\n  import_fail: '导入失败！',\n  import_from_url: '从 URL 导入',\n  is_latest_version_inform: '太棒了，你正在运行的是最新版本！',\n  check_update_failed: '检查更新失败！',\n  update_download_now: '下载更新',\n  update_install_now: '安装并重启',\n  update_downloading_desc: '正在下载版本 {0}：{1}',\n  update_ready_desc: '版本 {0} 已下载完成，可以开始安装。',\n  item_found: '{0} 项匹配',\n  items: '项',\n  items_found: '{0} 项匹配',\n  language: '语言',\n  last_refresh: '最后刷新：',\n  latest_version_desc: '最新的版本为：{0}',\n  line: '行',\n  lines: '行',\n  loading: '加载中...',\n  local: '本地',\n  match: '匹配',\n  migrate_confirm: 'SwitchHosts v4.0 使用了新的数据存储格式，是否迁移旧数据为新格式？',\n  migrate_data: '迁移数据',\n  minimize: '最小化',\n  minute: '分钟',\n  minutes: '分钟',\n  move_items_to_trashcan: '移动 {0} 项到回收站',\n  move_to_trashcan: '移到回收站',\n  multi_chose_folder_switch_all: '多选文件夹开关控制子项目',\n  need_to_relaunch: '需要重启',\n  need_to_relaunch_after_setting_changed: '设置已更改，应用重启后生效。',\n  never: '从不',\n  new: '新建',\n  new_version_found: '发现新版本',\n  next: '下一个',\n  no_access_to_hosts: '没有写入 Hosts 文件的权限。',\n  no_record: '没有记录',\n  overwrite: '覆盖',\n  password: '密码',\n  paste: '粘贴',\n  port: '端口',\n  preferences: '选项',\n  previous: '上一个',\n  protocol: '协议',\n  proxy: '代理',\n  quit: '退出',\n  read_only: '只读',\n  redo: '重做',\n  refresh: '刷新',\n  regexp: '正则表达式',\n  reload: '重载',\n  remote: '远程',\n  remove_duplicate_records: '移除重复的记录',\n  remove_duplicate_records_desc: '如果一个域名指向多个 IP，只有第一条会生效，后面的将被转为注释。',\n  replace: '替换',\n  replace_all: '替换所有',\n  replace_history: '替换历史',\n  reset: '重置',\n  reset_data_dir_confirm: '确定要把数据文件夹重置为默认地址吗？({0})?',\n  reset_zoom: '重置缩放',\n  search: '搜索',\n  select_all: '全选',\n  selected: '已选',\n  show_dock_icon: '显示任务栏图标',\n  show_history: '显示历史记录',\n  show_main_window: '显示主窗口',\n  show_title_on_tray: '在系统托盘显示标题',\n  source_code: '源码',\n  success: '操作成功！',\n  sudo_prompt_title: '请输入你的登录密码（sudo 密码）',\n  system_hosts: '系统 Hosts',\n  system_hosts_history: '系统 Hosts 历史版本',\n  system_hosts_history_delete_confirm: '确实要删除该项记录吗？',\n  system_hosts_history_help: '如果历史记录的总数超过这个限制，最老的记录将被删除。',\n  system_hosts_history_limit: '最大记录数：',\n  test: '测试',\n  theme: '主题',\n  theme_dark: '夜间',\n  theme_light: '明亮',\n  title: '标题',\n  to_show_source: '双击显示源代码。',\n  toggle_developer_tools: '切换开发者工具',\n  toggle_dock_icon: '显示/隐藏任务栏图标',\n  toggle_full_screen: '切换全屏',\n  trashcan: '回收站',\n  trashcan_clear: '清空回收站',\n  trashcan_clear_confirm: '确实要清空回收站吗？',\n  trashcan_delete_confirm: '要彻底删除本项吗？',\n  trashcan_restore: '还原',\n  tray_mini_window: '任务栏快捷小窗',\n  undo: '撤销',\n  unhide: '取消隐藏',\n  untitled: '未命名',\n  url_placeholder: 'http:// 或 https:// 或 file://',\n  usage_data_agree: '好的，发送匿名的使用数据',\n  usage_data_help:\n    '您愿意发送匿名的使用数据来帮助我们改进 SwitchHosts 吗？数据中不会包含任何隐私信息。',\n  usage_data_title: '帮助改进 SwitchHosts',\n  use_proxy: '使用代理',\n  use_system_window_frame: '使用系统窗口框架，需要重启程序',\n  view: '视图',\n  where_is_my_data: '我的数据存储在哪里？',\n  where_is_my_hosts: '我的 hosts 文件在哪里？',\n  window: 'Window',\n  write_mode: '写入模式',\n  write_mode_append_help: '新记录将追加到现有系统 hosts 文件末尾。',\n  write_mode_overwrite_help: '新记录将覆盖现有系统 hosts 文件。',\n  write_mode_set: '设置写入模式',\n  your_data_is: '你的数据在：',\n  your_hosts_file_is: '你的 hosts 文件在：',\n  zoom: '缩放',\n  zoom_in: '放大',\n  zoom_out: '缩小',\n}\n\nexport default lang\n"
  },
  {
    "path": "src/common/newlines.ts",
    "content": "export type LineEnding = '\\n' | '\\r\\n'\n\nconst LINE_ENDING_RE = /\\r\\n?/g\n\nexport function normalizeLineEndings(content: string): string {\n  return content.replace(LINE_ENDING_RE, '\\n')\n}\n\nexport function getLineEndingForPlatform(platform = process.platform): LineEnding {\n  if (platform === 'win32') {\n    return '\\r\\n'\n  }\n\n  return '\\n'\n}\n\nexport function restoreLineEndings(content: string, lineEnding: LineEnding): string {\n  const normalized = normalizeLineEndings(content)\n\n  if (lineEnding === '\\r\\n') {\n    return normalized.replace(/\\n/g, '\\r\\n')\n  }\n\n  return normalized\n}\n"
  },
  {
    "path": "src/common/normalize.ts",
    "content": "/**\n * normalize\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as os from 'os'\n\nconst default_options = {\n  remove_duplicate_records: false,\n}\n\nexport type INormalizeOptions = Partial<typeof default_options>\n\ninterface IHostsLineObj {\n  ip: string\n  domains: string[]\n  comment: string\n}\n\ninterface IDomainsIPMap {\n  [domain: string]: string\n}\n\nexport const parseLine = (line: string): IHostsLineObj => {\n  let [cnt, ...cmt] = line.split('#')\n  let comment = cmt.join('#').trim()\n\n  let [ip, ...domains] = cnt.trim().replace(/\\s+/g, ' ').split(' ')\n\n  return { ip, domains, comment }\n}\n\nexport const formatLine = (o: Partial<IHostsLineObj>): string => {\n  let comment = o.comment || ''\n  if (comment) {\n    comment = '# ' + comment\n  }\n  return [o.ip || '', (o.domains || []).join(' '), comment].join(' ').trim()\n}\n\nconst removeDuplicateRecords = (content: string): string => {\n  let domain_ip_map: IDomainsIPMap = {}\n  let lines = content.split('\\n')\n  let new_lines: string[] = []\n\n  lines.map((line) => {\n    let { ip, domains, comment } = parseLine(line)\n\n    if (!ip || domains.length === 0) {\n      new_lines.push(line)\n      return\n    }\n\n    const ipv = /:/.test(ip) ? 6 : 4\n\n    let new_domains: string[] = []\n    let duplicate_domains: string[] = []\n    domains.map((domain) => {\n      const domain_v = `${domain}_${ipv}`\n      if (domain_v in domain_ip_map) {\n        duplicate_domains.push(domain)\n      } else {\n        new_domains.push(domain)\n        domain_ip_map[domain_v] = ip\n      }\n    })\n\n    if (new_domains.length > 0) {\n      new_lines.push(formatLine({ ip, domains: new_domains, comment }))\n    }\n    if (duplicate_domains.length > 0) {\n      new_lines.push(\n        formatLine({\n          comment:\n            'invalid hosts (repeated): ' +\n            formatLine({ ip, domains: duplicate_domains }),\n        }),\n      )\n    }\n  })\n\n  return new_lines.join(os.EOL)\n}\n\nexport default (\n  hosts_content: string,\n  options: INormalizeOptions = {},\n): string => {\n  // 在这儿执行去重等等操作\n  if (options.remove_duplicate_records) {\n    hosts_content = removeDuplicateRecords(hosts_content)\n  }\n\n  return hosts_content\n}\n"
  },
  {
    "path": "src/common/tree.ts",
    "content": "export type NodeIdType = string\n\nexport interface ITreeNodeData {\n  id: NodeIdType\n  title?: string\n  can_select?: boolean // 是否可以被选中，默认为 true\n  can_drag?: boolean // 是否可以拖动，默认为 true\n  can_drop_before?: boolean // 是否可以接受 drop before，默认为 true\n  can_drop_in?: boolean // 是否可以接受 drop in，默认为 true\n  can_drop_after?: boolean // 是否可以接受 drop after，默认为 true\n  is_collapsed?: boolean\n  children?: ITreeNodeData[]\n\n  [key: string]: any\n}\n\ninterface IWithChildren {\n  children?: IWithChildren[]\n}\n\nexport function flatten<T extends IWithChildren>(tree_list: T[]): T[] {\n  let arr: any[] = []\n\n  Array.isArray(tree_list) &&\n    tree_list.map((item) => {\n      if (!item) return\n\n      arr.push(item)\n\n      if (Array.isArray(item.children)) {\n        let a2 = flatten(item.children)\n        arr = arr.concat(a2)\n      }\n    })\n\n  return arr\n}\n\ninterface IWidthId extends IWithChildren {\n  id: string\n}\n\nexport function getNodeById<T extends IWidthId>(\n  tree_list: T[],\n  id: string,\n): T | undefined {\n  return flatten(tree_list).find((i) => i.id === id)\n}\n"
  },
  {
    "path": "src/common/types.d.ts",
    "content": "/**\n * types\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { HostsType } from '@common/data'\nimport { MenuItemConstructorOptions, WebContents } from 'electron'\nimport { default as lang } from './i18n/languages/en'\nimport * as actions from '@main/actions'\n\nexport type LanguageDict = typeof lang\nexport type LanguageKey = keyof LanguageDict\n\nexport interface IActionFunc {\n  sender: WebContents\n}\n\nexport type Actions = typeof actions & IActionFunc\n\nexport interface IMenuItemOption extends MenuItemConstructorOptions {\n  // 参见：https://www.electronjs.org/docs/api/menu-item\n\n  _click_evt?: string\n}\n\nexport interface IPopupMenuOption {\n  menu_id: string\n  items: IMenuItemOption[]\n}\n\nexport interface IFindPosition {\n  start: number\n  end: number\n  line: number\n  line_pos: number\n  end_line: number\n  end_line_pos: number\n  before: string\n  match: string\n  after: string\n}\n\nexport interface IFindSplitter {\n  before: string\n  match: string\n  after: string\n  replace?: string\n}\n\nexport interface IFindItem {\n  item_id: string\n  item_title: string\n  item_type: HostsType\n  positions: IFindPosition[]\n  splitters: IFindSplitter[]\n}\n\nexport type IFindShowSourceParam = IFindPosition & {\n  item_id: string\n  [key: string]: any\n}\n"
  },
  {
    "path": "src/common/update.ts",
    "content": "export interface AppUpdateInfo {\n  version: string\n  releaseName?: string | null\n  releaseNotes?: string | null\n}\n\nexport interface AppUpdateProgress {\n  percent: number\n  transferred: number\n  total: number\n  bytesPerSecond: number\n}\n\nexport interface AppDownloadedUpdateInfo extends AppUpdateInfo {\n  downloadedFile?: string | null\n}\n"
  },
  {
    "path": "src/common/utils/wait.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n"
  },
  {
    "path": "src/main/actions/checkUpdate.ts",
    "content": "import * as updater from '@main/core/updater'\n\nexport default async (): Promise<boolean | null> => {\n  try {\n    const update = await updater.checkUpdate()\n    return !!update\n  } catch (error) {\n    console.error(error)\n    return null\n  }\n}\n"
  },
  {
    "path": "src/main/actions/closeMainWindow.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default async () => {\n  let win = global.main_win\n  win && win.isClosable() && win.close()\n}\n"
  },
  {
    "path": "src/main/actions/cmd/changeDataDir.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { app, BrowserWindow, dialog, OpenDialogOptions, OpenDialogReturnValue } from 'electron'\nimport { localdb } from '@main/data'\nimport getDataFolder, { getDefaultDataDir } from '@main/libs/getDataDir'\nimport getI18N from '@main/core/getI18N'\nimport { IActionFunc } from '@common/types'\n\nexport default async function (\n  this: IActionFunc,\n  to_default?: boolean,\n): Promise<string | undefined> {\n  let { sender } = this\n  let { lang } = await getI18N()\n  let current_dir = getDataFolder()\n  let dir: string = ''\n\n  if (to_default) {\n    dir = getDefaultDataDir()\n  } else {\n    let parent = BrowserWindow.fromWebContents(sender)\n    if (parent?.isFullScreen()) {\n      parent?.setFullScreen(false)\n    }\n\n    let options: OpenDialogOptions = {\n      // title: '选择数据目录',\n      defaultPath: current_dir,\n      properties: ['openDirectory', 'createDirectory'],\n    }\n\n    let r: OpenDialogReturnValue\n\n    if (parent) {\n      r = await dialog.showOpenDialog(parent, options)\n    } else {\n      r = await dialog.showOpenDialog(options)\n    }\n\n    if (r.canceled) {\n      return\n    }\n\n    dir = r.filePaths[0]\n  }\n\n  if (!dir || dir === current_dir) {\n    return\n  }\n\n  await localdb.dict.local.set('data_dir', dir)\n  dialog.showMessageBoxSync({\n    message: lang.need_to_relaunch_after_setting_changed,\n  })\n  app.relaunch()\n  app.exit(0)\n\n  return dir\n}\n"
  },
  {
    "path": "src/main/actions/cmd/clearHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\n\nexport default async () => {\n  return await cfgdb.collection.cmd_history.remove()\n}\n"
  },
  {
    "path": "src/main/actions/cmd/deleteHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\n\nexport default async (_id: string) => {\n  return await cfgdb.collection.cmd_history.delete((i) => i._id === _id)\n}\n"
  },
  {
    "path": "src/main/actions/cmd/focusMainWindow.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default () => {\n  global.main_win.show()\n  global.main_win.focus()\n}\n"
  },
  {
    "path": "src/main/actions/cmd/getHistoryList.ts",
    "content": "/**\n * getHistoryList\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\nimport { ICommandRunResult } from '@common/data'\n\nexport default async (): Promise<ICommandRunResult[]> => {\n  return await cfgdb.collection.cmd_history.all()\n}\n"
  },
  {
    "path": "src/main/actions/cmd/toggleDevTools.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default () => {\n  let win = global.main_win\n  if (!win) return\n\n  win.webContents.toggleDevTools()\n}\n"
  },
  {
    "path": "src/main/actions/cmd/tryToRun.ts",
    "content": "/**\n * run\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\nimport { ICommandRunResult } from '@common/data'\nimport { exec } from 'child_process'\nimport { broadcast } from '@main/core/agent'\nimport events from '@common/events'\n\nconst run = (cmd: string): Promise<ICommandRunResult> =>\n  new Promise((resolve) => {\n    exec(cmd, (error, stdout, stderr) => {\n      // command output is in stdout\n      let success: boolean = !error\n\n      resolve({\n        success,\n        stdout,\n        stderr,\n        add_time_ms: new Date().getTime(),\n      })\n    })\n  })\n\nexport default async () => {\n  let cmd = await cfgdb.dict.cfg.get('cmd_after_hosts_apply')\n\n  if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {\n    return\n  }\n\n  console.log(`to run cmd...`)\n  let result = await run(cmd)\n  console.log(result)\n  await cfgdb.collection.cmd_history.insert(result)\n  broadcast(events.cmd_run_result, result)\n\n  // auto delete old records\n  const max_records = 200\n  let all = await cfgdb.collection.cmd_history.all<ICommandRunResult>()\n  if (all.length > max_records) {\n    let n = all.length - max_records\n    for (let i = 0; i < n; i++) {\n      await cfgdb.collection.cmd_history.delete((item) => item._id === all[i]._id)\n    }\n  }\n\n  global.tracer.add(`cmd:${result.success ? 1 : 0}`)\n}\n"
  },
  {
    "path": "src/main/actions/config/all.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\nimport default_configs, { ConfigsType } from '@common/default_configs'\n\nexport default async (): Promise<ConfigsType> => {\n  if (!default_configs.locale && global.system_locale) {\n    default_configs.locale = global.system_locale\n  }\n\n  let cfgs: Partial<ConfigsType> = await cfgdb.dict.cfg.all()\n  return Object.assign({}, default_configs, cfgs)\n}\n"
  },
  {
    "path": "src/main/actions/config/get.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\nimport default_configs, { ConfigsType } from '@common/default_configs'\n\nexport default async <K extends keyof ConfigsType>(key: K) => {\n  return (await cfgdb.dict.cfg.get(key, default_configs[key])) as ConfigsType[K]\n}\n"
  },
  {
    "path": "src/main/actions/config/set.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\nimport { ConfigsType } from '@common/default_configs'\n\nexport default async <K extends keyof ConfigsType>(key: K, value: ConfigsType[K]) => {\n  console.log(`config:store.set [${key}]: ${value}`)\n  await cfgdb.dict.cfg.set(key, value)\n}\n"
  },
  {
    "path": "src/main/actions/config/update.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { updateTrayTitle } from '@main/actions'\nimport { cfgdb } from '@main/data'\nimport * as http_api from '@main/http'\nimport { makeMainMenu } from '@main/ui/menu'\nimport { ConfigsType } from '@common/default_configs'\nimport { app } from 'electron'\n\nexport default async (data: Partial<ConfigsType>) => {\n  const old_configs = (await cfgdb.dict.cfg.all()) as ConfigsType\n\n  await cfgdb.dict.cfg.update(data)\n\n  await updateTrayTitle(!!data.show_title_on_tray)\n  if (old_configs.locale !== data.locale) {\n    makeMainMenu(data.locale)\n  }\n\n  if (old_configs.http_api_on !== data.http_api_on) {\n    if (data.http_api_on) {\n      http_api.start(<boolean>data.http_api_only_local)\n    } else {\n      http_api.stop()\n    }\n  } else if (old_configs.http_api_only_local !== data.http_api_only_local) {\n    if (data.http_api_on) {\n      await http_api.stop()\n      http_api.start(<boolean>data.http_api_only_local)\n    }\n  }\n\n  if (old_configs.hide_dock_icon !== data.hide_dock_icon) {\n    if (!app.dock) {\n      return\n    }\n\n    if (data.hide_dock_icon) {\n      app.dock.hide()\n    } else {\n      app.dock.show().catch((e) => console.error(e))\n    }\n  }\n}\n"
  },
  {
    "path": "src/main/actions/downloadUpdate.ts",
    "content": "import * as updater from '@main/core/updater'\n\nexport default async (): Promise<boolean> => {\n  await updater.downloadUpdate()\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/find/addHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getHistory from '@main/actions/find/getHistory'\nimport setHistory, { IFindHistoryData } from '@main/actions/find/setHistory'\n\nconst MAX_LENGTH = 20\n\nexport default async (data: IFindHistoryData) => {\n  let history_all = await getHistory()\n\n  // remove old\n  history_all = history_all.filter((i) => i.value !== data.value)\n\n  // insert new\n  history_all.push(data)\n\n  while (history_all.length > MAX_LENGTH) {\n    history_all.shift()\n  }\n\n  await setHistory(history_all)\n\n  return history_all\n}\n"
  },
  {
    "path": "src/main/actions/find/addReplaceHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getReplaceHistory from '@main/actions/find/getReplaceHistory'\nimport setReplaceHistory from '@main/actions/find/setReplaceHistory'\n\nconst MAX_LENGTH = 20\n\nexport default async (value: string) => {\n  let history_all = await getReplaceHistory()\n\n  // remove old\n  history_all = history_all.filter((v) => v !== value)\n\n  // insert new\n  history_all.push(value)\n\n  while (history_all.length > MAX_LENGTH) {\n    history_all.shift()\n  }\n\n  await setReplaceHistory(history_all)\n\n  return history_all\n}\n"
  },
  {
    "path": "src/main/actions/find/findBy.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport splitContent from '@main/actions/find/splitContent'\nimport getContentOfHosts from '@main/actions/hosts/getContent'\nimport { flatten } from '@common/hostsFn'\nimport { IFindItem } from '@common/types'\nimport findInContent from 'src/main/actions/find/findPositionsInContent'\nimport { getList } from '../index'\n\nexport interface IFindOptions {\n  is_regexp: boolean\n  is_ignore_case: boolean\n}\n\nexport default async (keyword: string, options: IFindOptions): Promise<IFindItem[]> => {\n  console.log(keyword)\n  let result_items: IFindItem[] = []\n\n  let tree = await getList()\n  let items = flatten(tree)\n\n  let exp: RegExp\n  if (options.is_regexp) {\n    exp = new RegExp(keyword, options.is_ignore_case ? 'ig' : 'g')\n  } else {\n    let kw = keyword.replace(/([.^$([?*+])/gi, '\\\\$1')\n    exp = new RegExp(kw, options.is_ignore_case ? 'ig' : 'g')\n  }\n\n  for (let item of items) {\n    const item_type = item.type || 'local'\n    if (item_type === 'group' || item_type === 'folder') {\n      continue\n    }\n    let content = await getContentOfHosts(item.id)\n    let positions = findInContent(content, exp)\n    if (positions.length === 0) {\n      continue\n    }\n\n    result_items.push({\n      item_title: item.title || '',\n      item_id: item.id,\n      item_type,\n      positions,\n      splitters: splitContent(content, positions),\n    })\n  }\n\n  return result_items\n}\n"
  },
  {
    "path": "src/main/actions/find/findPositionsInContent.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IFindPosition } from '@common/types'\n\ntype MatchResult = Pick<\n  IFindPosition,\n  'start' | 'end' | 'before' | 'match' | 'after' | 'line' | 'line_pos' | 'end_line' | 'end_line_pos'\n>\n\nexport default (content: string, exp: RegExp): MatchResult[] => {\n  let result_items: MatchResult[] = []\n\n  let m = content.match(exp)\n  if (!m) {\n    return []\n  }\n\n  let line = 1\n  let start = 0\n\n  let cnt = content\n  for (let i of m) {\n    let idx = cnt.indexOf(i)\n    if (idx === -1) continue\n\n    let head = cnt.slice(0, idx)\n    cnt = cnt.slice(idx + i.length)\n\n    let head_lines = head.split('\\n')\n    line += head_lines.length - 1\n    start += head.length\n    let before_lines = content.slice(0, start).split('\\n')\n    let before = before_lines[before_lines.length - 1]\n    let after = cnt.split('\\n')[0]\n\n    let i_ln = i.split('\\n')\n    let end_line = line + i_ln.length - 1\n    let end_line_pos = before.length + i.length\n    if (i_ln.length > 1) {\n      end_line_pos = i_ln[i_ln.length - 1].length\n    }\n\n    result_items.push({\n      start,\n      end: start + i.length,\n      before,\n      match: i,\n      after,\n      line,\n      line_pos: before.length,\n      end_line,\n      end_line_pos,\n    })\n\n    start += i.length\n  }\n\n  return result_items\n}\n"
  },
  {
    "path": "src/main/actions/find/getHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IFindHistoryData } from '@main/actions/find/setHistory'\nimport { cfgdb } from '@main/data'\n\nexport default async (): Promise<IFindHistoryData[]> => {\n  return (await cfgdb.list.find_history.all()) as IFindHistoryData[]\n}\n"
  },
  {
    "path": "src/main/actions/find/getReplaceHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\n\nexport default async (): Promise<string[]> => {\n  return (await cfgdb.list.replace_history.all()) as string[]\n}\n"
  },
  {
    "path": "src/main/actions/find/setHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\n\nexport interface IFindHistoryData {\n  value: string\n  is_regexp: boolean\n  is_ignore_case: boolean\n}\n\nexport default async (data: IFindHistoryData[]) => {\n  await cfgdb.list.find_history.set(data)\n}\n"
  },
  {
    "path": "src/main/actions/find/setReplaceHistory.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { cfgdb } from '@main/data'\n\nexport default async (data: string[]) => {\n  await cfgdb.list.replace_history.set(data)\n}\n"
  },
  {
    "path": "src/main/actions/find/show.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { makeWindow } from '@main/ui/find'\n\nexport default async () => {\n  if (!global.find_win) {\n    global.find_win = await makeWindow()\n  }\n\n  global.find_win?.show()\n  global.find_win?.focus()\n}\n"
  },
  {
    "path": "src/main/actions/find/splitContent.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IFindPosition, IFindSplitter } from '@common/types'\n\ntype MatchResult = Pick<IFindPosition, 'start' | 'end' | 'match'> & {\n  [key: string]: any\n}\n\nexport default (content: string, find_results: MatchResult[]): IFindSplitter[] => {\n  let spliters: IFindSplitter[] = []\n\n  let last_end = 0\n  find_results.map((r, idx) => {\n    let { start, match } = r\n    let before = content.slice(last_end, start)\n    let after = ''\n\n    last_end += before.length + match.length\n    if (idx === find_results.length - 1) {\n      after = content.slice(last_end)\n    }\n\n    let spliter: IFindSplitter = {\n      before,\n      after,\n      match,\n    }\n\n    spliters.push(spliter)\n  })\n\n  return spliters\n}\n"
  },
  {
    "path": "src/main/actions/getBasicData.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsBasicData, IHostsListObject, ITrashcanListObject, VersionType } from '@common/data'\nimport { flatten } from '@common/hostsFn'\nimport { v4 as uuid4 } from 'uuid'\nimport version from '@/version.json'\n\nconst app_version = version as unknown as VersionType\n\nconst normalizeList = (list: IHostsListObject[]): IHostsListObject[] => {\n  let flat = flatten(list)\n  flat.map((item) => {\n    if (!item.id) {\n      item.id = uuid4()\n    }\n  })\n\n  return list\n}\n\nconst normalizeTrashcan = (list: ITrashcanListObject[]): ITrashcanListObject[] => {\n  list.map((item) => {\n    if (!item.id) {\n      item.id = uuid4()\n    }\n  })\n\n  return list\n}\n\nexport default async (): Promise<IHostsBasicData> => {\n  const default_data: IHostsBasicData = {\n    list: [],\n    trashcan: [],\n    version: app_version,\n  }\n\n  let list = normalizeList(await swhdb.list.tree.all())\n  let trashcan = normalizeTrashcan(await swhdb.list.trashcan.all())\n  let v = (await swhdb.dict.meta.get<VersionType>('version', app_version)) || [0, 0, 0, 0]\n\n  return {\n    ...default_data,\n    list,\n    trashcan,\n    version: v,\n  }\n}\n"
  },
  {
    "path": "src/main/actions/getDataDir.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getDataDir from '@main/libs/getDataDir'\n\nexport default async () => getDataDir()\n"
  },
  {
    "path": "src/main/actions/getDefaultDataDir.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getDefaultDataDir } from '@main/libs/getDataDir'\n\nexport default async () => getDefaultDataDir()\n"
  },
  {
    "path": "src/main/actions/hosts/deleteHistory.ts",
    "content": "/**\n * removeHistory\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\n\nexport default async (id: string) => {\n  console.log('delete history #' + id)\n  await swhdb.collection.history.delete((item) => item.id === id)\n}\n"
  },
  {
    "path": "src/main/actions/hosts/getContent.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet, getItemFromList, getList } from '@main/actions'\nimport { swhdb } from '@main/data'\nimport { IHostsContentObject } from '@common/data'\nimport { findItemById, flatten } from '@common/hostsFn'\nimport { normalizeLineEndings } from '@common/newlines'\n\nconst getContentById = async (id: string) => {\n  let hosts_content = await swhdb.collection.hosts.find<IHostsContentObject>((i) => i.id === id)\n  return normalizeLineEndings(hosts_content?.content || '')\n}\n\nconst getContentOfHosts = async (id: string): Promise<string> => {\n  let hosts = await getItemFromList(id)\n  if (!hosts) {\n    return await getContentById(id)\n  }\n\n  const { type } = hosts\n  if (!type || type === 'local' || type === 'remote') {\n    return await getContentById(id)\n  }\n\n  let list = await getList()\n\n  let multi_chose_folder_switch_all = await configGet('multi_chose_folder_switch_all')\n  let isSkipFolder = multi_chose_folder_switch_all && hosts.folder_mode !== 1\n\n  if (type === 'folder' && !isSkipFolder) {\n    const items = flatten(hosts.children || [])\n\n    let a = await Promise.all(\n      items.map(async (item) => {\n        return `# file: ${item.title}\\n` + (await getContentOfHosts(item.id))\n      }),\n    )\n    return a.join('\\n\\n')\n  }\n\n  if (type === 'group') {\n    let a = await Promise.all(\n      (hosts.include || []).map(async (id) => {\n        let item = findItemById(list, id)\n        if (!item) return ''\n\n        return `# file: ${item.title}\\n` + (await getContentOfHosts(id))\n      }),\n    )\n    return a.join('\\n\\n')\n  }\n\n  return ''\n}\n\nexport default getContentOfHosts\n"
  },
  {
    "path": "src/main/actions/hosts/getHistoryList.ts",
    "content": "/**\n * getHistoryList\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsHistoryObject } from '@common/data'\n\nexport default async (): Promise<IHostsHistoryObject[]> => {\n  let list = await swhdb.collection.history.all<IHostsHistoryObject>()\n\n  list = list.map((item) => {\n    item.content = item.content || ''\n    return item\n  })\n\n  return list\n}\n"
  },
  {
    "path": "src/main/actions/hosts/getPathOfSystemHostsPath.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default async (): Promise<string> => {\n  // Windows 系统有可能不安装在 C 盘\n  return process.platform === 'win32'\n    ? `${process.env.windir || 'C:\\\\WINDOWS'}\\\\system32\\\\drivers\\\\etc\\\\hosts`\n    : '/etc/hosts'\n}\n"
  },
  {
    "path": "src/main/actions/hosts/getSystemHosts.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getPathOfSystemHosts from './getPathOfSystemHostsPath'\nimport * as fs from 'fs'\nimport { normalizeLineEndings } from '@common/newlines'\n\nexport default async (): Promise<string> => {\n  const fn = await getPathOfSystemHosts()\n\n  if (!fs.existsSync(fn)) {\n    return ''\n  }\n\n  return normalizeLineEndings(await fs.promises.readFile(fn, 'utf-8'))\n}\n"
  },
  {
    "path": "src/main/actions/hosts/refresh.ts",
    "content": "/**\n * refreshHosts\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getHostsContent, setHostsContent, setList } from '@main/actions/index'\nimport { broadcast } from '@main/core/agent'\n\nimport { swhdb } from '@main/data'\nimport { GET } from '@main/libs/request'\nimport { IHostsListObject, IOperationResult } from '@common/data'\nimport events from '@common/events'\nimport * as hostsFn from '@common/hostsFn'\nimport dayjs from 'dayjs'\nimport * as fs from 'fs'\nimport { URL } from 'url'\n\nexport default async (hosts_id: string): Promise<IOperationResult> => {\n  let list = await swhdb.list.tree.all()\n  let hosts: IHostsListObject | undefined = hostsFn.findItemById(list, hosts_id)\n\n  if (!hosts) {\n    return {\n      success: false,\n      code: 'invalid_id',\n    }\n  }\n\n  let { type, url } = hosts\n\n  if (type !== 'remote') {\n    return {\n      success: false,\n      code: 'not_remote',\n    }\n  }\n\n  if (!url) {\n    return {\n      success: false,\n      code: 'no_url',\n    }\n  }\n\n  let old_content: string = await getHostsContent(hosts.id)\n  let new_content: string\n  try {\n    console.log(`-> refreshHosts URL: \"${url}\"`)\n    if (url.startsWith('file://')) {\n      new_content = await fs.promises.readFile(new URL(url), 'utf-8')\n    } else {\n      let resp = await GET(url)\n      new_content = resp.data\n    }\n  } catch (e: any) {\n    console.error(e)\n    return {\n      success: false,\n      message: e.message,\n    }\n  }\n\n  hosts.last_refresh = dayjs().format('YYYY-MM-DD HH:mm:ss')\n  hosts.last_refresh_ms = new Date().getTime()\n\n  await setList(list)\n\n  if (old_content !== new_content) {\n    await setHostsContent(hosts_id, new_content)\n    broadcast(events.hosts_refreshed, hosts)\n    broadcast(events.hosts_content_changed, hosts_id)\n  }\n\n  return {\n    success: true,\n    data: { ...hosts },\n  }\n}\n"
  },
  {
    "path": "src/main/actions/hosts/setContent.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsContentObject } from '@common/data'\nimport { normalizeLineEndings } from '@common/newlines'\n\nexport default async (id: string, content: string) => {\n  const normalizedContent = normalizeLineEndings(content)\n  let d = await swhdb.collection.hosts.find<IHostsContentObject>((i) => i.id === id)\n  if (!d || !d._id) {\n    await swhdb.collection.hosts.insert({ id, content: normalizedContent })\n  } else {\n    await swhdb.collection.hosts.update((i) => i._id === d?._id, { content: normalizedContent })\n  }\n}\n"
  },
  {
    "path": "src/main/actions/hosts/setSystemHosts.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet, deleteHistory, getHistoryList, updateTrayTitle } from '@main/actions'\nimport tryToRun from '@main/actions/cmd/tryToRun'\nimport { broadcast } from '@main/core/agent'\nimport { swhdb } from '@main/data'\nimport safePSWD from '@main/libs/safePSWD'\nimport { IHostsWriteOptions } from '@main/types'\nimport { IHostsHistoryObject } from '@common/data'\nimport events from '@common/events'\nimport { getLineEndingForPlatform, normalizeLineEndings, restoreLineEndings } from '@common/newlines'\nimport { exec } from 'child_process'\nimport * as fs from 'fs'\nimport md5 from 'md5'\nimport md5File from 'md5-file'\nimport * as os from 'os'\nimport * as path from 'path'\nimport { v4 as uuid4 } from 'uuid'\nimport getPathOfSystemHosts from './getPathOfSystemHostsPath'\n\ninterface IWriteResult {\n  success: boolean\n  code?: string\n  message?: string\n  old_content?: string\n  new_content?: string\n}\n\nconst CONTENT_START = '# --- SWITCHHOSTS_CONTENT_START ---'\n\nlet sudo_pswd: string = ''\n\nconst checkAccess = async (fn: string): Promise<boolean> => {\n  try {\n    await fs.promises.access(fn, fs.constants.W_OK)\n    return true\n  } catch (e) {\n    // console.error(e)\n  }\n  return false\n}\n\nconst addHistory = async (content: string) => {\n  await swhdb.collection.history.insert({\n    id: uuid4(),\n    content,\n    add_time_ms: new Date().getTime(),\n  })\n\n  let history_limit = await configGet('history_limit')\n  if (typeof history_limit !== 'number' || history_limit <= 0) return\n\n  let lists = await swhdb.collection.history.all<IHostsHistoryObject>()\n  if (lists.length <= history_limit) {\n    return\n  }\n\n  for (let i = 0; i < lists.length - history_limit; i++) {\n    if (!lists[i] || !lists[i].id) break\n    await deleteHistory(lists[i].id)\n  }\n}\n\nconst writeWithSudo = (sys_hosts_path: string, content: string): Promise<IWriteResult> =>\n  new Promise((resolve) => {\n    let tmp_fn = path.join(os.tmpdir(), `swh_${new Date().getTime()}_${Math.random()}.txt`)\n    fs.writeFileSync(tmp_fn, content, 'utf-8')\n\n    let cmd = [\n      `echo '${sudo_pswd}' | sudo -S chmod 777 ${sys_hosts_path}`,\n      `cat \"${tmp_fn}\" > ${sys_hosts_path}`,\n      `echo '${sudo_pswd}' | sudo -S chmod 644 ${sys_hosts_path}`,\n      // , 'rm -rf ' + tmp_fn\n    ].join(' && ')\n\n    exec(cmd, function (error, stdout, stderr) {\n      // command output is in stdout\n      console.log('stdout', stdout)\n      console.log('stderr', stderr)\n\n      if (fs.existsSync(tmp_fn)) {\n        fs.unlinkSync(tmp_fn)\n      }\n\n      let result: IWriteResult\n\n      if (!error) {\n        console.log('success.')\n\n        result = {\n          success: true,\n        }\n      } else {\n        console.log('fail!')\n        sudo_pswd = ''\n\n        result = {\n          success: false,\n          message: stderr,\n        }\n      }\n\n      resolve(result)\n    })\n  })\n\nconst write = async (content: string, options?: IHostsWriteOptions): Promise<IWriteResult> => {\n  const sys_hosts_path = await getPathOfSystemHosts()\n  let old_content_raw = ''\n  try {\n    old_content_raw = await fs.promises.readFile(sys_hosts_path, 'utf-8')\n  } catch (e) {\n    console.error(e)\n  }\n  const lineEnding = getLineEndingForPlatform()\n  const diskContent = restoreLineEndings(content, lineEnding)\n  const fn_md5 = await md5File(sys_hosts_path)\n  const content_md5 = md5(diskContent)\n\n  if (fn_md5 === content_md5) {\n    // file not change\n    return { success: true }\n  }\n\n  const old_content = normalizeLineEndings(old_content_raw)\n\n  let has_access = await checkAccess(sys_hosts_path)\n  if (!has_access) {\n    if (options && options.sudo_pswd) {\n      sudo_pswd = safePSWD(options.sudo_pswd)\n    }\n\n    let platform = process.platform\n    if ((platform === 'darwin' || platform === 'linux') && sudo_pswd) {\n      let result = await writeWithSudo(sys_hosts_path, diskContent)\n      if (result.success) {\n        result.old_content = old_content\n        result.new_content = content\n      }\n\n      return result\n    }\n\n    return {\n      success: false,\n      code: 'no_access',\n    }\n  }\n\n  try {\n    await fs.promises.writeFile(sys_hosts_path, diskContent, 'utf-8')\n  } catch (e: any) {\n    console.error(e)\n    let code = 'fail'\n    if (e.code === 'EPERM' || e.message.includes('operation not permitted')) {\n      code = 'no_access'\n    }\n\n    return {\n      success: false,\n      code,\n      message: e.message,\n    }\n  }\n\n  return { success: true, old_content, new_content: content }\n}\n\nconst makeAppendContent = async (content: string): Promise<string> => {\n  const sys_hosts_path = await getPathOfSystemHosts()\n  const old_content = normalizeLineEndings(await fs.promises.readFile(sys_hosts_path, 'utf-8'))\n\n  let index = old_content.indexOf(CONTENT_START)\n  let new_content = index > -1 ? old_content.substring(0, index).trimEnd() : old_content\n\n  if (!content) {\n    return new_content + '\\n'\n  }\n\n  return `${new_content}\\n\\n${CONTENT_START}\\n\\n${content}`\n}\n\nconst setSystemHosts = async (\n  content: string,\n  options?: IHostsWriteOptions,\n): Promise<IWriteResult> => {\n  content = normalizeLineEndings(content)\n  let write_mode = await configGet('write_mode')\n  console.log(`write_mode: ${write_mode}`)\n  if (write_mode === 'append') {\n    content = await makeAppendContent(content)\n  }\n\n  let result = await write(content, options)\n  let { success, old_content } = result\n\n  if (success) {\n    if (typeof old_content === 'string') {\n      let histories = await getHistoryList()\n      if (histories.length === 0 || histories[histories.length - 1].content !== old_content) {\n        await addHistory(old_content)\n      }\n    }\n\n    await addHistory(content)\n    await updateTrayTitle()\n    broadcast(events.system_hosts_updated)\n\n    await tryToRun()\n  }\n\n  global.tracer.add(`w:${success ? 1 : 0}`)\n\n  return result\n}\n\nexport default setSystemHosts\n"
  },
  {
    "path": "src/main/actions/index.ts",
    "content": "/**\n * index\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport { default as ping } from './ping'\n\nexport { default as getBasicData } from './getBasicData'\nexport { default as getDataDir } from './getDataDir'\nexport { default as getDefaultDataDir } from './getDefaultDataDir'\n\nexport { default as configGet } from './config/get'\nexport { default as configSet } from './config/set'\nexport { default as configAll } from './config/all'\nexport { default as configUpdate } from './config/update'\n\nexport { default as getPathOfSystemHosts } from './hosts/getPathOfSystemHostsPath'\nexport { default as getHostsContent } from './hosts/getContent'\nexport { default as setHostsContent } from './hosts/setContent'\nexport { default as refreshHosts } from './hosts/refresh'\nexport { default as getSystemHosts } from './hosts/getSystemHosts'\nexport { default as setSystemHosts } from './hosts/setSystemHosts'\nexport { default as getHistoryList } from './hosts/getHistoryList'\nexport { default as deleteHistory } from './hosts/deleteHistory'\n\nexport { default as getList } from './list/getList'\nexport { default as setList } from './list/setList'\nexport { default as getItemFromList } from './list/getItem'\nexport { default as getContentOfList } from './list/getContentOfList'\nexport { default as moveToTrashcan } from './list/moveItemToTrashcan'\nexport { default as moveManyToTrashcan } from './list/moveManyToTrashcan'\n\nexport { default as getTrashcanList } from './trashcan/getList'\nexport { default as clearTrashcan } from './trashcan/clear'\nexport { default as deleteItemFromTrashcan } from './trashcan/deleteItem'\nexport { default as restoreItemFromTrashcan } from './trashcan/restoreItem'\n\nexport { default as cmdGetHistoryList } from './cmd/getHistoryList'\nexport { default as cmdDeleteHistory } from './cmd/deleteHistory'\nexport { default as cmdClearHistory } from './cmd/clearHistory'\nexport { default as cmdFocusMainWindow } from './cmd/focusMainWindow'\nexport { default as cmdToggleDevTools } from './cmd/toggleDevTools'\nexport { default as cmdChangeDataDir } from './cmd/changeDataDir'\n\nexport { default as openUrl } from './openUrl'\nexport { default as showItemInFolder } from './showItemInFolder'\nexport { default as updateTrayTitle } from './updateTrayTitle'\nexport { default as checkUpdate } from './checkUpdate'\nexport { default as downloadUpdate } from './downloadUpdate'\nexport { default as installUpdate } from './installUpdate'\nexport { default as closeMainWindow } from './closeMainWindow'\nexport { default as quit } from './quit'\n\nexport { default as findShow } from './find/show'\nexport { default as findBy } from './find/findBy'\nexport { default as findAddHistory } from './find/addHistory'\nexport { default as findGetHistory } from './find/getHistory'\nexport { default as findSetHistory } from './find/setHistory'\nexport { default as findAddReplaceHistory } from './find/addReplaceHistory'\nexport { default as findGetReplaceHistory } from './find/getReplaceHistory'\nexport { default as findSetReplaceHistory } from './find/setReplaceHistory'\n\nexport { default as migrateCheck } from './migrate/checkIfMigration'\nexport { default as migrateData } from './migrate/migrateData'\nexport { default as exportData } from './migrate/export'\nexport { default as importData } from './migrate/import'\nexport { default as importDataFromUrl } from './migrate/importFromUrl'\n"
  },
  {
    "path": "src/main/actions/installUpdate.ts",
    "content": "import * as updater from '@main/core/updater'\n\nexport default async (): Promise<boolean> => {\n  await updater.installUpdate()\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/list/getContentOfList.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet, getHostsContent } from '@main/actions'\nimport { IHostsListObject } from '@common/data'\nimport { flatten } from '@common/hostsFn'\nimport normalize, { INormalizeOptions } from '@common/normalize'\n\nconst getContentOfList = async (list: IHostsListObject[]): Promise<string> => {\n  const content_list: string[] = []\n  const flat = flatten(list).filter((item) => item.on)\n\n  for (let hosts of flat) {\n    let c = await getHostsContent(hosts.id)\n    content_list.push(c)\n  }\n\n  let content = content_list.join('\\n\\n')\n  // console.log(content)\n  let options: INormalizeOptions = {}\n\n  if (await configGet('remove_duplicate_records')) {\n    options.remove_duplicate_records = true\n  }\n\n  content = normalize(content, options)\n\n  return content\n}\n\nexport default getContentOfList\n"
  },
  {
    "path": "src/main/actions/list/getItem.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList } from '@main/actions'\nimport { IHostsListObject } from '@common/data'\nimport { findItemById } from '@common/hostsFn'\n\nexport default async (id: string): Promise<IHostsListObject | undefined> => {\n  let list = await getList()\n  return findItemById(list, id)\n}\n"
  },
  {
    "path": "src/main/actions/list/getList.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsListObject } from '@common/data'\n\nexport default async (): Promise<IHostsListObject[]> => {\n  return await swhdb.list.tree.all()\n}\n"
  },
  {
    "path": "src/main/actions/list/moveItemToTrashcan.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList } from '@main/actions'\nimport { broadcast } from '@main/core/agent'\nimport { swhdb } from '@main/data'\nimport { IHostsListObject, ITrashcanObject } from '@common/data'\nimport events from '@common/events'\nimport * as hostsFn from '@common/hostsFn'\n\nexport default async (id: string) => {\n  let list: IHostsListObject[] = await getList()\n\n  let node = hostsFn.findItemById(list, id)\n  if (!node) {\n    console.error(`can't find node by id #${id}.`)\n    return\n  }\n\n  if (node.on) {\n    // current hosts is in use, update system hosts\n    broadcast(events.toggle_item, node.id, false)\n  }\n\n  let obj: ITrashcanObject = {\n    data: {\n      ...node,\n      on: false,\n    },\n    add_time_ms: new Date().getTime(),\n    parent_id: hostsFn.getParentOfItem(list, id)?.id || null,\n  }\n\n  await swhdb.list.trashcan.push(obj)\n\n  hostsFn.deleteItemById(list, id)\n  await swhdb.list.tree.set(list)\n}\n"
  },
  {
    "path": "src/main/actions/list/moveManyToTrashcan.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { moveToTrashcan } from '@main/actions'\n\nexport default async function (ids: string[]) {\n  for (let id of ids) {\n    await moveToTrashcan(id)\n  }\n}\n"
  },
  {
    "path": "src/main/actions/list/setList.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsListObject } from '@common/data'\n\nexport default async (list: IHostsListObject[]) => {\n  await swhdb.list.tree.set(list)\n}\n"
  },
  {
    "path": "src/main/actions/migrate/checkIfMigration.ts",
    "content": "/**\n * checkIfMigration\n * check if migration is required\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getDataFolder from '@main/libs/getDataDir'\nimport { isDir } from '@main/utils/fs2'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\nexport default async (): Promise<boolean> => {\n  let dir = getDataFolder()\n  let old_data_file = path.join(dir, 'data.json')\n  let new_data_dir = path.join(dir, 'data')\n  let has_new_data =\n    isDir(new_data_dir) && isDir(path.join(new_data_dir, 'collection'))\n\n  return fs.existsSync(old_data_file) && !has_new_data\n}\n"
  },
  {
    "path": "src/main/actions/migrate/export.ts",
    "content": "/**\n * export\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getI18N from '@main/core/getI18N'\nimport { swhdb } from '@main/data'\nimport { dialog } from 'electron'\nimport { promises as fs } from 'fs'\nimport * as path from 'path'\nimport version from '@/version.json'\n\nexport default async (): Promise<string | null | false> => {\n  let { lang } = await getI18N()\n\n  let result = await dialog.showSaveDialog({\n    title: lang.import,\n    defaultPath: path.join(global.last_path || '', 'swh_data.json'),\n    properties: ['createDirectory', 'showOverwriteConfirmation'],\n  })\n\n  if (result.canceled || !result.filePath) {\n    return null\n  }\n\n  let target_dir = result.filePath\n\n  let data = await swhdb.toJSON()\n  try {\n    await fs.writeFile(\n      target_dir,\n      JSON.stringify({\n        data,\n        version,\n      }),\n      'utf-8',\n    )\n  } catch (e) {\n    console.error(e)\n    return false\n  }\n\n  return target_dir\n}\n"
  },
  {
    "path": "src/main/actions/migrate/import.ts",
    "content": "/**\n * import\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport importV3Data from '@main/actions/migrate/importV3Data'\nimport getI18N from '@main/core/getI18N'\nimport { swhdb } from '@main/data'\nimport { dialog } from 'electron'\nimport { promises as fs } from 'fs'\n\nexport default async (): Promise<boolean | null | string> => {\n  let { lang } = await getI18N()\n\n  let result = await dialog.showOpenDialog({\n    title: lang.import,\n    defaultPath: global.last_path,\n    filters: [\n      { name: 'JSON', extensions: ['json'] },\n      { name: 'All Files', extensions: ['*'] },\n    ],\n    properties: ['openFile'],\n  })\n\n  if (result.canceled) {\n    return null\n  }\n\n  let paths = result.filePaths\n  let fn = paths[0]\n  let content = await fs.readFile(fn, 'utf-8')\n\n  let data: any\n  try {\n    data = JSON.parse(content)\n  } catch (e) {\n    console.error(e)\n    return 'parse_error'\n  }\n\n  if (\n    typeof data !== 'object' ||\n    !data.version ||\n    !Array.isArray(data.version)\n  ) {\n    return 'invalid_data'\n  }\n\n  let { version } = data\n  if (version[0] === 3) {\n    // import v3 data\n    try {\n      await importV3Data(data)\n    } catch (e) {\n      console.error(e)\n      return 'invalid_v3_data'\n    }\n\n    return true\n  }\n\n  if (version[0] > 4) {\n    return 'new_version'\n  }\n\n  if (!data.data || typeof data.data !== 'object') {\n    return 'invalid_data_key'\n  }\n\n  await swhdb.loadJSON(data.data)\n\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/migrate/importFromUrl.ts",
    "content": "/**\n * importFromUrl\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport importV3Data from '@main/actions/migrate/importV3Data'\nimport { swhdb } from '@main/data'\nimport { GET } from '@main/libs/request'\n\nexport default async (url: string): Promise<boolean | null | string> => {\n  console.log(`import from url: ${url}`)\n  let res\n  try {\n    res = await GET(url)\n  } catch (e: any) {\n    console.error(e)\n    return e.message\n  }\n\n  // console.log(res)\n  if (res.status !== 200) {\n    return `error_${res.status}`\n  }\n\n  let data: any\n  if (typeof res.data === 'string') {\n    try {\n      data = JSON.parse(res.data)\n    } catch (e) {\n      console.error(e)\n      return 'parse_error'\n    }\n  } else {\n    data = res.data\n  }\n\n  if (typeof data !== 'object' || !data.version || !Array.isArray(data.version)) {\n    return 'invalid_data'\n  }\n\n  let { version } = data\n  if (version[0] === 3) {\n    // import v3 data\n    try {\n      await importV3Data(data)\n    } catch (e) {\n      console.error(e)\n      return 'invalid_v3_data'\n    }\n\n    return true\n  }\n\n  if (version[0] > 4) {\n    return 'new_version'\n  }\n\n  if (!data.data || typeof data.data !== 'object') {\n    return 'invalid_data_key'\n  }\n\n  await swhdb.loadJSON(data.data)\n\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/migrate/importV3Data.ts",
    "content": "/**\n * importV3Data\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\n// import data from v3 to v4\n\nimport { swhdb } from '@main/data'\nimport { cleanHostsList, flatten } from '@common/hostsFn'\nimport version from '@/version.json'\n\nexport default async (old_data: any) => {\n  old_data = cleanHostsList(old_data)\n\n  await swhdb.collection.hosts.remove()\n  await swhdb.list.tree.remove()\n\n  let { list } = old_data\n  let hosts = flatten(list)\n\n  for (let h of hosts) {\n    if (h.refresh_interval) {\n      h.refresh_interval *= 3600\n    }\n\n    h.type = h.where\n    delete h.where\n\n    await swhdb.collection.hosts.insert(h)\n    h.content = ''\n  }\n\n  await swhdb.list.tree.extend(...list)\n  await swhdb.dict.meta.set('version', version)\n}\n"
  },
  {
    "path": "src/main/actions/migrate/migrateData.ts",
    "content": "/**\n * migrateData\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\n// migrate data from v3 to v4\n\nimport importV3Data from '@main/actions/migrate/importV3Data'\nimport getDataFolder from '@main/libs/getDataDir'\nimport { IHostsBasicData, VersionType } from '@common/data'\nimport { cleanHostsList } from '@common/hostsFn'\nimport version from '@/version.json'\nimport * as fs from 'fs'\nimport path from 'path'\n\nconst readOldData = async (): Promise<IHostsBasicData> => {\n  const fn = path.join(await getDataFolder(), 'data.json')\n  const default_data: IHostsBasicData = {\n    list: [],\n    trashcan: [],\n    version: version as VersionType,\n  }\n\n  if (!fs.existsSync(fn)) {\n    return default_data\n  }\n\n  let content = await fs.promises.readFile(fn, 'utf-8')\n  try {\n    let data = JSON.parse(content) as IHostsBasicData\n    return cleanHostsList(data)\n  } catch (e) {\n    console.error(e)\n    return default_data\n  }\n}\n\nexport default async () => {\n  let old_data = await readOldData()\n  await importV3Data(old_data)\n}\n"
  },
  {
    "path": "src/main/actions/openUrl.ts",
    "content": "/**\n * @author oldj\n * @blog https://oldj.net\n */\n\nimport { shell } from 'electron'\n\nexport default async (url: string) => {\n  await shell.openExternal(url)\n}\n"
  },
  {
    "path": "src/main/actions/ping.ts",
    "content": "/**\n * ping\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nconst wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nexport default async (ms: number = 1000): Promise<string> => {\n  await wait(ms)\n  return 'pong'\n}\n"
  },
  {
    "path": "src/main/actions/quit.ts",
    "content": "/**\n * quit\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { app } from 'electron'\n\nexport default async () => {\n  console.log('to quit...')\n  try {\n    global.main_win.webContents.closeDevTools()\n  } catch (e) {\n    console.error(e)\n  }\n  app.quit()\n}\n"
  },
  {
    "path": "src/main/actions/showItemInFolder.ts",
    "content": "/**\n * showItemInFolder\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { isDir } from '@main/utils/fs2'\nimport { shell } from 'electron'\n\nexport default async (link: string) => {\n  if (isDir(link)) {\n    await shell.openPath(link)\n    return\n  }\n\n  await shell.showItemInFolder(link)\n}\n"
  },
  {
    "path": "src/main/actions/trashcan/clear.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { flatten } from '@common/hostsFn'\n\nexport default async () => {\n  let trashcan_items = await swhdb.list.trashcan.all()\n\n  let ids: string[] = []\n  trashcan_items.map((i) => {\n    ids.push(i.data.id)\n    flatten(i.data.children || []).map((i) => ids.push(i.id))\n  })\n\n  await swhdb.collection.hosts.delete((i) => ids.includes(i.id))\n  await swhdb.list.tree.delete((i) => ids.includes(i.id))\n  await swhdb.list.trashcan.remove()\n\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/trashcan/deleteItem.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { ITrashcanListObject } from '@common/data'\nimport { flatten } from '@common/hostsFn'\n\nexport default async (id: string): Promise<boolean> => {\n  // Permanently delete the specified item with id.\n\n  let trashcan_item: ITrashcanListObject | undefined = await swhdb.list.trashcan.find(\n    (i) => i.data.id === id,\n  )\n\n  if (!trashcan_item) {\n    console.log(`can't find trashcan_item with id #${id}.`)\n    return false\n  }\n\n  let ids: string[] = [id]\n  flatten(trashcan_item.data.children || []).map((i) => ids.push(i.id))\n\n  await swhdb.collection.hosts.delete((i) => ids.includes(i.id))\n  await swhdb.list.tree.delete((i) => i.id === id)\n  await swhdb.list.trashcan.delete((i) => i.data.id === id)\n\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/trashcan/getList.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { swhdb } from '@main/data'\nimport { IHostsListObject } from '@common/data'\n\nexport default async (): Promise<IHostsListObject[]> => {\n  return await swhdb.list.trashcan.all()\n}\n"
  },
  {
    "path": "src/main/actions/trashcan/restoreItem.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList, setList } from '@main/actions'\nimport { swhdb } from '@main/data'\nimport { getNodeById } from '@common/tree'\nimport { IHostsListObject, ITrashcanListObject } from '@common/data'\n\nexport default async (id: string): Promise<boolean> => {\n  let trashcan_item: ITrashcanListObject | undefined = await swhdb.list.trashcan.find(\n    (i) => i.data.id === id,\n  )\n\n  if (!trashcan_item) {\n    console.log(`can't find trashcan_item with id #${id}.`)\n    return false\n  }\n\n  let hosts = trashcan_item.data\n  if (!hosts || !hosts.id) {\n    console.log(`bad trashcan_item!`)\n    return false\n  }\n\n  let list = await getList()\n  let { parent_id } = trashcan_item\n\n  if (!parent_id) {\n    await setList([...list, hosts])\n  } else {\n    let parent_hosts = getNodeById<IHostsListObject>(list, parent_id)\n    if (!parent_hosts) {\n      console.log(`can't find parent_hosts with id #${parent_id}.`)\n      return false\n    }\n\n    parent_hosts.children = [...(parent_hosts.children || []), hosts]\n    await setList(list)\n  }\n\n  await swhdb.list.trashcan.delete((i) => i.data.id === id)\n\n  return true\n}\n"
  },
  {
    "path": "src/main/actions/updateTrayTitle.ts",
    "content": "/**\n * toggleTrayTitle\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList } from '@main/actions/index'\nimport { cfgdb } from '@main/data'\nimport { tray } from '@main/ui/tray'\nimport { flatten } from '@common/hostsFn'\n\nexport default async (show?: boolean, title?: string) => {\n  if (!tray) return\n\n  if (typeof show !== 'boolean') {\n    show = await cfgdb.dict.cfg.get('show_title_on_tray')\n  }\n\n  if (!show) {\n    tray.setTitle('')\n    return\n  }\n\n  if (!title) {\n    let list = await getList()\n    let on_items = flatten(list).filter((i) => i.on)\n    title = on_items.map((i) => i.title).join(',')\n    if (title.length > 20) {\n      title = title.substring(0, 17) + '...'\n    }\n  }\n  tray.setTitle(title)\n}\n"
  },
  {
    "path": "src/main/core/agent.ts",
    "content": "/**\n * agent\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ipcMain } from 'electron'\n\nexport const broadcast = (event: string, ...args: any[]) => {\n  ipcMain.emit('x_broadcast', null, { event, args })\n}\n"
  },
  {
    "path": "src/main/core/getI18N.ts",
    "content": "/**\n * getLang\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet } from '@main/actions'\nimport { LocaleName } from '@common/i18n'\nimport { I18N } from '@common/i18n'\n\nexport default async (locale?: LocaleName): Promise<I18N> => {\n  if (!locale) {\n    locale = await configGet('locale')\n  }\n\n  return new I18N(locale)\n}\n"
  },
  {
    "path": "src/main/core/message.ts",
    "content": "/**\n * message\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as actions from '@main/actions'\nimport { ActionData } from '@main/types'\nimport { ipcMain } from 'electron'\nimport { EventEmitter } from 'events'\nimport { IActionFunc } from '@common/types'\n\nconst ee = new EventEmitter()\nconst registered_clients: { [key: string]: any } = {}\n\nlet i_reg_idx = 0\nipcMain.on('x_reg', (e, d) => {\n  i_reg_idx++\n  let name = d?.name || i_reg_idx.toString()\n  registered_clients[name] = e.sender\n})\n\nipcMain.on('x_unreg', (e, d) => {\n  let name: string | undefined = d?.name\n\n  if (name === '*') {\n    for (let k in registered_clients) {\n      if (registered_clients.hasOwnProperty(k)) {\n        delete registered_clients[k]\n      }\n    }\n  } else if (name) {\n    delete registered_clients[name]\n  } else {\n    for (let k in registered_clients) {\n      if (registered_clients.hasOwnProperty(k) && registered_clients[k] === e.sender) {\n        delete registered_clients[k]\n        break\n      }\n    }\n  }\n})\n\nipcMain.on('x_broadcast', (e, d) => {\n  // 广播给内部\n  ee.emit(d.event, ...d.args)\n\n  // 广播给 renderer\n  for (let k in registered_clients) {\n    if (registered_clients.hasOwnProperty(k)) {\n      try {\n        registered_clients[k].send('y_broadcast', d)\n      } catch (e) {\n        console.error(e)\n      }\n    }\n  }\n})\n\nfunction sendBack(sender: any, event_name: string, data: [any] | [any, any]) {\n  try {\n    sender.send(event_name, ...data)\n  } catch (e) {\n    console.error(e)\n  }\n}\n\nipcMain.on('x_action', async (e, action_data: ActionData) => {\n  let sender = e.sender\n  let { action, data, callback } = action_data\n\n  let fn = actions[action]\n  if (typeof fn === 'function') {\n    let params = data || []\n    if (!Array.isArray(params)) {\n      params = [params]\n    }\n\n    try {\n      let obj: IActionFunc = { sender }\n      // @ts-ignore\n      let v = await fn.call(obj, ...params)\n      sendBack(sender, callback, [null, v])\n    } catch (e) {\n      console.error(e)\n      sendBack(sender, callback, [e])\n    }\n  } else {\n    let e = `unknow action [${action}].`\n    console.error(e)\n    sendBack(sender, callback, [e])\n  }\n})\n\nexport const on = (event: string, handler: (...args: any[]) => void) => {\n  ee.on(event, (d, ...args) => {\n    handler(d, ...args)\n  })\n}\n"
  },
  {
    "path": "src/main/core/popupMenu.ts",
    "content": "/**\n * contextMenu\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { broadcast } from '@main/core/agent'\nimport { IPopupMenuOption } from '@common/types'\nimport { ipcMain, Menu, MenuItem } from 'electron'\n\nipcMain.on('x_popup_menu', (e, options: IPopupMenuOption) => {\n  // console.log(options)\n  const menu = new Menu()\n\n  options.items.map((opt) => {\n    if (typeof opt._click_evt === 'string') {\n      let evt: string = opt._click_evt\n      opt.click = () => {\n        broadcast(evt)\n      }\n    }\n\n    const item = new MenuItem(opt)\n    menu.append(item)\n  })\n\n  menu.on('menu-will-close', () => {\n    // console.log('menu-will-close')\n    broadcast(`popup_menu_close:${options.menu_id}`)\n  })\n\n  menu.popup()\n})\n"
  },
  {
    "path": "src/main/core/updater.ts",
    "content": "import events from '@common/events'\nimport { AppDownloadedUpdateInfo, AppUpdateInfo, AppUpdateProgress } from '@common/update'\nimport { broadcast } from '@main/core/agent'\nimport { autoUpdater } from 'electron-updater'\nimport type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater'\n\nlet isBound = false\nlet currentUpdateInfo: AppUpdateInfo | null = null\nlet downloadedUpdateInfo: AppDownloadedUpdateInfo | null = null\n\nfunction normalizeReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | null {\n  if (!releaseNotes) {\n    return null\n  }\n\n  if (typeof releaseNotes === 'string') {\n    return releaseNotes\n  }\n\n  return releaseNotes\n    .map((item) => {\n      if (!item.note) {\n        return ''\n      }\n\n      if (!item.version) {\n        return item.note\n      }\n\n      return `## ${item.version}\\n${item.note}`\n    })\n    .filter(Boolean)\n    .join('\\n\\n')\n}\n\nfunction toAppUpdateInfo(info: UpdateInfo): AppUpdateInfo {\n  return {\n    version: info.version,\n    releaseName: info.releaseName || null,\n    releaseNotes: normalizeReleaseNotes(info.releaseNotes),\n  }\n}\n\nfunction toProgressPayload(info: ProgressInfo): AppUpdateProgress {\n  return {\n    percent: info.percent,\n    transferred: info.transferred,\n    total: info.total,\n    bytesPerSecond: info.bytesPerSecond,\n  }\n}\n\nfunction toDownloadedUpdateInfo(event: UpdateDownloadedEvent): AppDownloadedUpdateInfo {\n  return {\n    ...(currentUpdateInfo || toAppUpdateInfo(event)),\n    downloadedFile: event.downloadedFile || null,\n  }\n}\n\nfunction bindUpdaterEvents() {\n  if (isBound) {\n    return\n  }\n\n  // Bind lazily so test environments that stub Electron do not initialize\n  // the updater before an explicit update action is requested.\n  isBound = true\n  autoUpdater.autoDownload = false\n  autoUpdater.allowPrerelease = false\n  autoUpdater.autoInstallOnAppQuit = false\n\n  autoUpdater.on('update-available', (info) => {\n    currentUpdateInfo = toAppUpdateInfo(info)\n    downloadedUpdateInfo = null\n    console.log('update-available', currentUpdateInfo)\n    broadcast(events.new_version, currentUpdateInfo)\n  })\n\n  autoUpdater.on('update-not-available', (info) => {\n    console.log('update-not-available', info)\n    currentUpdateInfo = null\n    downloadedUpdateInfo = null\n  })\n\n  autoUpdater.on('download-progress', (info) => {\n    const payload = toProgressPayload(info)\n    console.log('download-progress', payload)\n    broadcast(events.update_download_progress, payload)\n  })\n\n  autoUpdater.on('update-downloaded', (event) => {\n    downloadedUpdateInfo = toDownloadedUpdateInfo(event)\n    console.log('update-downloaded', downloadedUpdateInfo)\n    broadcast(events.update_downloaded, downloadedUpdateInfo)\n  })\n\n  autoUpdater.on('error', (error, message) => {\n    console.error('autoUpdater error', message || '', error)\n  })\n}\n\nexport async function checkUpdate(): Promise<AppUpdateInfo | null> {\n  bindUpdaterEvents()\n  const result = await autoUpdater.checkForUpdates()\n  console.log('updater checkForUpdates', result)\n\n  if (!result?.isUpdateAvailable) {\n    currentUpdateInfo = null\n    downloadedUpdateInfo = null\n    return null\n  }\n\n  // Normalize the updater payload so renderer code does not depend on\n  // electron-updater's version-specific event shape.\n  currentUpdateInfo = toAppUpdateInfo(result.updateInfo)\n  return currentUpdateInfo\n}\n\nexport async function downloadUpdate() {\n  bindUpdaterEvents()\n\n  if (!currentUpdateInfo) {\n    throw new Error('No update is available to download.')\n  }\n\n  downloadedUpdateInfo = null\n  return autoUpdater.downloadUpdate()\n}\n\nexport async function installUpdate() {\n  bindUpdaterEvents()\n\n  if (!downloadedUpdateInfo) {\n    throw new Error('No downloaded update is ready to install.')\n  }\n\n  global.is_will_quit = true\n  autoUpdater.quitAndInstall()\n}\n"
  },
  {
    "path": "src/main/data/index.ts",
    "content": "/**\n * db\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as path from 'path'\nimport PotDb from 'potdb'\nimport { app } from 'electron'\nimport getDataFolder from '@main/libs/getDataDir'\nimport getConfigFolder from '@main/libs/getConfigDir'\n\nlet localdb: PotDb\nlet cfgdb: PotDb\nlet swhdb: PotDb\n\nif (!global.localdb) {\n  let db_dir: string = path.join(app.getPath('userData'), 'swh_local')\n  localdb = new PotDb(db_dir)\n  console.log(`local db: ${localdb.dir}`)\n  global.localdb = localdb\n} else {\n  localdb = global.localdb\n}\n\nif (!global.cfgdb) {\n  let db_dir: string = path.join(getConfigFolder(), 'config')\n  cfgdb = new PotDb(db_dir)\n  console.log(`config db: ${cfgdb.dir}`)\n  global.cfgdb = cfgdb\n} else {\n  cfgdb = global.cfgdb\n}\n\nasync function getSwhDb(): Promise<PotDb> {\n  if (!swhdb) {\n    global.data_dir = await localdb.dict.local.get('data_dir')\n    let db_dir: string = path.join(getDataFolder(), 'data')\n    swhdb = new PotDb(db_dir)\n    console.log(`data db: ${swhdb.dir}`)\n    global.swhdb = swhdb\n  }\n\n  return swhdb\n}\n\nexport { localdb, cfgdb, swhdb, getSwhDb }\n"
  },
  {
    "path": "src/main/http/api/index.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { Hono } from 'hono'\nimport list from './list'\nimport toggle from './toggle'\n\nconst router = new Hono()\n\nrouter.get('/list', list)\nrouter.get('/toggle', toggle)\n\nexport default router\n"
  },
  {
    "path": "src/main/http/api/list.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList } from '@main/actions'\nimport { IHostsListObject } from '@common/data'\nimport { flatten } from '@common/hostsFn'\nimport type { Context } from 'hono'\n\nconst list = async (c: Context) => {\n  let list: IHostsListObject[]\n  try {\n    list = await getList()\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    return c.json({\n      success: false,\n      message,\n    })\n  }\n\n  list = flatten(list)\n\n  return c.json({\n    success: true,\n    data: list,\n  })\n}\n\nexport default list\n"
  },
  {
    "path": "src/main/http/api/toggle.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { getList } from '@main/actions'\nimport { broadcast } from '@main/core/agent'\nimport events from '@common/events'\nimport { findItemById } from '@common/hostsFn'\nimport type { Context } from 'hono'\n\nconst toggle = async (c: Context) => {\n  const id = c.req.query('id')\n  console.log(`http_api toggle: ${id}`)\n  if (!id) {\n    return c.text('bad id.')\n  }\n\n  let list = await getList()\n  let item = findItemById(list, id)\n  if (!item) {\n    return c.text('not found.')\n  }\n\n  broadcast(events.toggle_item, id, !item.on)\n  return c.text('ok')\n}\n\nexport default toggle\n"
  },
  {
    "path": "src/main/http/index.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { http_api_port } from '@common/constants'\nimport { serve } from '@hono/node-server'\nimport { Hono } from 'hono'\nimport type { Context, Next } from 'hono'\nimport api_router from './api/index'\n\nexport const app = new Hono()\n\nexport const requestLogger = async (c: Context, next: Next) => {\n  const url = new URL(c.req.url)\n\n  console.log(\n    `> \"${new Date().toString()}\"`,\n    c.req.method,\n    `${url.pathname}${url.search}`,\n    `\"${c.req.header('user-agent')}\"`,\n  )\n  await next()\n}\n\nexport const homeHandler = (c: Context) => c.text('Hello SwitchHosts!')\n\nexport const remoteTestHandler = (c: Context) => c.text(`# remote-test\\n# ${new Date().toString()}`)\n\napp.use('*', requestLogger)\n\napp.get('/', homeHandler)\n\napp.get('/remote-test', remoteTestHandler)\n\napp.route('/api', api_router)\n\nlet server: ReturnType<typeof serve> | undefined\n\nexport const start = (http_api_only_local: boolean): boolean => {\n  try {\n    let listenIp = http_api_only_local ? '127.0.0.1' : '0.0.0.0'\n    server = serve(\n      {\n        fetch: app.fetch,\n        port: http_api_port,\n        hostname: listenIp,\n      },\n      () => {\n        console.log(`SwitchHosts HTTP server is listening on port ${http_api_port}!`)\n        console.log(`-> http://${listenIp}:${http_api_port}`)\n      },\n    )\n  } catch (e) {\n    console.error(e)\n    return false\n  }\n\n  return true\n}\n\nexport const stop = () => {\n  if (!server) return\n\n  try {\n    server.close()\n    server = undefined\n  } catch (e) {\n    console.error(e)\n  }\n}\n"
  },
  {
    "path": "src/main/libs/cron.ts",
    "content": "/**\n * cron\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { checkUpdate, configGet, getList, refreshHosts } from '@main/actions'\nimport { broadcast } from '@main/core/agent'\nimport { IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport { flatten } from '@common/hostsFn'\n\nlet t: any\nlet ts_last_server_check = 0\n\nconst isNeedRefresh = (hosts: IHostsListObject): boolean => {\n  let { refresh_interval, last_refresh_ms, url } = hosts\n\n  if (!refresh_interval || refresh_interval <= 0) return false\n  if (!url || !url.match(/^https?:\\/\\//i)) return false\n\n  if (!last_refresh_ms) return true\n\n  let ts = new Date().getTime()\n  if ((ts - last_refresh_ms) / 1000 >= refresh_interval) {\n    return true\n  }\n\n  // false\n  return false\n}\n\nconst checkRefresh = async () => {\n  // console.log('check refresh...')\n  let list = await getList()\n  let remote_hosts = flatten(list).filter((h) => h.type === 'remote')\n\n  for (let hosts of remote_hosts) {\n    if (isNeedRefresh(hosts)) {\n      try {\n        await refreshHosts(hosts.id)\n      } catch (e) {\n        console.error(e)\n      }\n    }\n  }\n\n  broadcast(events.reload_list)\n}\n\nconst checkServer = async () => {\n  let auto_download_update = await configGet('auto_download_update')\n  if (!auto_download_update) return\n\n  let ts = new Date().getTime()\n  if (!ts_last_server_check || ts - ts_last_server_check > 3600 * 1000) {\n    await checkUpdate()\n    ts_last_server_check = ts\n  }\n}\n\nconst check = async () => {\n  checkRefresh().catch((e) => console.error(e))\n\n  checkServer().catch((e) => console.error(e))\n\n  global.tracer.emit().catch((e) => console.error(e))\n}\n\nexport const start = () => {\n  setTimeout(checkServer, 5000)\n\n  clearInterval(t)\n  t = setInterval(check, 60 * 1000)\n}\n"
  },
  {
    "path": "src/main/libs/getConfigDir.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as path from 'path'\nimport { homedir } from 'os'\n\nexport default (): string => {\n  // todo data folder should be current working dir for portable version\n\n  return path.join(homedir(), '.SwitchHosts')\n}\n"
  },
  {
    "path": "src/main/libs/getDataDir.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as path from 'path'\nimport { homedir } from 'os'\n\nexport function getDefaultDataDir() {\n  return path.join(homedir(), '.SwitchHosts')\n}\n\nexport default (): string => {\n  // todo data folder should be current working dir for portable version\n\n  return global.data_dir || getDefaultDataDir()\n}\n"
  },
  {
    "path": "src/main/libs/getIndex.ts",
    "content": "/**\n * getIndex\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport isDev from '@main/libs/isDev'\nimport path from 'path'\nimport * as url from 'url'\n\nexport default (): string => {\n  let index: string\n  if (isDev()) {\n    index = 'http://127.0.0.1:8220'\n  } else {\n    index = url.format({\n      pathname: path.join(__dirname, 'index.html'),\n      protocol: 'file:',\n      slashes: true,\n    })\n  }\n\n  return index\n}\n"
  },
  {
    "path": "src/main/libs/isDev.ts",
    "content": "/**\n * isDev\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport default () => {\n  return process.env.NODE_ENV === 'development'\n}\n"
  },
  {
    "path": "src/main/libs/request.ts",
    "content": "/**\n * request\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet } from '@main/actions'\nimport axios, { AxiosRequestConfig } from 'axios'\nimport querystring from 'querystring'\nimport version from '@/version.json'\n\ninterface IParams {\n  [key: string]: string | string[] | number\n}\n\ninterface IRequestOptions {\n  timeout?: number\n  headers?: { [key: string]: string | string[] }\n}\n\nexport const GET = async (\n  url: string,\n  params: IParams | null = null,\n  options: IRequestOptions = {},\n) => {\n  let s = ''\n  if (params) {\n    s = querystring.stringify(params)\n  }\n  if (s) {\n    url += (url.includes('?') ? '&' : '?') + s\n  }\n\n  const default_headers = {\n    'user-agent': `${global.ua} SwitchHosts/${version.join('.')}`,\n  }\n\n  let configs: AxiosRequestConfig = {\n    timeout: options.timeout || 30000,\n    headers: {\n      ...default_headers,\n      ...options.headers,\n    },\n  }\n\n  if (await configGet('use_proxy')) {\n    let protocol = await configGet('proxy_protocol')\n    let host = await configGet('proxy_host')\n    let port = await configGet('proxy_port')\n\n    if (host && port) {\n      configs.proxy = { protocol, host, port }\n    }\n  }\n\n  const instance = axios.create(configs)\n\n  return await instance.get(url)\n}\n"
  },
  {
    "path": "src/main/libs/safePSWD.ts",
    "content": "/**\n * safe-pswd\n * @author oldj\n * @blog https://oldj.net\n */\n\nexport default (pswd: string): string => {\n  return (\n    pswd\n      .replace(/\\\\/g, '\\\\\\\\')\n      //.replace(/'/g, \"\\\\''\")\n      .replace(/'/g, '\\\\x27')\n  )\n}\n"
  },
  {
    "path": "src/main/libs/tracer.ts",
    "content": "import { configGet } from '@main/actions'\nimport { GET } from '@main/libs/request'\nimport { server_url } from '@common/constants'\n\nclass Tracer {\n  data: string[]\n\n  constructor() {\n    this.data = []\n  }\n\n  add(action: string) {\n    this.data.push(action)\n  }\n\n  async emit() {\n    if (this.data.length === 0) return\n\n    let send_usage_data = await configGet('send_usage_data')\n    if (send_usage_data) {\n      // Tracking is temporarily disabled.\n      console.log('Tracking is temporarily disabled.')\n      // console.log('send usage data...')\n      // await GET(`${server_url}/api/tick/`, {\n      //   sid: global.session_id,\n      //   t: 1,\n      //   a: this.data.join(','),\n      // })\n    }\n\n    this.data = []\n  }\n}\n\nexport default Tracer\n"
  },
  {
    "path": "src/main/main.ts",
    "content": "/**\n * main.ts\n * @author oldj\n * @homepage https://oldj.net\n */\n\nimport { configAll, configGet } from '@main/actions'\nimport '@main/core/agent'\nimport * as message from '@main/core/message'\nimport '@main/core/popupMenu'\nimport '@main/data'\nimport * as http_api from '@main/http'\nimport * as cron from '@main/libs/cron'\nimport getIndex from '@main/libs/getIndex'\nimport isDev from '@main/libs/isDev'\nimport Tracer from '@main/libs/tracer'\nimport checkSystemLocale from '@main/ui/checkSystemLocale'\nimport * as find from '@main/ui/find'\nimport { makeMainMenu } from '@main/ui/menu'\nimport '@main/ui/tray'\nimport version from '@/version.json'\nimport { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'\nimport windowStateKeeper from 'electron-window-state'\nimport * as path from 'path'\nimport { v4 as uuid4 } from 'uuid'\nimport { getSwhDb } from '@main/data'\n\nlet win: BrowserWindow | null\n\nconst createWindow = async () => {\n  await getSwhDb()\n  const configs = await configAll()\n\n  let main_window_state = windowStateKeeper({\n    defaultWidth: 800,\n    defaultHeight: 480,\n  })\n\n  let linux_icon = {}\n  if (process.platform === 'linux') {\n    linux_icon = {\n      icon: path.join(__dirname, '/assets/icon.png'),\n    }\n  }\n\n  win = new BrowserWindow({\n    x: main_window_state.x,\n    y: main_window_state.y,\n    width: main_window_state.width,\n    height: main_window_state.height,\n    minWidth: 300,\n    minHeight: 200,\n    autoHideMenuBar: true,\n    titleBarStyle: 'hiddenInset',\n    frame: configs.use_system_window_frame || false,\n    hasShadow: true,\n    webPreferences: {\n      contextIsolation: true,\n      preload: path.join(__dirname, 'preload.js'),\n      spellcheck: true,\n    },\n    ...linux_icon,\n  })\n\n  main_window_state.manage(win)\n\n  const ses = win.webContents.session\n  // console.log(ses.getUserAgent())\n  global.ua = ses.getUserAgent()\n  global.main_win = win\n\n  if (configs.hide_at_launch) {\n    win.hide()\n  }\n\n  let hide_dock_icon = await configGet('hide_dock_icon')\n  if (hide_dock_icon) {\n    app.dock && app.dock.hide()\n  } else {\n    app.dock && app.dock.show().catch((e) => console.error(e))\n  }\n\n  console.log('isDev: ', isDev())\n  if (isDev()) {\n    process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = '1' // eslint-disable-line require-atomic-updates\n  }\n\n  makeMainMenu(configs.locale)\n\n  win.loadURL(getIndex()).catch((e) => console.error(e))\n\n  if (isDev()) {\n    // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready\n    win.webContents.once('dom-ready', () => {\n      win!.webContents.openDevTools()\n    })\n  }\n\n  win.on('close', (e: Electron.Event) => {\n    if (global.is_will_quit) {\n      win = null\n    } else {\n      e.preventDefault()\n      win?.hide()\n    }\n  })\n\n  win.on('closed', () => {\n    win = null\n  })\n\n  ipcMain.handle('dark-mode:toggle', () => {\n    if (nativeTheme.shouldUseDarkColors) {\n      nativeTheme.themeSource = 'light'\n    } else {\n      nativeTheme.themeSource = 'dark'\n    }\n    return nativeTheme.shouldUseDarkColors\n  })\n\n  ipcMain.handle('dark-mode:dark', () => {\n    nativeTheme.themeSource = 'dark'\n  })\n\n  ipcMain.handle('dark-mode:light', () => {\n    nativeTheme.themeSource = 'light'\n  })\n\n  ipcMain.handle('dark-mode:system', () => {\n    nativeTheme.themeSource = 'system'\n  })\n}\n\nconst gotTheLock = app.requestSingleInstanceLock()\nif (!gotTheLock) {\n  app.quit()\n} else {\n  app.on('second-instance', (event, commandLine, workingDirectory) => {\n    if (win) {\n      if (win.isMinimized()) {\n        win.restore()\n      }\n      win.focus()\n    }\n  })\n}\n\nconst onActive = async () => {\n  if (win === null) {\n    await createWindow()\n  } else if (win.isMinimized()) {\n    await win.restore()\n  }\n  win?.show()\n}\n\nglobal.tracer = new Tracer()\n\napp.on('ready', async () => {\n  console.log(`VERSION: ${version.join('.')}`)\n  global.session_id = uuid4()\n  await checkSystemLocale()\n\n  await createWindow()\n  cron.start()\n\n  let http_api_on = await configGet('http_api_on')\n  let http_api_only_local = await configGet('http_api_only_local')\n  if (http_api_on) {\n    http_api.start(http_api_only_local)\n  }\n\n  find.makeWindow()\n})\n\napp.on('window-all-closed', () => {\n  if (process.platform !== 'darwin') {\n    app.quit()\n  }\n})\n\napp.on('before-quit', () => (global.is_will_quit = true))\napp.on('activate', onActive)\nmessage.on('active_main_window', onActive)\n"
  },
  {
    "path": "src/main/preload.ts",
    "content": "/**\n * preload\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { Actions } from '@common/types'\nimport { IPopupMenuOption } from '@common/types'\nimport { contextBridge, ipcRenderer } from 'electron'\nimport { EventEmitter } from 'events'\n\ndeclare global {\n  interface Window {\n    _agent: typeof _agent\n  }\n}\n\nexport type EventHandler = (...args: any[]) => void\n\nconst ee = new EventEmitter()\n\nlet x_get_idx = 0\n\nconst callAction = (action: keyof Actions, ...params: any[]) => {\n  const callback = ['_cb', new Date().getTime(), x_get_idx++].join('_')\n\n  return new Promise((resolve, reject) => {\n    ipcRenderer.send('x_action', {\n      action,\n      data: params,\n      callback,\n    })\n\n    ipcRenderer.once(callback, (sender, err, d) => {\n      if (err) {\n        reject(err)\n      } else {\n        resolve(d)\n      }\n    })\n  })\n}\n\nconst broadcast = <T>(event: string, ...args: any) => {\n  // 广播消息给所有 render 窗口\n  ipcRenderer.send('x_broadcast', { event, args })\n}\n\nconst on = (event: string, handler: EventHandler) => {\n  // console.log(`on [${event}]`)\n  ee.on(event, handler)\n  return () => off(event, handler)\n}\n\nconst once = (event: string, handler: EventHandler) => {\n  // console.log(`once [${event}]`)\n  ee.once(event, handler)\n  return () => off(event, handler)\n}\n\nconst off = (event: string, handler: EventHandler) => {\n  // console.log(`off [${event}]`)\n  ee.off(event, handler)\n}\n\nconst popupMenu = (options: IPopupMenuOption) => {\n  ipcRenderer.send('x_popup_menu', options)\n}\n\nipcRenderer.on('y_broadcast', (e, d) => {\n  // 接收其他（包括当前） render 窗口广播的消息\n  ee.emit(d.event, ...d.args)\n})\n\nipcRenderer.send('x_reg')\n\n// 窗口销毁时 unreg\nwindow.addEventListener('beforeunload', () => {\n  ipcRenderer.send('x_unreg')\n})\n\nconst _agent = {\n  call: callAction,\n  broadcast,\n  on,\n  once,\n  off,\n  popupMenu,\n  platform: process.platform,\n  darkModeToggle: (theme?: 'dark' | 'light' | 'system') =>\n    ipcRenderer.invoke(`dark-mode:${theme ?? 'toggle'}`),\n}\n\ncontextBridge.exposeInMainWorld('_agent', _agent)\n"
  },
  {
    "path": "src/main/types.d.ts",
    "content": "/**\n * index\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport Tracer from '@main/libs/tracer'\nimport { LocaleName } from '@common/i18n'\nimport SwhDb from 'potdb'\nimport { BrowserWindow } from 'electron'\nimport * as actions from '@main/actions'\n\nexport interface ActionData {\n  action: keyof typeof actions\n  data?: any\n  callback: string\n}\n\nexport interface IHostsWriteOptions {\n  sudo_pswd?: string\n}\n\ndeclare global {\n  var data_dir: string | undefined\n  var swhdb: SwhDb\n  var cfgdb: SwhDb\n  var localdb: SwhDb\n  var ua: string // user agent\n  var session_id: string // A random value, refreshed every time the app starts, used to identify different startup sessions.\n  var main_win: BrowserWindow\n  var find_win: BrowserWindow | null\n  var last_path: string // the last path opened by SwitchHosts\n  var tracer: Tracer\n  var is_will_quit: boolean\n  var system_locale: LocaleName\n}\n"
  },
  {
    "path": "src/main/ui/checkSystemLocale.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { languages, LocaleName } from '@common/i18n'\nimport { app } from 'electron'\n\nconst isLocaleName = (locale: string): locale is LocaleName => {\n  return Object.keys(languages).includes(locale)\n}\n\nexport default async () => {\n  let locale = app.getLocale()\n  if (!locale) {\n    return\n  }\n\n  console.log(`System locale: ${locale}`)\n  if (locale.startsWith('en')) {\n    locale = 'en'\n  } else if (locale.startsWith('zh')) {\n    locale = 'zh'\n  } else if (locale.startsWith('fr')) {\n    locale = 'fr'\n  } else if (locale.startsWith('de')) {\n    locale = 'de'\n  } else if (locale.startsWith('ja')) {\n    locale = 'ja'\n  } else if (locale.startsWith('tr')) {\n    locale = 'tr'\n  } else if (locale.startsWith('ko')) {\n    locale = 'ko'\n  }\n\n  if (!isLocaleName(locale)) {\n    return\n  }\n\n  global.system_locale = locale\n}\n"
  },
  {
    "path": "src/main/ui/find.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { broadcast } from '@main/core/agent'\nimport getIndex from '@main/libs/getIndex'\nimport isDev from '@main/libs/isDev'\nimport events from '@common/events'\nimport { BrowserWindow } from 'electron'\nimport path from 'path'\n\nconst makeWindow = () => {\n  let win: BrowserWindow | null\n  win = new BrowserWindow({\n    // frame: false,\n    // titleBarStyle: 'hidden',\n    hasShadow: true,\n    // resizable: false,\n    // transparent: true,\n    width: 480,\n    height: 400,\n    minWidth: 400,\n    minHeight: 400,\n    maximizable: false,\n    minimizable: false,\n    skipTaskbar: true,\n    show: false,\n    autoHideMenuBar: true,\n    webPreferences: {\n      contextIsolation: true,\n      preload: path.join(__dirname, 'preload.js'),\n      spellcheck: true,\n    },\n  })\n\n  // win.setVisibleOnAllWorkspaces(true, {\n  //   visibleOnFullScreen: true,\n  // })\n\n  win.loadURL(`${getIndex()}#/find`).catch((e) => console.error(e))\n\n  // win.on('blur', () => win?.hide())\n\n  win.on('close', (e: Electron.Event) => {\n    if (global.is_will_quit) {\n      win = null\n      global.find_win = null\n    } else {\n      e.preventDefault()\n      win?.hide()\n      broadcast(events.close_find)\n    }\n  })\n\n  if (isDev()) {\n    // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready\n    win.webContents.once('dom-ready', () => {\n      win!.webContents.openDevTools()\n    })\n  }\n\n  global.find_win = win\n\n  return win\n}\n\nexport { makeWindow }\n"
  },
  {
    "path": "src/main/ui/menu.ts",
    "content": "/**\n * @author oldj\n * @blog https://oldj.net\n */\n\nimport { findShow } from '@main/actions'\nimport events from '@common/events'\nimport { BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron'\nimport { I18N, LocaleName } from '@common/i18n'\nimport { homepage_url, feedback_url } from '@common/constants'\nimport { broadcast } from '@main/core/agent'\n\nexport const makeMainMenu = (locale: LocaleName = 'en') => {\n  const i18n = new I18N(locale)\n  const { lang } = i18n\n\n  const template: MenuItemConstructorOptions[] = [\n    {\n      label: lang.file,\n      submenu: [\n        {\n          label: lang.new,\n          accelerator: 'CommandOrControl+N',\n          click: () => {\n            broadcast(events.add_new)\n          },\n        },\n        {\n          type: 'separator',\n          // },\n          // {\n          //   label: lang.import,\n          //   accelerator: 'Alt+CommandOrControl+I',\n          //   click: () => {\n          //   }\n          // },\n          // {\n          //   label: lang.export,\n          //   accelerator: 'Alt+CommandOrControl+E',\n          //   click: () => {\n          //   }\n          // },\n          // {\n          //   type: 'separator'\n        },\n        {\n          label: lang.preferences,\n          accelerator: 'CommandOrControl+,',\n          click: () => {\n            broadcast(events.show_preferences)\n          },\n        },\n      ],\n    },\n    {\n      label: lang.edit,\n      submenu: [\n        {\n          role: 'undo',\n          label: lang.undo,\n        },\n        {\n          role: 'redo',\n          label: lang.redo,\n        },\n        {\n          type: 'separator',\n        },\n        {\n          role: 'cut',\n          label: lang.cut,\n        },\n        {\n          role: 'copy',\n          label: lang.copy,\n        },\n        {\n          role: 'paste',\n          label: lang.paste,\n        },\n        {\n          role: 'delete',\n          label: lang.delete,\n        },\n        {\n          role: 'selectAll',\n          label: lang.select_all,\n        },\n        {\n          type: 'separator',\n        },\n        {\n          label: lang.comment_current_line,\n          accelerator: 'CommandOrControl+/',\n          click() {\n            broadcast(events.toggle_comment)\n          },\n        },\n        {\n          label: lang.find_and_replace,\n          accelerator: 'CommandOrControl+F',\n          click() {\n            findShow()\n          },\n        },\n      ],\n    },\n    {\n      label: lang.view,\n      submenu: [\n        {\n          label: lang.reload,\n          accelerator: 'CmdOrCtrl+R',\n          click(_item: MenuItem, focusedWindow) {\n            if (!(focusedWindow instanceof BrowserWindow)) return\n            if (focusedWindow) focusedWindow.reload()\n          },\n        },\n        {\n          label: lang.toggle_developer_tools, // 'Toggle Developer Tools',\n          accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',\n          click(_item: MenuItem, focusedWindow) {\n            if (!(focusedWindow instanceof BrowserWindow)) return\n            if (focusedWindow) focusedWindow.webContents.toggleDevTools()\n          },\n        },\n        {\n          type: 'separator',\n        },\n        {\n          role: 'resetZoom',\n          label: lang.reset_zoom,\n        },\n        {\n          role: 'zoomIn',\n          label: lang.zoom_in,\n        },\n        {\n          role: 'zoomOut',\n          label: lang.zoom_out,\n        },\n        {\n          type: 'separator',\n        },\n        {\n          role: 'togglefullscreen',\n          label: lang.toggle_full_screen,\n        },\n      ],\n    },\n    {\n      label: lang.window,\n      role: 'window',\n      submenu: [\n        {\n          role: 'minimize',\n          label: lang.minimize,\n        },\n        {\n          role: 'close',\n          label: lang.close,\n        },\n      ],\n    },\n    {\n      label: lang.help,\n      role: 'help',\n      submenu: [\n        // {\n        //   label: lang.check_update,\n        //   click () {\n        //     checkUpdate.check()\n        //   }\n        // },\n        // {\n        //   type: 'separator',\n        // },\n        {\n          label: lang.feedback,\n          click() {\n            shell.openExternal(feedback_url).catch((e) => console.log(e))\n          },\n        },\n        {\n          label: lang.homepage,\n          click() {\n            shell.openExternal(homepage_url).catch((e) => console.log(e))\n          },\n        },\n      ],\n    },\n  ]\n\n  const name = 'SwitchHosts'\n  const os = process.platform\n  if (os === 'darwin') {\n    template.unshift({\n      label: name,\n      submenu: [\n        {\n          label: lang.about,\n          //role: 'about',\n          click: () => {\n            broadcast(events.show_about)\n          },\n        },\n        {\n          type: 'separator',\n        },\n        // {\n        //     role: 'services',\n        //     submenu: []\n        // },\n        // {\n        //     type: 'separator'\n        // },\n        {\n          role: 'hide',\n          label: lang.hide,\n        },\n        {\n          role: 'hideOthers',\n          label: lang.hide_others,\n        },\n        {\n          role: 'unhide',\n          label: lang.unhide,\n        },\n        {\n          type: 'separator',\n        },\n        {\n          role: 'quit',\n          label: lang.quit,\n        },\n      ],\n    })\n    // Edit menu.\n    /*template[2].submenu.push(\n     {\n     type: 'separator'\n     },\n     {\n     label: 'Speech',\n     submenu: [\n     {\n     role: 'startspeaking'\n     },\n     {\n     role: 'stopspeaking'\n     }\n     ]\n     }\n     );*/\n    // Window menu.\n    template[4].submenu = [\n      {\n        accelerator: 'CmdOrCtrl+W',\n        role: 'close',\n        label: lang.close,\n      },\n      {\n        accelerator: 'CmdOrCtrl+M',\n        role: 'minimize',\n        label: lang.minimize,\n      },\n      {\n        role: 'zoom',\n        label: lang.zoom,\n      },\n      {\n        type: 'separator',\n      },\n      // {\n      //   role: 'front',\n      //   label: lang.front,\n      // },\n    ]\n  } else if (os === 'win32' || os === 'linux') {\n    let submenu = (template[0] && template[0].submenu) as MenuItemConstructorOptions[]\n\n    if (submenu) {\n      submenu.unshift({\n        type: 'separator',\n      })\n      submenu.unshift({\n        label: `${lang.about} ${name}`,\n        //role: 'about',\n        click: () => {\n          broadcast(events.show_about)\n        },\n      })\n\n      submenu.push({\n        type: 'separator',\n      })\n      submenu.push({\n        role: 'quit',\n        label: lang.quit,\n        accelerator: 'CmdOrCtrl+Q',\n      })\n    }\n\n    // VIEW\n    submenu = (template[2] && template[2].submenu) as MenuItemConstructorOptions[]\n    submenu.splice(0, 4)\n  }\n\n  // if (isDev()) {\n  //   // VIEW\n  //   // @ts-ignore\n  //   template[3].submenu = [\n  //     // @ts-ignore\n  //     ...template[3].submenu,\n  //   ]\n  // }\n\n  const menu = Menu.buildFromTemplate(template)\n  Menu.setApplicationMenu(menu)\n}\n"
  },
  {
    "path": "src/main/ui/tray/index.ts",
    "content": "/**\n * tray\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { configGet, configSet, updateTrayTitle } from '@main/actions'\nimport { broadcast } from '@main/core/agent'\nimport { makeWindow } from '@main/ui/tray/window'\nimport events from '@common/events'\nimport { I18N } from '@common/i18n'\nimport version from '@/version.json'\nimport { app, BrowserWindow, Menu, MenuItemConstructorOptions, screen, Tray } from 'electron'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\nlet tray: Tray\nlet win: BrowserWindow\n\nconst getTrayIconPath = () => {\n  const iconCandidates =\n    process.platform === 'darwin'\n      ? ['logoTemplate.png', 'logoTemplate@2x.png', 'logo@512w.png']\n      : ['logo@512w.png', 'logo.png']\n  const baseDirCandidates = [path.join(__dirname, 'assets'), path.join(__dirname, '..', 'src', 'assets')]\n\n  for (const icon of iconCandidates) {\n    for (const baseDir of baseDirCandidates) {\n      const iconPath = path.join(baseDir, icon)\n      if (fs.existsSync(iconPath)) {\n        return iconPath\n      }\n    }\n  }\n\n  return path.join(__dirname, 'assets', process.platform === 'darwin' ? 'logoTemplate.png' : 'logo@512w.png')\n}\n\nconst makeTray = async () => {\n  tray = new Tray(getTrayIconPath())\n  win = makeWindow()\n\n  updateTrayTitle().catch((e) => console.error(e))\n\n  tray.setToolTip('SwitchHosts')\n\n  let locale = await configGet('locale')\n  if (process.platform === 'linux') {\n    locale = global.system_locale // configGet() always get undefined on Linux\n  }\n  const i18n = new I18N(locale)\n  const { lang } = i18n\n\n  const ver = version.slice(0, 3).join('.') + ` (${version[3]})`\n\n  if (process.platform === 'linux') {\n    const menu = Menu.buildFromTemplate([\n      {\n        label: lang.click_to_open,\n        click: () => window(),\n      },\n      { type: 'separator' },\n      {\n        label: lang._app_name,\n        toolTip: lang.show_main_window,\n        click: () => {\n          broadcast(events.active_main_window)\n        },\n      },\n      {\n        label: `v${ver}`,\n        enabled: false,\n      },\n      { type: 'separator' },\n      {\n        label: lang.quit,\n        role: 'quit',\n      },\n    ])\n\n    // Linux requires setContextMenu to be called in order for the context menu to populate correctly\n    tray.setContextMenu(menu)\n    return\n  }\n\n  tray.on('click', async () => {\n    let tray_mini_window = await configGet('tray_mini_window')\n    tray_mini_window ? window() : broadcast(events.active_main_window)\n  })\n\n  tray.on('double-click', () => broadcast(events.active_main_window))\n\n  tray.on('right-click', async () => {\n    let hide_dock_icon = await configGet('hide_dock_icon')\n\n    const menu = Menu.buildFromTemplate([\n      {\n        label: lang._app_name,\n        toolTip: lang.show_main_window,\n        click() {\n          broadcast(events.active_main_window)\n        },\n      },\n      {\n        label: `v${ver}`,\n        enabled: false,\n      },\n      ...(app.dock\n        ? <MenuItemConstructorOptions[]>[\n            { type: 'separator' },\n            {\n              label: hide_dock_icon ? lang.show_dock_icon : lang.hide_dock_icon,\n              async click() {\n                let hide_dock_icon = await configGet('hide_dock_icon')\n                hide_dock_icon = !hide_dock_icon\n                await configSet('hide_dock_icon', hide_dock_icon)\n                if (!app.dock) return\n                if (hide_dock_icon) {\n                  app.dock.hide()\n                } else {\n                  app.dock.show().catch((e) => console.error(e))\n                }\n              },\n            },\n          ]\n        : []),\n      { type: 'separator' },\n      {\n        label: lang.quit,\n        role: 'quit',\n      },\n    ])\n\n    tray.popUpContextMenu(menu)\n  })\n}\n\nconst getPosition = () => {\n  const tray_bounds = tray.getBounds()\n  const window_bounds = win.getBounds()\n  const point = screen.getCursorScreenPoint()\n  const screen_bounds0 = screen.getDisplayNearestPoint(point).bounds\n  const screen_bounds = screen.getDisplayNearestPoint(point).workAreaSize\n\n  let x: number\n  let y: number\n\n  let dw = screen_bounds0.width - screen_bounds.width\n  if (dw > 0 && tray_bounds.x < dw) {\n    // tray is at left\n    x = dw\n  } else {\n    x = tray_bounds.x + tray_bounds.width / 2 - window_bounds.width / 2\n  }\n\n  // let dh = screen_bounds0.height - screen_bounds.height\n  if (tray_bounds.y < screen_bounds.height / 2) {\n    y = tray_bounds.y + tray_bounds.height\n  } else {\n    y = tray_bounds.y - window_bounds.height - 2\n  }\n\n  if (x < 0) x = 0\n  if (x + window_bounds.width > screen_bounds.width) x = screen_bounds.width - window_bounds.width\n\n  x = Math.round(x)\n  y = Math.round(y)\n\n  return { x, y }\n}\n\nconst getLinuxPosition = () => {\n  const window_bounds = win.getBounds()\n  const point = screen.getCursorScreenPoint()\n  const screen_bounds0 = screen.getDisplayNearestPoint(point).bounds\n  const screen_bounds = screen.getDisplayNearestPoint(point).workAreaSize\n\n  let x: number\n  let y: number\n\n  if (point.x - screen_bounds0.x > screen_bounds.width / 2) {\n    // display on the right of the active screen\n    x = screen_bounds0.x + screen_bounds0.width - window_bounds.width\n  } else {\n    x = 0\n  }\n  if (point.y < screen_bounds.height / 2) {\n    // display on the top of the active screen\n    y = 0\n  } else {\n    y = screen_bounds.height - window_bounds.height\n  }\n\n  x = Math.round(x)\n  y = Math.round(y)\n\n  return { x, y }\n}\n\nconst window = () => {\n  if (!win) {\n    makeWindow()\n    return\n  }\n\n  if (win.isVisible()) {\n    if (win.isFocused()) {\n      win.hide()\n    } else {\n      show()\n      win.focus()\n    }\n  } else {\n    show()\n  }\n}\n\nconst show = () => {\n  let { x, y } = process.platform === 'linux' ? getLinuxPosition() : getPosition()\n  win.setPosition(x, y, true)\n  win.show()\n  // win.focus()\n}\n\napp &&\n  app.whenReady().then(() => {\n    if (!tray) {\n      makeTray()\n    }\n  })\n\nexport { tray, makeTray }\n"
  },
  {
    "path": "src/main/ui/tray/window.ts",
    "content": "/**\n * window\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport getIndex from '@main/libs/getIndex'\nimport isDev from '@main/libs/isDev'\nimport { BrowserWindow } from 'electron'\nimport path from 'path'\n\nconst makeWindow = () => {\n  let win: BrowserWindow | null\n  // Linux AppImage APP can't automatically recognize dock icon, requires special configuration to display correctly\n  let linux_icon = {}\n  if (process.platform === 'linux') {\n    linux_icon = {\n      icon: path.join(__dirname, '/assets/icon.png'),\n    }\n  }\n  win = new BrowserWindow({\n    frame: false,\n    // titleBarStyle: 'hidden',\n    hasShadow: true,\n    resizable: false,\n    // transparent: true,\n    width: 300,\n    height: 600,\n    minWidth: 300,\n    minHeight: 200,\n    maximizable: false,\n    minimizable: false,\n    skipTaskbar: true,\n    show: false,\n    webPreferences: {\n      contextIsolation: true,\n      preload: path.join(__dirname, 'preload.js'),\n      spellcheck: true,\n    },\n    ...linux_icon,\n  })\n\n  win.setVisibleOnAllWorkspaces(true, {\n    visibleOnFullScreen: true,\n  })\n\n  win.loadURL(`${getIndex()}#/tray`).catch((e) => console.error(e))\n\n  win.on('blur', () => win?.hide())\n\n  win.on('close', (e: Electron.Event) => {\n    if (global.is_will_quit) {\n      win = null\n    } else {\n      e.preventDefault()\n      win?.hide()\n    }\n  })\n\n  if (isDev()) {\n    // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready\n    win.webContents.once('dom-ready', () => {\n      win!.webContents.openDevTools()\n    })\n  }\n\n  return win\n}\n\nexport { makeWindow }\n"
  },
  {
    "path": "src/main/utils/fs2.ts",
    "content": "/**\n * fs2\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as fs from 'fs'\n\nexport const isDir = (dir_path: string): boolean => {\n  return fs.existsSync(dir_path) && fs.lstatSync(dir_path).isDirectory()\n}\n"
  },
  {
    "path": "src/renderer/common/PageWrapper.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport Loading from '@renderer/components/Loading'\nimport React, { Suspense } from 'react'\n\ninterface IProps {\n  children?: React.ReactNode\n}\n\nfunction PageWrapper(props: IProps) {\n  const { children } = props\n\n  return <Suspense fallback={<Loading />}>{children}</Suspense>\n}\n\nexport default PageWrapper\n"
  },
  {
    "path": "src/renderer/components/About/AboutContent.module.scss",
    "content": "@use '../../styles/common';\n\n.root {\n  // padding-bottom: 20px;\n\n  a {\n    color: var(--swh-primary-color);\n\n    &:hover {\n      opacity: 0.8;\n    }\n  }\n}\n\n.logo {\n  width: 64px;\n  height: 64px;\n  border-radius: 50%;\n  box-shadow: 0 1px 1px 1px rgba(0, 0, 0, 0.1);\n}\n\n.names {\n  @include common.swh-scroll-y;\n  display: flex;\n  flex-wrap: wrap;\n  gap: 4px 16px;\n  //justify-content: center;\n  margin: 8px 0;\n  //max-width: 350px;\n  max-height: 160px;\n  overflow-y: auto;\n  border: 1px solid var(--swh-border-color-1);\n  border-radius: 4px;\n  padding: 8px;\n\n  // a {\n  //   margin: 0 0.5em;\n  //   display: inline-block;\n  // }\n}\n"
  },
  {
    "path": "src/renderer/components/About/AboutContent.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport logo from '@/assets/logo@512w.png'\nimport version from '@/version.json'\nimport acknowledgements from '@common/acknowledgements'\nimport { homepage_url, source_url } from '@common/constants'\nimport { Box, Center, Flex, Image, Stack } from '@mantine/core'\nimport { default as Link } from '@renderer/components/BrowserLink'\nimport useI18n from '@renderer/models/useI18n'\nimport styles from './AboutContent.module.scss'\n\nconst AboutContent = () => {\n  const { lang } = useI18n()\n  const version_str = version.slice(0, 3).join('.') + ` (${version[3]})`\n\n  return (\n    <Stack gap=\"4px\" align=\"stretch\">\n      <Center pb=\"12px\">\n        <Image className={styles.logo} src={logo} />\n      </Center>\n      <Center style={{ fontWeight: 'bold', fontSize: '16px' }}>{lang._app_name}</Center>\n      <Center style={{ fontSize: '80%', opacity: 0.5 }}>v{version_str}</Center>\n      <Flex gap={8} justify=\"center\" wrap=\"wrap\">\n        <Link href={homepage_url}>{lang.homepage}</Link>\n        <Link href={source_url}>{lang.source_code}</Link>\n      </Flex>\n\n      <Center style={{ paddingTop: 32, fontWeight: 'bold' }}>{lang.acknowledgement}</Center>\n      <Box className={styles.names}>\n        {acknowledgements.map((o, idx) => (\n          <Link key={idx} href={o.link}>\n            {o.name}\n          </Link>\n        ))}\n      </Box>\n    </Stack>\n  )\n}\n\nexport default AboutContent\n"
  },
  {
    "path": "src/renderer/components/About/index.module.scss",
    "content": ".close_btn {\n  position: absolute;\n  right: 12px;\n  top: 12px;\n}\n\n.body {\n  padding-top: 10px;\n}\n"
  },
  {
    "path": "src/renderer/components/About/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport events from '@common/events'\nimport { Modal } from '@mantine/core'\nimport AboutContent from '@renderer/components/About/AboutContent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport { useState } from 'react'\nimport styles from './index.module.scss'\n\nconst About = () => {\n  const [opened, setOpened] = useState(false)\n\n  const onClose = () => setOpened(false)\n\n  useOnBroadcast(events.show_about, () => setOpened(true))\n\n  return (\n    <Modal opened={opened} onClose={onClose} centered withCloseButton={false}>\n      <Modal.CloseButton className={styles.close_btn} />\n      <div className={styles.body}>\n        <AboutContent />\n      </div>\n    </Modal>\n  )\n}\n\nexport default About\n"
  },
  {
    "path": "src/renderer/components/BrowserLink.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport events from '@common/events'\nimport { actions, agent } from '@renderer/core/agent'\nimport React from 'react'\n\ninterface Props {\n  href: string\n  children: React.ReactElement | string\n\n  [key: string]: any\n}\n\nconst BrowserLink = (props: Props) => {\n  const { href } = props\n\n  const onClick = (e: React.MouseEvent) => {\n    e.preventDefault()\n    agent.broadcast(events.browser_link, href)\n    actions.openUrl(href).catch((e) => console.error(e))\n  }\n\n  return (\n    <a href={href} onClick={onClick}>\n      {props.children}\n    </a>\n  )\n}\n\nexport default BrowserLink\n"
  },
  {
    "path": "src/renderer/components/EditHostsInfo.module.scss",
    "content": ".ln {\n  margin-bottom: 20px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n}\n\n.refresh_info {\n  color: var(--swh-font-color-weak);\n\n  span {\n    margin-right: 8px;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/EditHostsInfo.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { FolderModeType, HostsType, IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport * as hostsFn from '@common/hostsFn'\nimport {\n  Box,\n  Button,\n  Group,\n  NativeSelect,\n  Radio,\n  SimpleGrid,\n  Text,\n  TextInput,\n} from '@mantine/core'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport SideDrawer from '@renderer/components/SideDrawer'\nimport Transfer from '@renderer/components/Transfer'\nimport { actions, agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport lodash from 'lodash'\nimport React, { useState } from 'react'\nimport { BiEdit, BiTrash } from 'react-icons/bi'\nimport { v4 as uuidv4 } from 'uuid'\nimport useHostsData from '../models/useHostsData'\nimport useI18n from '../models/useI18n'\nimport styles from './EditHostsInfo.module.scss'\n\nconst EditHostsInfo = () => {\n  const { lang } = useI18n()\n  const [hosts, setHosts] = useState<IHostsListObject | null>(null)\n  const { hosts_data, setList, current_hosts, setCurrentHosts } = useHostsData()\n  const [is_show, setIsShow] = useState(false)\n  const [is_add, setIsAdd] = useState(true)\n  const [is_refreshing, setIsRefreshing] = useState(false)\n\n  const onCancel = () => {\n    setHosts(null)\n    setIsShow(false)\n  }\n\n  const onSave = async () => {\n    let data: Omit<IHostsListObject, 'id'> & { id?: string } = { ...hosts }\n\n    const keys_to_trim = ['title', 'url']\n    keys_to_trim.map((k) => {\n      if (data[k]) {\n        data[k] = data[k].trim()\n      }\n    })\n\n    if (is_add) {\n      let h: IHostsListObject = {\n        ...data,\n        id: uuidv4(),\n      }\n      let list: IHostsListObject[] = [...hosts_data.list, h]\n      await setList(list)\n      agent.broadcast(events.select_hosts, h.id, 1000)\n    } else if (data && data.id) {\n      let h: IHostsListObject | undefined = hostsFn.findItemById(hosts_data.list, data.id)\n      if (h) {\n        Object.assign(h, data)\n        await setList([...hosts_data.list])\n\n        if (data.id === current_hosts?.id) {\n          setCurrentHosts(h)\n        }\n      } else {\n        setIsAdd(true)\n        setTimeout(onSave, 300)\n        return\n      }\n    } else {\n      alert('unknown error!')\n    }\n\n    setIsShow(false)\n  }\n\n  const onUpdate = (kv: Partial<IHostsListObject>) => {\n    let obj: IHostsListObject = Object.assign({}, hosts, kv)\n    setHosts(obj)\n  }\n\n  useOnBroadcast(events.edit_hosts_info, (hosts?: IHostsListObject) => {\n    setHosts(hosts || null)\n    setIsAdd(!hosts)\n    setIsShow(true)\n  })\n\n  useOnBroadcast(events.add_new, () => {\n    setHosts(null)\n    setIsAdd(true)\n    setIsShow(true)\n  })\n\n  useOnBroadcast(\n    events.hosts_refreshed,\n    (_hosts: IHostsListObject) => {\n      if (hosts && hosts.id === _hosts.id) {\n        onUpdate(lodash.pick(_hosts, ['last_refresh', 'last_refresh_ms']))\n      }\n    },\n    [hosts],\n  )\n\n  const forRemote = (): React.ReactElement => {\n    return (\n      <>\n        <Box className={styles.ln}>\n          <Text mb=\"8px\">URL</Text>\n          <TextInput\n            value={hosts?.url || ''}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) => onUpdate({ url: e.target.value })}\n            placeholder={lang.url_placeholder}\n            onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => e.key === 'Enter' && onSave()}\n          />\n        </Box>\n\n        <Box className={styles.ln}>\n          <Text mb=\"8px\">{lang.auto_refresh}</Text>\n          <NativeSelect\n            value={(hosts?.refresh_interval || 0).toString()}\n            onChange={(e) => onUpdate({ refresh_interval: parseInt(e.target.value) || 0 })}\n            data={[\n              { value: '0', label: lang.never },\n              { value: '60', label: `1 ${lang.minute}` },\n              { value: `${60 * 5}`, label: `5 ${lang.minutes}` },\n              { value: `${60 * 15}`, label: `15 ${lang.minutes}` },\n              { value: `${60 * 60}`, label: `1 ${lang.hour}` },\n              { value: `${60 * 60 * 24}`, label: `24 ${lang.hours}` },\n              { value: `${60 * 60 * 24 * 7}`, label: `7 ${lang.days}` },\n            ]}\n            maw={160}\n          />\n          {is_add ? null : (\n            <Box className={styles.refresh_info} mt=\"8px\">\n              <span>\n                {lang.last_refresh}\n                {hosts?.last_refresh || 'N/A'}\n              </span>\n              <Button\n                size=\"sm\"\n                variant=\"subtle\"\n                disabled={is_refreshing}\n                onClick={() => {\n                  if (!hosts) return\n\n                  setIsRefreshing(true)\n                  actions\n                    .refreshHosts(hosts.id)\n                    .then((r) => {\n                      console.log(r)\n                      if (!r.success) {\n                        console.error(r.message || r.code || 'Error!')\n                        return\n                      }\n\n                      console.log('OK!')\n                      onUpdate({\n                        last_refresh: r.data.last_refresh,\n                        last_refresh_ms: r.data.last_refresh_ms,\n                      })\n                    })\n                    .catch((e) => {\n                      console.log(e)\n                      console.error(e.message)\n                    })\n                    .finally(() => setIsRefreshing(false))\n                }}\n              >\n                {lang.refresh}\n              </Button>\n            </Box>\n          )}\n        </Box>\n      </>\n    )\n  }\n\n  const renderTransferItem = (item: IHostsListObject): React.ReactElement => {\n    return (\n      <Group gap=\"8px\">\n        <ItemIcon type={item.type} />\n        <span>{item.title || lang.untitled}</span>\n      </Group>\n    )\n  }\n\n  const forGroup = (): React.ReactElement => {\n    const list = hostsFn.flatten(hosts_data.list)\n\n    let source_list: IHostsListObject[] = list\n      .filter((item) => !item.type || item.type === 'local' || item.type === 'remote')\n      .map((item) => {\n        let o = { ...item }\n        o.key = o.id\n        return o\n      })\n\n    let target_keys: string[] = hosts?.include || []\n\n    return (\n      <Box className={styles.ln}>\n        <Text mb=\"8px\">{lang.content}</Text>\n        <Transfer\n          dataSource={source_list}\n          targetKeys={target_keys}\n          render={renderTransferItem}\n          onChange={(next_target_keys) => {\n            onUpdate({ include: next_target_keys })\n          }}\n        />\n      </Box>\n    )\n  }\n\n  const forFolder = (): React.ReactElement => {\n    return (\n      <Box className={styles.ln}>\n        <Text mb=\"8px\">{lang.choice_mode}</Text>\n        <Radio.Group\n          value={(hosts?.folder_mode || 0).toString()}\n          onChange={(v) => onUpdate({ folder_mode: (parseInt(v) || 0) as FolderModeType })}\n        >\n          <Group gap=\"12px\">\n            <Radio value=\"0\" label={lang.choice_mode_default} />\n            <Radio value=\"1\" label={lang.choice_mode_single} />\n            <Radio value=\"2\" label={lang.choice_mode_multiple} />\n          </Group>\n        </Radio.Group>\n      </Box>\n    )\n  }\n\n  const types: HostsType[] = ['local', 'remote', 'group', 'folder']\n\n  return (\n    <SideDrawer\n      opened={is_show}\n      onClose={onCancel}\n      size=\"lg\"\n      title={\n        <Group gap=\"8px\">\n          <BiEdit />\n          <Box>{is_add ? lang.hosts_add : lang.hosts_edit}</Box>\n        </Group>\n      }\n      scrollAreaStyle={{\n        paddingBottom: 24,\n      }}\n      footer={\n        <SimpleGrid cols={2} style={{ width: '100%', alignItems: 'center' }}>\n          <Box>\n            {is_add ? null : (\n              <Button\n                variant=\"outline\"\n                color=\"pink\"\n                disabled={!hosts}\n                leftSection={<BiTrash />}\n                onClick={() => {\n                  if (hosts) {\n                    agent.broadcast(events.move_to_trashcan, [hosts.id])\n                    onCancel()\n                  }\n                }}\n              >\n                {lang.move_to_trashcan}\n              </Button>\n            )}\n          </Box>\n          <Group justify=\"flex-end\" gap=\"12px\">\n            <Button onClick={onCancel} variant=\"outline\">\n              {lang.btn_cancel}\n            </Button>\n            <Button onClick={onSave} color=\"blue\">\n              {lang.btn_ok}\n            </Button>\n          </Group>\n        </SimpleGrid>\n      }\n    >\n      <Box>\n        <Box className={styles.ln}>\n          <Text mb=\"8px\">{lang.hosts_type}</Text>\n          <Radio.Group\n            value={hosts?.type || 'local'}\n            onChange={(v) => onUpdate({ type: v as HostsType })}\n          >\n            <Group gap=\"24px\">\n              {types.map((type) => (\n                <Radio\n                  key={type}\n                  value={type}\n                  disabled={!is_add}\n                  label={\n                    <Group gap=\"4px\" wrap=\"nowrap\">\n                      <ItemIcon type={type} />\n                      <span>{lang[type]}</span>\n                    </Group>\n                  }\n                />\n              ))}\n            </Group>\n          </Radio.Group>\n        </Box>\n\n        <Box className={styles.ln}>\n          <Text mb=\"8px\">{lang.hosts_title}</Text>\n          <TextInput\n            data-autofocus\n            value={hosts?.title || ''}\n            maxLength={50}\n            placeholder=\"\"\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) => onUpdate({ title: e.target.value })}\n            onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => e.key === 'Enter' && onSave()}\n          />\n        </Box>\n\n        {hosts?.type === 'remote' ? forRemote() : null}\n        {hosts?.type === 'group' ? forGroup() : null}\n        {hosts?.type === 'folder' ? forFolder() : null}\n      </Box>\n    </SideDrawer>\n  )\n}\n\nexport default EditHostsInfo\n"
  },
  {
    "path": "src/renderer/components/Editor/HostsEditor.module.scss",
    "content": "@use '../../styles/common';\n\n.root {\n  @include common.code;\n  width: 100%;\n  height: 100%;\n}\n\n.editor {\n  height: calc(100% - var(--swh-status-bar-height));\n  background: inherit;\n\n  &.read_only {\n    .surface {\n      background: var(--swh-editor-read-only-bg);\n      caret-color: transparent;\n      opacity: 0.8;\n      // box-shadow: inset 0 0 0 1px var(--swh-border-color-0);\n    }\n\n    :global {\n      .codejar-linenumbers,\n      .codejar-linenumbers-inner-wrap {\n        background: var(--swh-editor-read-only-bg) !important;\n      }\n    }\n  }\n\n  :global {\n    .codejar-wrap {\n      height: 100%;\n    }\n\n    .codejar-linenumbers-inner-wrap {\n      background: var(--swh-editor-gutter-bg) !important;\n    }\n\n    .codejar-linenumbers {\n      border-right: none;\n      padding-right: 6px;\n      background: var(--swh-editor-gutter-bg);\n      user-select: none;\n      cursor: pointer;\n    }\n\n    .codejar-linenumber {\n      color: var(--swh-editor-line-number-color);\n      font-size: 12px;\n    }\n\n    .hl-comment {\n      color: var(--swh-editor-comment);\n    }\n\n    .hl-ip {\n      color: var(--swh-editor-ip);\n      font-weight: bold;\n    }\n\n    .hl-error {\n      color: var(--swh-editor-error);\n    }\n  }\n}\n\n.mount {\n  height: 100%;\n}\n\n.surface {\n  box-sizing: border-box;\n  width: 100%;\n  height: 100%;\n  padding: 8px 10px;\n  overflow: auto;\n  background: var(--swh-editor-bg-color);\n  color: var(--swh-editor-text-color);\n  font-family: common.$font-editor;\n  font-size: var(--swh-editor-font-size);\n  line-height: var(--swh-editor-line-height);\n  white-space: pre;\n  caret-color: var(--swh-editor-text-color);\n}\n\n.surface:focus {\n  outline: none;\n}\n\n:global(.theme-dark) {\n  .editor {\n    .surface {\n      caret-color: #39c;\n    }\n\n    &.read_only {\n      .surface {\n        // box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);\n        caret-color: transparent;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Editor/HostsEditor.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport { normalizeLineEndings } from '@common/newlines'\nimport { IFindShowSourceParam } from '@common/types'\nimport StatusBar from '@renderer/components/StatusBar'\nimport { actions, agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useHostsData from '@renderer/models/useHostsData'\nimport { useDebounceFn } from 'ahooks'\nimport clsx from 'clsx'\nimport { CodeJar, type Position } from 'codejar'\nimport { withLineNumbers } from 'codejar-linenumbers'\nimport 'codejar-linenumbers/es/codejar-linenumbers.css'\nimport { useEffect, useRef, useState } from 'react'\nimport { highlightHosts, toggleCommentByLine, toggleCommentBySelection } from './hosts_highlight'\nimport styles from './HostsEditor.module.scss'\n\nconst HostsEditor = () => {\n  const { current_hosts, isReadOnly } = useHostsData()\n  const hosts_id = current_hosts?.id || '0'\n  const is_read_only = isReadOnly(current_hosts)\n  const [content, setContent] = useState('')\n\n  const ref_mount = useRef<HTMLDivElement>(null) // outer container that hosts the CodeJar wrapper\n  const ref_editor = useRef<HTMLDivElement | null>(null) // contenteditable div managed by CodeJar\n  const ref_jar = useRef<ReturnType<typeof CodeJar> | null>(null)\n  // Refs mirror React state so that callbacks inside the CodeJar effect\n  // (which only re-runs on hosts_id change) can always read the latest values.\n  const ref_hosts_id = useRef(hosts_id)\n  const ref_is_read_only = useRef(is_read_only)\n  // Pending find: when a show_source event arrives before the target hosts is loaded,\n  // we stash the params here and apply them once loadContent finishes (with a 3s timeout).\n  const ref_pending_find = useRef<IFindShowSourceParam | null>(null)\n  const ref_pending_find_timer = useRef<number | null>(null)\n\n  useEffect(() => {\n    ref_hosts_id.current = hosts_id\n  }, [hosts_id])\n\n  useEffect(() => {\n    ref_is_read_only.current = is_read_only\n  }, [is_read_only])\n\n  const clearPendingFind = () => {\n    if (ref_pending_find_timer.current) {\n      window.clearTimeout(ref_pending_find_timer.current)\n      ref_pending_find_timer.current = null\n    }\n    ref_pending_find.current = null\n  }\n\n  useEffect(() => clearPendingFind, [])\n\n  const { run: toSave } = useDebounceFn(\n    (id: string, nextContent: string) => {\n      actions\n        .setHostsContent(id, nextContent)\n        .then(() => agent.broadcast(events.hosts_content_changed, id))\n        .catch((e) => console.error(e))\n    },\n    { wait: 1000 },\n  )\n\n  /** Toggle contenteditable between 'plaintext-only' and 'false' (Chromium/Electron only). */\n  const setEditorReadOnly = (readOnly: boolean) => {\n    const editor = ref_editor.current\n    if (!editor) return\n\n    editor.setAttribute('contenteditable', readOnly ? 'false' : 'plaintext-only')\n    editor.setAttribute('aria-readonly', readOnly ? 'true' : 'false')\n  }\n\n  /** Scroll the current selection/cursor into view after programmatic focus changes. */\n  const scrollSelectionIntoView = () => {\n    const editor = ref_editor.current\n    if (!editor) return\n\n    const selection = window.getSelection()\n    if (!selection || selection.rangeCount === 0) return\n\n    const range = selection.getRangeAt(0)\n    const startNode = range.startContainer\n    const target =\n      startNode.nodeType === Node.TEXT_NODE\n        ? startNode.parentElement\n        : (startNode as Element | null)\n\n    ;(target ?? editor).scrollIntoView({\n      block: 'nearest',\n      inline: 'nearest',\n    })\n  }\n\n  /** Restore a character-offset selection in the editor (used by find/show-source). */\n  const setSelection = (params: IFindShowSourceParam) => {\n    const jar = ref_jar.current\n    const editor = ref_editor.current\n    if (!jar || !editor) return\n\n    const editorContent = jar.toString()\n    const start = Math.max(0, Math.min(params.start, editorContent.length))\n    const end = Math.max(0, Math.min(params.end, editorContent.length))\n    jar.restore({\n      start,\n      end,\n      dir: '->',\n    })\n    editor.focus()\n    window.requestAnimationFrame(scrollSelectionIntoView)\n  }\n\n  /** Fetch and display the hosts content. Applies any pending find selection after loading. */\n  const loadContent = async (targetHostsId = hosts_id) => {\n    const jar = ref_jar.current\n    if (!jar) return\n\n    const nextContent = normalizeLineEndings(\n      targetHostsId === '0'\n        ? await actions.getSystemHosts()\n        : await actions.getHostsContent(targetHostsId),\n    )\n\n    if (ref_hosts_id.current !== targetHostsId) return\n\n    setContent(nextContent)\n    jar.updateCode(nextContent, false)\n\n    const pendingFind = ref_pending_find.current\n    if (pendingFind && pendingFind.item_id === targetHostsId) {\n      setSelection(pendingFind)\n      clearPendingFind()\n    }\n  }\n\n  const getCurrentSelection = (): Position => {\n    const jar = ref_jar.current\n    const editor = ref_editor.current\n    const fallbackOffset = jar?.toString().length ?? 0\n    if (!jar || !editor) {\n      return {\n        start: fallbackOffset,\n        end: fallbackOffset,\n        dir: '->',\n      }\n    }\n\n    try {\n      return jar.save()\n    } catch {\n      return {\n        start: fallbackOffset,\n        end: fallbackOffset,\n        dir: '->',\n      }\n    }\n  }\n\n  const onChange = (nextContent: string) => {\n    const normalizedContent = normalizeLineEndings(nextContent)\n    setContent(normalizedContent)\n    toSave(hosts_id, normalizedContent)\n  }\n\n  /** Push a programmatic edit into CodeJar: update content, restore selection, and record undo history. */\n  const applyEditorChange = (nextContent: string, nextSelection: Position) => {\n    const jar = ref_jar.current\n    const editor = ref_editor.current\n    if (!jar || !editor) return\n\n    editor.focus()\n    jar.recordHistory()\n    jar.updateCode(nextContent, false)\n    jar.restore(nextSelection)\n    editor.focus()\n    jar.recordHistory()\n    onChange(nextContent)\n  }\n\n  const toggleComment = () => {\n    if (ref_is_read_only.current) return\n\n    const jar = ref_jar.current\n    if (!jar) return\n\n    const selection = getCurrentSelection()\n    const next = toggleCommentBySelection(jar.toString(), selection.start, selection.end, true)\n    if (!next.changed) return\n\n    applyEditorChange(next.content, {\n      start: next.selectionStart,\n      end: next.selectionEnd,\n      dir: '->',\n    })\n  }\n\n  /** Handle a click on the line-number gutter to toggle comment on that line. */\n  const onGutterClick = (lineIndex: number) => {\n    if (ref_is_read_only.current) return\n\n    const jar = ref_jar.current\n    if (!jar) return\n\n    const selection = getCurrentSelection()\n    const next = toggleCommentByLine(jar.toString(), lineIndex, selection.start, selection.end)\n    if (!next.changed) return\n\n    applyEditorChange(next.content, {\n      start: next.selectionStart,\n      end: next.selectionEnd,\n      dir: '->',\n    })\n  }\n\n  useEffect(() => {\n    const mount = ref_mount.current\n    if (!mount) return\n\n    mount.replaceChildren()\n\n    const editor = document.createElement('div')\n    editor.className = styles.surface\n    editor.tabIndex = 0\n    mount.appendChild(editor)\n\n    const jar = CodeJar(\n      editor,\n      withLineNumbers(highlightHosts, {\n        width: '25px',\n        backgroundColor: 'var(--swh-editor-gutter-bg)',\n        color: 'var(--swh-editor-line-number-color)',\n      }),\n    )\n    ref_editor.current = editor\n    ref_jar.current = jar\n    setEditorReadOnly(is_read_only)\n\n    const onEditorUpdate = (nextContent: string) => {\n      onChange(nextContent)\n    }\n\n    // Detect clicks on the line-number gutter and convert the click Y position\n    // into a zero-based line index, accounting for scroll offset of the wrapper.\n    const onMountClick = (event: MouseEvent) => {\n      const target = event.target as HTMLElement | null\n      const gutter = target?.closest('.codejar-linenumbers')\n      if (!gutter) return\n\n      const lineHeight = parseFloat(window.getComputedStyle(editor).lineHeight) || 24\n      const scrollContainer = gutter.closest('.codejar-wrap') ?? editor\n      const relativeY =\n        event.clientY - gutter.getBoundingClientRect().top + scrollContainer.scrollTop\n      const lineCount = Math.max(1, jar.toString().split('\\n').length)\n      const lineIndex = Math.max(0, Math.min(lineCount - 1, Math.floor(relativeY / lineHeight)))\n\n      event.preventDefault()\n      onGutterClick(lineIndex)\n    }\n\n    jar.onUpdate(onEditorUpdate)\n    jar.updateCode('', false)\n    mount.addEventListener('click', onMountClick)\n    loadContent(hosts_id).catch((e) => console.error(e))\n\n    return () => {\n      mount.removeEventListener('click', onMountClick)\n      jar.destroy()\n      mount.replaceChildren()\n      ref_jar.current = null\n      ref_editor.current = null\n    }\n  }, [hosts_id])\n\n  useEffect(() => {\n    setEditorReadOnly(is_read_only)\n  }, [is_read_only])\n\n  useOnBroadcast(\n    events.hosts_refreshed,\n    (h: IHostsListObject) => {\n      if (hosts_id !== '0' && h.id !== hosts_id) return\n      loadContent().catch((e) => console.error(e))\n    },\n    [hosts_id],\n  )\n\n  useOnBroadcast(\n    events.hosts_refreshed_by_id,\n    (id: string) => {\n      if (hosts_id !== '0' && hosts_id !== id) return\n      loadContent().catch((e) => console.error(e))\n    },\n    [hosts_id],\n  )\n\n  useOnBroadcast(\n    events.set_hosts_on_status,\n    () => {\n      if (hosts_id === '0') {\n        loadContent().catch((e) => console.error(e))\n      }\n    },\n    [hosts_id],\n  )\n\n  useOnBroadcast(\n    events.system_hosts_updated,\n    () => {\n      if (hosts_id === '0') {\n        loadContent().catch((e) => console.error(e))\n      }\n    },\n    [hosts_id],\n  )\n\n  useOnBroadcast(events.toggle_comment, toggleComment, [hosts_id])\n\n  useOnBroadcast(\n    events.show_source,\n    (params: IFindShowSourceParam) => {\n      if (params.item_id !== hosts_id || !ref_jar.current) {\n        clearPendingFind()\n        ref_pending_find.current = params\n        ref_pending_find_timer.current = window.setTimeout(clearPendingFind, 3000)\n        return\n      }\n\n      clearPendingFind()\n      setSelection(params)\n    },\n    [hosts_id],\n  )\n\n  return (\n    <div className={styles.root}>\n      <div className={clsx(styles.editor, is_read_only && styles.read_only)}>\n        <div ref={ref_mount} className={styles.mount} />\n      </div>\n\n      <StatusBar\n        line_count={content.split('\\n').length}\n        bytes={content.length}\n        read_only={is_read_only}\n      />\n    </div>\n  )\n}\n\nexport default HostsEditor\n"
  },
  {
    "path": "src/renderer/components/Editor/hosts_highlight.test.ts",
    "content": "/**\n * Tests for hosts file syntax highlighting and comment toggling.\n * Covers HTML rendering of comment / IP / error lines,\n * single-line and multi-line comment toggle with cursor adjustment,\n * and gutter (line-index) based toggling.\n */\n\nimport {\n  highlightHostsLine,\n  highlightHostsText,\n  toggleCommentByLine,\n  toggleCommentBySelection,\n} from './hosts_highlight'\nimport { describe, expect, it } from 'vitest'\n\ndescribe('hosts_highlight', () => {\n  it('highlights comment lines', () => {\n    expect(highlightHostsLine('  # localhost')).toBe(\n      '<span class=\"hl-comment\">  # localhost</span>',\n    )\n  })\n\n  it('highlights valid hosts lines with leading whitespace', () => {\n    expect(highlightHostsLine('  127.0.0.1 localhost')).toBe(\n      '  <span class=\"hl-ip\">127.0.0.1</span> localhost',\n    )\n  })\n\n  it('marks invalid lines as errors and escapes html', () => {\n    expect(highlightHostsLine('foo <bar>')).toBe(\n      '<span class=\"hl-error\">foo &lt;bar&gt;</span>',\n    )\n  })\n\n  it('preserves multiline output including trailing newline', () => {\n    expect(highlightHostsText('127.0.0.1 localhost\\n# ok\\n')).toBe(\n      '<span class=\"hl-ip\">127.0.0.1</span> localhost\\n<span class=\"hl-comment\"># ok</span>\\n',\n    )\n  })\n\n  it('normalizes CRLF input before highlighting', () => {\n    expect(highlightHostsText('127.0.0.1 localhost\\r\\n# ok\\r\\n')).toBe(\n      '<span class=\"hl-ip\">127.0.0.1</span> localhost\\n<span class=\"hl-comment\"># ok</span>\\n',\n    )\n  })\n\n  it('toggles the current line and moves the cursor to the next line', () => {\n    const code = '127.0.0.1 localhost\\nfoo'\n    const result = toggleCommentBySelection(code, 0, 0, true)\n\n    expect(result.content).toBe('# 127.0.0.1 localhost\\nfoo')\n    expect(result.selectionStart).toBe('# 127.0.0.1 localhost\\n'.length)\n    expect(result.selectionEnd).toBe('# 127.0.0.1 localhost\\n'.length)\n  })\n\n  it('toggles every line touched by a selection', () => {\n    const code = '127.0.0.1 localhost\\nfoo'\n    const result = toggleCommentBySelection(code, 0, code.length)\n\n    expect(result.content).toBe('# 127.0.0.1 localhost\\n# foo')\n    expect(result.selectionStart).toBe(2)\n    expect(result.selectionEnd).toBe(code.length + 4)\n  })\n\n  it('keeps blank lines as no-op', () => {\n    const code = 'foo\\n\\nbar'\n    const result = toggleCommentBySelection(code, 4, 4, true)\n\n    expect(result.changed).toBe(false)\n    expect(result.content).toBe(code)\n    expect(result.selectionStart).toBe(4)\n    expect(result.selectionEnd).toBe(4)\n  })\n\n  it('adjusts selection offsets when uncommenting indented lines', () => {\n    const code = '  # foo\\nbar'\n    const result = toggleCommentBySelection(code, 4, 7)\n\n    expect(result.content).toBe('  foo\\nbar')\n    expect(result.selectionStart).toBe(2)\n    expect(result.selectionEnd).toBe(5)\n  })\n\n  it('toggles a single line by gutter index', () => {\n    const code = 'foo\\nbar'\n    const result = toggleCommentByLine(code, 1, 0, 0)\n\n    expect(result.content).toBe('foo\\n# bar')\n    expect(result.selectionStart).toBe(0)\n    expect(result.selectionEnd).toBe(0)\n  })\n\n  it('normalizes CRLF before toggling comments', () => {\n    const result = toggleCommentBySelection('foo\\r\\nbar', 0, 0, true)\n\n    expect(result.content).toBe('# foo\\nbar')\n    expect(result.selectionStart).toBe('# foo\\n'.length)\n    expect(result.selectionEnd).toBe('# foo\\n'.length)\n  })\n})\n"
  },
  {
    "path": "src/renderer/components/Editor/hosts_highlight.ts",
    "content": "/**\n * Hosts file syntax highlighting and comment toggling for CodeJar.\n *\n * Highlighting: converts plain-text hosts content into HTML with\n * `hl-comment`, `hl-ip`, and `hl-error` spans for styling.\n *\n * Comment toggling: adds/removes `# ` prefixes while preserving\n * cursor/selection positions via offset-based transforms.\n */\n\nimport type { Position } from 'codejar'\nimport { normalizeLineEndings } from '@common/newlines'\n\n/** Matches a valid hosts entry: optional whitespace, an IPv4/IPv6 address, then a hostname. */\nconst HOSTS_LINE_RE = /^\\s*([\\d.]+|[\\da-f:.%lo]+)\\s+\\w/i\n/** Captures the leading indent and `# ` prefix of a comment line for removal. */\nconst COMMENT_LINE_RE = /^(\\s*)#\\s*/\n\n/** A single line with its byte offsets within the full document. */\ninterface LineInfo {\n  start: number\n  end: number\n  text: string\n}\n\n/**\n * Transform records describe how a single toggle operation shifted characters.\n * They are collected per-line and then applied to map the original cursor/selection\n * offsets to their new positions in the modified text.\n */\ninterface InsertTransform {\n  type: 'insert'\n  at: number\n  length: number\n}\n\ninterface RemoveTransform {\n  type: 'remove'\n  start: number\n  end: number\n}\n\ntype Transform = InsertTransform | RemoveTransform\n\nexport interface CommentToggleResult {\n  content: string\n  selectionStart: number\n  selectionEnd: number\n  changed: boolean\n}\n\ninterface ToggleLineResult {\n  nextText: string\n  changed: boolean\n  transform?: Transform\n}\n\nexport function escapeHtml(text: string): string {\n  return text\n    .replaceAll('&', '&amp;')\n    .replaceAll('<', '&lt;')\n    .replaceAll('>', '&gt;')\n    .replaceAll('\"', '&quot;')\n    .replaceAll(\"'\", '&#039;')\n}\n\nexport function isHostsCommentLine(line: string): boolean {\n  return /^\\s*#/.test(line)\n}\n\nexport function isValidHostsLine(line: string): boolean {\n  return HOSTS_LINE_RE.test(line)\n}\n\nexport function highlightHostsLine(line: string): string {\n  if (!line) return ''\n\n  if (isHostsCommentLine(line)) {\n    return `<span class=\"hl-comment\">${escapeHtml(line)}</span>`\n  }\n\n  if (!isValidHostsLine(line)) {\n    return `<span class=\"hl-error\">${escapeHtml(line)}</span>`\n  }\n\n  const match = line.match(/^(\\s*)([\\w.:%]+)/)\n  if (!match) {\n    return escapeHtml(line)\n  }\n\n  const [, indent, ip] = match\n  const rest = line.slice(indent.length + ip.length)\n  return `${escapeHtml(indent)}<span class=\"hl-ip\">${escapeHtml(ip)}</span>${escapeHtml(rest)}`\n}\n\nexport function highlightHostsText(code: string): string {\n  return normalizeLineEndings(code)\n    .split('\\n')\n    .map((line) => highlightHostsLine(line))\n    .join('\\n')\n}\n\n/** CodeJar highlight callback — replaces the editor's innerHTML with syntax-highlighted HTML. */\nexport function highlightHosts(editor: HTMLElement, _pos?: Position): void {\n  editor.innerHTML = highlightHostsText(editor.textContent || '')\n}\n\nfunction getLines(code: string): LineInfo[] {\n  const parts = normalizeLineEndings(code).split('\\n')\n  let start = 0\n\n  return parts.map((text) => {\n    const line = {\n      start,\n      end: start + text.length,\n      text,\n    }\n    start += text.length + 1\n    return line\n  })\n}\n\nfunction getLineIndexAtOffset(lines: LineInfo[], offset: number): number {\n  if (lines.length === 0) return 0\n\n  for (let i = lines.length - 1; i >= 0; i -= 1) {\n    if (offset >= lines[i].start) {\n      return i\n    }\n  }\n\n  return 0\n}\n\nfunction toggleLine(line: string, lineStart: number): ToggleLineResult {\n  if (/^\\s*$/.test(line)) {\n    return {\n      nextText: line,\n      changed: false,\n    }\n  }\n\n  const commentMatch = line.match(COMMENT_LINE_RE)\n  if (commentMatch) {\n    const indent = commentMatch[1]\n    return {\n      nextText: line.replace(COMMENT_LINE_RE, '$1'),\n      changed: true,\n      transform: {\n        type: 'remove',\n        start: lineStart + indent.length,\n        end: lineStart + commentMatch[0].length,\n      },\n    }\n  }\n\n  return {\n    nextText: `# ${line}`,\n    changed: true,\n    transform: {\n      type: 'insert',\n      at: lineStart,\n      length: 2,\n    },\n  }\n}\n\n/** Map an original document offset through a series of insert/remove transforms. */\nfunction mapOffset(offset: number, transforms: Transform[]): number {\n  let mapped = offset\n\n  for (const transform of transforms) {\n    if (transform.type === 'insert') {\n      if (offset >= transform.at) {\n        mapped += transform.length\n      }\n      continue\n    }\n\n    if (offset <= transform.start) continue\n\n    if (offset < transform.end) {\n      mapped -= offset - transform.start\n      continue\n    }\n\n    mapped -= transform.end - transform.start\n  }\n\n  return mapped\n}\n\nfunction getLineStartOffsets(lines: string[]): number[] {\n  const starts: number[] = []\n  let start = 0\n\n  for (const line of lines) {\n    starts.push(start)\n    start += line.length + 1\n  }\n\n  return starts\n}\n\nfunction getSelectionRange(selectionStart: number, selectionEnd: number) {\n  return {\n    start: Math.min(selectionStart, selectionEnd),\n    end: Math.max(selectionStart, selectionEnd),\n  }\n}\n\n/**\n * Core toggle implementation: comment/uncomment lines in [startLineIndex, endLineIndex],\n * returning the updated text and adjusted selection offsets.\n * When `moveToNextLine` is true and the selection is collapsed (cursor), the cursor\n * is moved to the start of the next line after toggling (mimics IDE behavior).\n */\nfunction toggleCommentLines(\n  code: string,\n  selectionStart: number,\n  selectionEnd: number,\n  startLineIndex: number,\n  endLineIndex: number,\n  moveToNextLine: boolean,\n): CommentToggleResult {\n  const lines = getLines(code)\n  const nextLines = lines.map((line) => line.text)\n  const transforms: Transform[] = []\n  let changed = false\n\n  for (let i = startLineIndex; i <= endLineIndex; i += 1) {\n    const line = lines[i]\n    const result = toggleLine(line.text, line.start)\n    nextLines[i] = result.nextText\n    changed ||= result.changed\n    if (result.transform) {\n      transforms.push(result.transform)\n    }\n  }\n\n  if (!changed) {\n    return {\n      content: code,\n      selectionStart,\n      selectionEnd,\n      changed: false,\n    }\n  }\n\n  const nextContent = nextLines.join('\\n')\n  if (moveToNextLine && selectionStart === selectionEnd) {\n    const nextStarts = getLineStartOffsets(nextLines)\n    const nextLineIndex = startLineIndex + 1\n    const nextOffset = nextStarts[nextLineIndex] ?? nextContent.length\n    return {\n      content: nextContent,\n      selectionStart: nextOffset,\n      selectionEnd: nextOffset,\n      changed: true,\n    }\n  }\n\n  return {\n    content: nextContent,\n    selectionStart: mapOffset(selectionStart, transforms),\n    selectionEnd: mapOffset(selectionEnd, transforms),\n    changed: true,\n  }\n}\n\n/** Toggle comment on all lines touched by the current selection range. */\nexport function toggleCommentBySelection(\n  code: string,\n  selectionStart: number,\n  selectionEnd: number,\n  moveToNextLine = false,\n): CommentToggleResult {\n  const normalizedCode = normalizeLineEndings(code)\n  const lines = getLines(normalizedCode)\n  const { start, end } = getSelectionRange(selectionStart, selectionEnd)\n  const startLineIndex = getLineIndexAtOffset(lines, start)\n  const endLineIndex =\n    start === end ? startLineIndex : getLineIndexAtOffset(lines, Math.max(start, end - 1))\n\n  return toggleCommentLines(\n    normalizedCode,\n    selectionStart,\n    selectionEnd,\n    startLineIndex,\n    endLineIndex,\n    moveToNextLine,\n  )\n}\n\n/** Toggle comment on a single line identified by its zero-based index (used for gutter clicks). */\nexport function toggleCommentByLine(\n  code: string,\n  lineIndex: number,\n  selectionStart: number,\n  selectionEnd: number,\n): CommentToggleResult {\n  const normalizedCode = normalizeLineEndings(code)\n  const lines = getLines(normalizedCode)\n  if (lineIndex < 0 || lineIndex >= lines.length) {\n    return {\n      content: normalizedCode,\n      selectionStart,\n      selectionEnd,\n      changed: false,\n    }\n  }\n\n  return toggleCommentLines(\n    normalizedCode,\n    selectionStart,\n    selectionEnd,\n    lineIndex,\n    lineIndex,\n    false,\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/History.module.scss",
    "content": ".selected {\n  background: var(--swh-tree-selected-bg);\n}\n"
  },
  {
    "path": "src/renderer/components/History.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IHostsHistoryObject } from '@common/data'\nimport events from '@common/events'\nimport {\n  Box,\n  Button,\n  Center,\n  Flex,\n  Group,\n  Loader,\n  NativeSelect,\n  Text,\n  Tooltip,\n} from '@mantine/core'\nimport HostsViewer from '@renderer/components/HostsViewer'\nimport SideDrawer from '@renderer/components/SideDrawer'\nimport { actions } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useConfigs from '@renderer/models/useConfigs'\nimport useI18n from '@renderer/models/useI18n'\nimport { IconFileTime, IconHelpCircle, IconHistory, IconX } from '@tabler/icons-react'\nimport clsx from 'clsx'\nimport dayjs from 'dayjs'\nimport prettyBytes from 'pretty-bytes'\nimport React, { useState } from 'react'\nimport styles from './History.module.scss'\n\ninterface IHistoryProps {\n  list: IHostsHistoryObject[]\n  selected_item: IHostsHistoryObject | undefined\n  setSelectedItem: (item: IHostsHistoryObject) => void\n}\n\nconst HistoryList = (props: IHistoryProps): React.ReactElement => {\n  const { list, selected_item, setSelectedItem } = props\n  const { lang } = useI18n()\n\n  if (list.length === 0) {\n    return (\n      <Center h=\"100%\" style={{ opacity: 0.5, fontSize: 'var(--mantine-font-size-lg)' }}>\n        {lang.no_record}\n      </Center>\n    )\n  }\n\n  return (\n    <Flex h=\"100%\" mih={300}>\n      <Box\n        style={{\n          flex: 1,\n          marginRight: 12,\n          border: '1px solid var(--swh-border-color-0)',\n          borderRadius: 6,\n          overflow: 'hidden',\n        }}\n      >\n        <HostsViewer content={selected_item ? selected_item.content : ''} />\n      </Box>\n      <Box\n        w={200}\n        h=\"100%\"\n        style={{\n          overflow: 'auto',\n          border: '1px solid var(--swh-border-color-0)',\n          borderRadius: 6,\n        }}\n      >\n        {list.map((item) => (\n          <Box\n            key={item.id}\n            onClick={() => setSelectedItem(item)}\n            px=\"12px\"\n            py=\"8px\"\n            style={{ userSelect: 'none' }}\n            className={clsx(item.id === selected_item?.id && styles.selected)}\n          >\n            <Group gap=\"8px\" wrap=\"nowrap\" align=\"flex-start\">\n              <Box>\n                <IconFileTime size={16} />\n              </Box>\n              <Box style={{ minWidth: 0 }}>\n                <Text size=\"sm\">{dayjs(item.add_time_ms).format('YYYY-MM-DD HH:mm:ss')}</Text>\n                <Group\n                  gap=\"8px\"\n                  style={{\n                    lineHeight: '14px',\n                    fontSize: 9,\n                    opacity: 0.6,\n                  }}\n                >\n                  <Box>{item.content.split('\\n').length} lines</Box>\n                  <Box>{prettyBytes(item.content.length)}</Box>\n                </Group>\n              </Box>\n            </Group>\n          </Box>\n        ))}\n      </Box>\n    </Flex>\n  )\n}\n\nconst Loading = () => (\n  <Center h={300}>\n    <Group gap=\"12px\">\n      <Loader size=\"lg\" />\n      <Text>Loading...</Text>\n    </Group>\n  </Center>\n)\n\nconst History = () => {\n  const { configs, updateConfigs } = useConfigs()\n  const [is_open, setIsOpen] = useState(false)\n  const [is_loading, setIsLoading] = useState(false)\n  const [list, setList] = useState<IHostsHistoryObject[]>([])\n  const [selected_item, setSelectedItem] = useState<IHostsHistoryObject>()\n\n  const { lang } = useI18n()\n\n  const loadData = async () => {\n    setIsLoading(true)\n    let next_list = await actions.getHistoryList()\n    next_list = next_list.reverse()\n    setList(next_list)\n    if (!selected_item) {\n      setSelectedItem(next_list[0])\n    }\n    setIsLoading(false)\n\n    return next_list\n  }\n\n  const onClose = () => {\n    setIsOpen(false)\n    setList([])\n  }\n\n  const deleteItem = async (id: string) => {\n    if (!confirm(lang.system_hosts_history_delete_confirm)) {\n      return\n    }\n\n    let idx = list.findIndex((i) => i.id === id)\n    await actions.deleteHistory(id)\n    setSelectedItem(undefined)\n    let list2 = await loadData()\n\n    let next_item = list2[idx] || list2[idx - 1]\n    if (next_item) {\n      setSelectedItem(next_item)\n    }\n  }\n\n  const updateHistoryLimit = async (value: number) => {\n    if (!value || value < 0) return\n    await updateConfigs({ history_limit: value })\n  }\n\n  useOnBroadcast(events.show_history, () => {\n    setIsOpen(true)\n    loadData().catch((e) => {\n      console.error(e)\n    })\n  })\n\n  let history_limit_values: number[] = [10, 50, 100, 500]\n  if (configs && !history_limit_values.includes(configs.history_limit)) {\n    history_limit_values.push(configs.history_limit)\n    history_limit_values.sort()\n  }\n\n  return (\n    <SideDrawer\n      opened={is_open}\n      onClose={onClose}\n      size=\"lg\"\n      title={\n        <Group gap=\"8px\">\n          <IconHistory size={16} />\n          <Box>{lang.system_hosts_history}</Box>\n        </Group>\n      }\n      footer={\n        <Flex align=\"center\" gap=\"12px\">\n          <Box>{lang.system_hosts_history_limit}</Box>\n          <NativeSelect\n            data={history_limit_values.map((v) => v.toString())}\n            value={String(configs?.history_limit ?? '')}\n            onChange={(e) => updateHistoryLimit(parseInt(e.target.value || '0'))}\n            w={100}\n          />\n          <Tooltip label={lang.system_hosts_history_help}>\n            <Box style={{ display: 'flex' }}>\n              <IconHelpCircle size={16} />\n            </Box>\n          </Tooltip>\n          <Box style={{ flex: 1 }} />\n          <Button\n            variant=\"outline\"\n            color=\"red\"\n            disabled={!selected_item}\n            onClick={() => selected_item && deleteItem(selected_item.id)}\n            leftSection={<IconX size={16} />}\n          >\n            {lang.delete}\n          </Button>\n          <Button onClick={onClose} variant=\"outline\">\n            {lang.close}\n          </Button>\n        </Flex>\n      }\n    >\n      <Box style={{ height: '100%' }}>\n        {is_loading ? (\n          <Loading />\n        ) : (\n          <HistoryList\n            list={list}\n            selected_item={selected_item}\n            setSelectedItem={setSelectedItem}\n          />\n        )}\n      </Box>\n    </SideDrawer>\n  )\n}\n\nexport default History\n"
  },
  {
    "path": "src/renderer/components/HostsViewer.module.scss",
    "content": "@use \"../styles/common\";\n\n.root {\n  @include common.code;\n  height: 100%;\n}\n\n.content {\n  height: calc(100% - var(--swh-status-bar-height));\n  background: var(--swh-editor-read-only-bg);\n  font-size: var(--swh-editor-font-size);\n  line-height: var(--swh-editor-line-height);\n  color: var(--swh-editor-text-color);\n  overflow: auto;\n  padding: 8px 10px;\n}\n\n.line {\n  min-height: var(--swh-editor-line-height);\n}\n"
  },
  {
    "path": "src/renderer/components/HostsViewer.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport StatusBar from '@renderer/components/StatusBar'\nimport styles from './HostsViewer.module.scss'\n\ninterface Props {\n  content: string\n}\n\nconst HostsViewer = (props: Props) => {\n  const { content } = props\n  const lines = content.split('\\n')\n\n  const Line = (p: { line: string }) => {\n    return <div className={styles.line}>{p.line}</div>\n  }\n\n  return (\n    <div className={styles.root}>\n      <div className={styles.content}>\n        {lines.map((line, idx) => (\n          <Line line={line} key={idx} />\n        ))}\n      </div>\n      <StatusBar line_count={lines.length} bytes={content.length} read_only={true} />\n    </div>\n  )\n}\n\nexport default HostsViewer\n"
  },
  {
    "path": "src/renderer/components/ItemIcon.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport {\n  IconDeviceDesktop,\n  IconFileText,\n  IconFolder,\n  IconStack2,\n  IconTrash,\n  IconWorld,\n} from '@tabler/icons-react'\n\ninterface Props {\n  type?: string\n  is_collapsed?: boolean\n}\n\nconst ItemIcon = (props: Props) => {\n  const { type, is_collapsed } = props\n\n  const iconAttrs = {\n    size: 16,\n    stroke: 1.5,\n  }\n\n  switch (type) {\n    case 'folder':\n      return is_collapsed ? <IconFolder {...iconAttrs} /> : <IconFolder {...iconAttrs} />\n    case 'remote':\n      return <IconWorld {...iconAttrs} />\n    case 'group':\n      return <IconStack2 {...iconAttrs} />\n    case 'system':\n      return <IconDeviceDesktop {...iconAttrs} />\n    case 'trashcan':\n      return <IconTrash {...iconAttrs} />\n    default:\n      return <IconFileText {...iconAttrs} />\n  }\n}\n\nexport default ItemIcon\n"
  },
  {
    "path": "src/renderer/components/Lang.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { LocaleName } from '@common/i18n'\nimport useI18n from '@renderer/models/useI18n'\nimport React from 'react'\n\ninterface Props {\n  locale: LocaleName\n  children: string | React.ReactElement | React.ReactElement[]\n}\n\nconst Lang = (props: Props): React.ReactElement | null => {\n  const { locale } = useI18n()\n\n  if (locale !== props.locale) {\n    return null\n  }\n\n  return <>{props.children}</>\n}\n\nexport default Lang\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/SystemHostsItem.module.scss",
    "content": ".root {\n  height: var(--swh-tree-row-height);\n  line-height: var(--swh-tree-row-height);\n  border-radius: var(--swh-border-radius);\n  padding: 0 10px;\n  cursor: default;\n}\n\n.selected {\n  background: var(--swh-tree-selected-bg);\n}\n\n.icon {\n  margin-right: 0.5em;\n  display: inline-block;\n  vertical-align: middle;\n}\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/SystemHostsItem.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport clsx from 'clsx'\nimport styles from './SystemHostsItem.module.scss'\n\nconst SystemHostsItem = () => {\n  const { i18n } = useI18n()\n  const { current_hosts, setCurrentHosts } = useHostsData()\n\n  const is_selected = !current_hosts\n\n  const showSystemHosts = () => {\n    setCurrentHosts(null)\n  }\n\n  return (\n    <div className={clsx(styles.root, is_selected && styles.selected)} onClick={showSystemHosts}>\n      <span className={styles.icon}>\n        <ItemIcon type=\"system\" />\n      </span>\n      <span>{i18n.lang.system_hosts}</span>\n    </div>\n  )\n}\n\nexport default SystemHostsItem\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/Trashcan.module.scss",
    "content": ".root {\n  user-select: none;\n}\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/Trashcan.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ITrashcanListObject } from '@common/data'\nimport TrashcanItem from '@renderer/components/LeftPanel/TrashcanItem'\nimport list_styles from '@renderer/components/List/index.module.scss'\nimport { Tree } from '@renderer/components/Tree'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport { useEffect, useState } from 'react'\nimport { BiChevronRight } from 'react-icons/bi'\nimport styles from './Trashcan.module.scss'\n\nconst Trashcan = () => {\n  const { lang } = useI18n()\n  const { hosts_data, current_hosts, setCurrentHosts } = useHostsData()\n  const [trash_list, setTrashList] = useState<ITrashcanListObject[]>([])\n  const [is_collapsed, setIsCollapsed] = useState(true)\n\n  useEffect(() => {\n    let root: ITrashcanListObject = {\n      id: '0',\n      data: {\n        id: '0',\n        title: lang.trashcan,\n      },\n      add_time_ms: 0,\n      children: [],\n      can_drag: false,\n      can_select: false,\n      is_collapsed,\n      is_root: true,\n      type: 'trashcan',\n      parent_id: null,\n    }\n\n    let list: ITrashcanListObject[] = [root]\n\n    hosts_data.trashcan.map((i) => {\n      root.children &&\n        root.children.push({\n          ...i,\n          id: i.data.id,\n          can_drag: false,\n          type: i.data.type,\n        })\n    })\n\n    setTrashList(list)\n  }, [hosts_data.trashcan, is_collapsed])\n\n  const onSelect = (ids: string[]) => {\n    let id = ids[0]\n    let item = hosts_data.trashcan.find((i) => i.data.id === id)\n    if (!item) return\n    setCurrentHosts(item.data)\n  }\n\n  return (\n    <div className={styles.root}>\n      <Tree\n        data={trash_list}\n        nodeRender={(item) => <TrashcanItem data={item as ITrashcanListObject} />}\n        collapseArrow={\n          <div\n            style={{\n              width: '20px',\n              height: '20px',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            <BiChevronRight />\n          </div>\n        }\n        nodeClassName={list_styles.node}\n        nodeSelectedClassName={list_styles.node_selected}\n        nodeCollapseArrowClassName={list_styles.arrow}\n        onSelect={onSelect}\n        selected_ids={current_hosts ? [current_hosts.id] : []}\n        onChange={(list) => setIsCollapsed(!!list[0]?.is_collapsed)}\n      />\n    </div>\n  )\n}\n\nexport default Trashcan\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/TrashcanItem.module.scss",
    "content": ".root {\n}\n\n.count {\n  color: var(--swh-font-color-weak);\n  margin-left: 1em;\n  font-size: 90%;\n}\n\n.trashcan_title {\n  opacity: 0.5;\n}\n\n.title {\n  display: flex;\n  align-items: center;\n  height: var(--swh-tree-row-height);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/TrashcanItem.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ITrashcanListObject } from '@common/data'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport list_item_styles from '@renderer/components/List/ListItem.module.scss'\nimport { actions } from '@renderer/core/agent'\nimport { PopupMenu } from '@renderer/core/PopupMenu'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport clsx from 'clsx'\nimport styles from './TrashcanItem.module.scss'\n\ninterface Props {\n  data: ITrashcanListObject\n}\n\nconst TrashcanItem = (props: Props) => {\n  const { data } = props\n  const { lang } = useI18n()\n  const { hosts_data, loadHostsData } = useHostsData()\n\n  const onSelect = (i: any) => {\n    console.log(i)\n  }\n\n  const menu_for_all = new PopupMenu([\n    {\n      label: lang.trashcan_clear,\n      enabled: hosts_data.trashcan.length > 0,\n      click() {\n        if (confirm(lang.trashcan_clear_confirm)) {\n          actions\n            .clearTrashcan()\n            .then(loadHostsData)\n            .catch((e) => console.error(e))\n        }\n      },\n    },\n  ])\n\n  const menu_for_item = new PopupMenu([\n    {\n      label: lang.trashcan_restore,\n      click() {\n        actions.restoreItemFromTrashcan(data.id).then((success) => {\n          success && loadHostsData()\n        })\n      },\n    },\n    {\n      type: 'separator',\n    },\n    {\n      label: lang.hosts_delete,\n      click() {\n        if (confirm(lang.trashcan_delete_confirm)) {\n          actions.deleteItemFromTrashcan(data.id).then((success) => {\n            success && loadHostsData()\n          })\n        }\n      },\n    },\n  ])\n\n  return (\n    <div\n      className={clsx(styles.root, data.is_root && styles.trashcan_title)}\n      onContextMenu={(e) => {\n        if (data.is_root) {\n          menu_for_all.show()\n        } else {\n          menu_for_item.show()\n        }\n\n        e.preventDefault()\n        e.stopPropagation()\n      }}\n    >\n      <div className={styles.title} onClick={onSelect}>\n        <span className={list_item_styles.icon}>\n          <ItemIcon type={data.type} is_collapsed={true} />\n        </span>\n\n        {data.data.title || lang.untitled}\n\n        {data.is_root ? <span className={styles.count}>{data.children?.length || 0}</span> : null}\n      </div>\n    </div>\n  )\n}\n\nexport default TrashcanItem\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/index.module.scss",
    "content": "@use \"../../styles/common\";\n\n.list {\n  position: relative;\n  height: calc(100vh - var(--swh-top-bar-height));\n  overflow: auto;\n  padding: 5px 10px;\n}\n\n:global(.platform-win32) {\n  .list {\n    @include common.swh-scroll-y();\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/LeftPanel/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport events from '@common/events'\nimport Trashcan from '@renderer/components/LeftPanel/Trashcan'\nimport List from '@renderer/components/List'\nimport { agent } from '@renderer/core/agent'\nimport { PopupMenu } from '@renderer/core/PopupMenu'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport styles from './index.module.scss'\n\ninterface Props {\n  width: number\n}\n\nconst Index = (props: Props) => {\n  const { lang } = useI18n()\n  const { hosts_data } = useHostsData()\n\n  const menu = new PopupMenu([\n    {\n      label: lang.hosts_add,\n      click() {\n        agent.broadcast(events.add_new)\n      },\n    },\n  ])\n\n  return (\n    <div className={styles.list} onContextMenu={() => menu.show()}>\n      <List />\n      {hosts_data.trashcan.length > 0 ? <Trashcan /> : null}\n    </div>\n  )\n}\n\nexport default Index\n"
  },
  {
    "path": "src/renderer/components/List/ListItem.module.scss",
    "content": ".root {\n  display: flex;\n\n  &.selected:not(.is_tray):hover {\n    .edit {\n      display: flex;\n    }\n  }\n\n  .edit {\n    display: none;\n    align-items: center;\n  }\n}\n\n.title {\n  display: flex;\n  align-items: center;\n  flex: 1 1 auto;\n  position: relative;\n  //padding-left: 10px;\n  height: var(--swh-tree-row-height);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  cursor: default;\n}\n\n.folder_open {\n  .folder_arrow {\n    transform: rotate(90deg);\n    margin-top: 1px;\n    margin-left: -10px;\n  }\n}\n\n.folder_arrow {\n  position: absolute;\n  margin-top: 1px;\n  margin-left: -10px;\n  cursor: pointer;\n  transition: 0.3s;\n  font-size: 8px;\n}\n\n.icon {\n  margin-right: 0.5em;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  &.folder {\n    cursor: pointer;\n  }\n\n  svg {\n    position: relative;\n    top: -1px;\n  }\n}\n\n.status {\n  flex: 0 0 auto;\n  display: flex;\n  align-items: center;\n  gap: 5px;\n  margin: auto 6px auto auto;\n}\n\n.children {\n  overflow: hidden;\n  transition: 0.3s;\n}\n"
  },
  {
    "path": "src/renderer/components/List/ListItem.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport { updateOneItem } from '@common/hostsFn'\nimport { IMenuItemOption } from '@common/types'\nimport { ActionIcon } from '@mantine/core'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport SwitchButton from '@renderer/components/SwitchButton'\nimport { actions, agent } from '@renderer/core/agent'\nimport { PopupMenu } from '@renderer/core/PopupMenu'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport { IconEdit } from '@tabler/icons-react'\nimport clsx from 'clsx'\nimport React, { useEffect, useRef, useState } from 'react'\nimport scrollIntoView from 'smooth-scroll-into-view-if-needed'\nimport styles from './ListItem.module.scss'\n\ninterface Props {\n  data: IHostsListObject\n  selected_ids: string[]\n  is_tray?: boolean\n}\n\nconst ListItem = (props: Props) => {\n  const { data, is_tray, selected_ids } = props\n  const { lang, i18n } = useI18n()\n  const { hosts_data, setList, current_hosts, setCurrentHosts } = useHostsData()\n  const [is_collapsed, setIsCollapsed] = useState(!!data.is_collapsed)\n  const [is_on, setIsOn] = useState(data.on)\n  const el = useRef<HTMLDivElement>(null)\n  // const [item_height, setItemHeight] = useState(0)\n  const ref_toast_refresh = useRef<string | null>(null)\n\n  useEffect(() => {\n    setIsOn(data.on)\n  }, [data])\n\n  useEffect(() => {\n    const is_selected = data.id === current_hosts?.id\n\n    if (is_selected && el.current) {\n      // el.current.scrollIntoViewIfNeeded()\n      scrollIntoView(el.current, {\n        behavior: 'smooth',\n        scrollMode: 'if-needed',\n      })\n    }\n  }, [data, current_hosts, el])\n\n  const onSelect = () => {\n    setCurrentHosts(data.is_sys ? null : data)\n  }\n\n  const toggleIsCollapsed = () => {\n    if (!is_folder) return\n\n    let _is_collapsed = !is_collapsed\n    setIsCollapsed(_is_collapsed)\n    setList(\n      updateOneItem(hosts_data.list, {\n        id: data.id,\n        is_collapsed: _is_collapsed,\n      }),\n    ).catch((e) => console.error(e))\n  }\n\n  const toggleOn = (on?: boolean) => {\n    on = typeof on === 'boolean' ? on : !is_on\n    setIsOn(on)\n\n    agent.broadcast(events.toggle_item, data.id, on)\n  }\n\n  if (!data) return null\n\n  const is_folder = data.type === 'folder'\n  const is_selected = data.id === current_hosts?.id\n\n  return (\n    <div\n      className={clsx(styles.root, is_selected && styles.selected, is_tray && styles.is_tray)}\n      // className={clsx(styles.item, is_selected && styles.selected, is_collapsed && styles.is_collapsed)}\n      // style={{ paddingLeft: `${1.3 * level}em` }}\n      onContextMenu={(e) => {\n        let deal_count = 1\n        if (selected_ids.includes(data.id)) {\n          deal_count = selected_ids.length\n        }\n\n        let menu_items: IMenuItemOption[] = [\n          {\n            label: lang.edit,\n            click() {\n              agent.broadcast(events.edit_hosts_info, data)\n            },\n          },\n          {\n            label: lang.refresh,\n            async click() {\n              ref_toast_refresh.current = `${Date.now()}`\n\n              actions\n                .refreshHosts(data.id)\n                .then((r) => {\n                  console.log(r)\n                  if (!r.success) {\n                    console.error(r.message || r.code || 'Error!')\n                    return\n                  }\n\n                  console.log('OK!')\n                })\n                .catch((e) => {\n                  console.log(e)\n                  console.error(e.message)\n                })\n                .finally(() => {\n                  if (ref_toast_refresh.current) {\n                    ref_toast_refresh.current = null\n                  }\n                })\n            },\n          },\n          {\n            type: 'separator',\n          },\n          {\n            label:\n              deal_count === 1\n                ? lang.move_to_trashcan\n                : i18n.trans('move_items_to_trashcan', [deal_count.toLocaleString()]),\n            click() {\n              let ids = deal_count === 1 ? [data.id] : selected_ids\n              agent.broadcast(events.move_to_trashcan, ids)\n            },\n          },\n        ]\n\n        if (data.type !== 'remote') {\n          menu_items = menu_items.filter((i) => i.label !== lang.refresh)\n        }\n\n        const menu = new PopupMenu(menu_items)\n\n        !data.is_sys && !is_tray && menu.show()\n        e.preventDefault()\n        e.stopPropagation()\n      }}\n      ref={el}\n      onClick={(e: React.MouseEvent) => {\n        if (is_tray) {\n          e.preventDefault()\n          e.stopPropagation()\n        }\n      }}\n    >\n      <div className={styles.title} onClick={onSelect}>\n        <span className={clsx(styles.icon, is_folder && styles.folder)} onClick={toggleIsCollapsed}>\n          <ItemIcon type={data.is_sys ? 'system' : data.type} is_collapsed={data.is_collapsed} />\n        </span>\n        {data.title || lang.untitled}\n      </div>\n      <div className={styles.status}>\n        {data.is_sys ? null : (\n          <>\n            <div className={styles.edit}>\n              <ActionIcon\n                variant=\"subtle\"\n                color=\"gray\"\n                onClick={() => {\n                  agent.broadcast(events.edit_hosts_info, data)\n                }}\n                size={24}\n              >\n                <IconEdit size={16} stroke={1.5} />\n              </ActionIcon>\n            </div>\n            <SwitchButton on={!!is_on} onChange={(on) => toggleOn(on)} />\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default ListItem\n"
  },
  {
    "path": "src/renderer/components/List/index.module.scss",
    "content": ".root {\n\n}\n\n.node {\n  position: relative;\n  line-height: var(--swh-tree-row-height);\n  border-radius: 4px;\n\n  &:hover {\n    background: var(--swh-tree-hover-bg);\n  }\n}\n\n.node_selected {\n  background: var(--swh-tree-selected-bg);\n\n  &:hover {\n    background: var(--swh-tree-selected-bg);\n\n    .edit {\n      display: block;\n    }\n  }\n}\n\n.node_drop_in {\n  div[data-role=\"tree-node-body\"] {\n    background: var(--swh-primary-color);\n    color: var(--swh-font-color-reverse);\n    border-radius: var(--swh-border-radius);\n  }\n}\n\n.arrow {\n  font-size: 16px;\n}\n\n.for_drag {\n  border: 1px solid var(--swh-border-color-1);\n  background: var(--swh-tree-node-drag-bg);\n  padding: 4px 8px;\n  display: flex;\n  align-items: center;\n\n  span.icon {\n    margin-right: 8px;\n  }\n}\n\n.items_count {\n  background: var(--swh-tree-selected-bg);\n  margin-left: 0.5em;\n  padding: 2px 0.5em;\n  border-radius: var(--swh-border-radius);\n  font-size: 12px;\n}\n"
  },
  {
    "path": "src/renderer/components/List/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport { findItemById, getNextSelectedItem, setOnStateOfItem } from '@common/hostsFn'\nimport { IFindShowSourceParam } from '@common/types'\nimport { IHostsWriteOptions } from '@main/types'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport { Tree } from '@renderer/components/Tree'\nimport { actions, agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useConfigs from '@renderer/models/useConfigs'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport clsx from 'clsx'\nimport { useEffect, useState } from 'react'\nimport { BiChevronRight } from 'react-icons/bi'\nimport styles from './index.module.scss'\nimport ListItem from './ListItem'\n\ninterface Props {\n  is_tray?: boolean\n}\n\nconst List = (props: Props) => {\n  const { is_tray } = props\n  const { hosts_data, loadHostsData, setList, current_hosts, setCurrentHosts } = useHostsData()\n  const { configs } = useConfigs()\n  const { lang } = useI18n()\n  const [selected_ids, setSelectedIds] = useState<string[]>([current_hosts?.id || '0'])\n  const [show_list, setShowList] = useState<IHostsListObject[]>([])\n\n  useEffect(() => {\n    if (!is_tray) {\n      setShowList([\n        {\n          id: '0',\n          title: lang.system_hosts,\n          is_sys: true,\n        },\n        ...hosts_data.list,\n      ])\n    } else {\n      setShowList([...hosts_data.list])\n    }\n  }, [hosts_data])\n\n  useEffect(() => {\n    if (is_tray || !current_hosts) return\n    if (!hosts_data.trashcan.find((item) => item.data.id === current_hosts.id)) return\n\n    setSelectedIds([])\n  }, [current_hosts, hosts_data.trashcan, is_tray])\n\n  const onToggleItem = async (id: string, on: boolean) => {\n    console.log(`writeMode: ${configs?.write_mode}`)\n    console.log(`toggle hosts #${id} as ${on ? 'on' : 'off'}`)\n\n    if (!configs?.write_mode) {\n      agent.broadcast(events.show_set_write_mode, { id, on })\n      return\n    }\n\n    const new_list = setOnStateOfItem(\n      hosts_data.list,\n      id,\n      on,\n      configs?.choice_mode ?? 0,\n      configs?.multi_chose_folder_switch_all ?? false,\n    )\n    let success = await writeHostsToSystem(new_list)\n    if (success) {\n      console.log(lang.success)\n      agent.broadcast(events.set_hosts_on_status, id, on)\n    } else {\n      agent.broadcast(events.set_hosts_on_status, id, !on)\n    }\n  }\n\n  const writeHostsToSystem = async (\n    list?: IHostsListObject[],\n    options?: IHostsWriteOptions,\n  ): Promise<boolean> => {\n    if (!Array.isArray(list)) {\n      list = hosts_data.list\n    }\n\n    let content: string = await actions.getContentOfList(list)\n    const result = await actions.setSystemHosts(content, options)\n    if (result.success) {\n      setList(list).catch((e) => console.error(e))\n      // new Notification(lang.success, {\n      //   body: lang.hosts_updated,\n      // })\n\n      if (current_hosts) {\n        let hosts = findItemById(list, current_hosts.id)\n        if (hosts) {\n          agent.broadcast(events.set_hosts_on_status, current_hosts.id, hosts.on)\n        }\n      }\n    } else {\n      console.log(result)\n      loadHostsData().catch((e) => console.log(e))\n      let err_desc = lang.fail\n\n      // let body: string = lang.no_access_to_hosts\n      if (result.code === 'no_access') {\n        if (agent.platform === 'darwin' || agent.platform === 'linux') {\n          agent.broadcast(events.show_sudo_password_input, list)\n        }\n        // } else {\n        // body = result.message || 'Unknown error!'\n        err_desc = lang.no_access_to_hosts\n      }\n\n      // new Notification(lang.fail, {\n      //   body,\n      // })\n      console.error(err_desc)\n    }\n\n    agent.broadcast(events.tray_list_updated)\n\n    return result.success\n  }\n\n  if (!is_tray) {\n    useOnBroadcast(events.toggle_item, onToggleItem, [hosts_data, configs])\n    useOnBroadcast(events.write_hosts_to_system, writeHostsToSystem, [hosts_data])\n  } else {\n    useOnBroadcast(events.tray_list_updated, loadHostsData)\n  }\n\n  useOnBroadcast(\n    events.move_to_trashcan,\n    async (ids: string[]) => {\n      console.log(`move_to_trashcan: #${ids}`)\n      await actions.moveManyToTrashcan(ids)\n      await loadHostsData()\n\n      if (current_hosts && ids.includes(current_hosts.id)) {\n        // 选中删除指定节点后的兄弟节点\n        let next_item = getNextSelectedItem(hosts_data.list, (i) => ids.includes(i.id))\n        setCurrentHosts(next_item || null)\n        setSelectedIds(next_item ? [next_item.id] : [])\n      }\n    },\n    [current_hosts, hosts_data],\n  )\n\n  useOnBroadcast(\n    events.select_hosts,\n    async (id: string, wait_ms: number = 0) => {\n      let hosts = findItemById(hosts_data.list, id)\n      if (!hosts) {\n        if (wait_ms > 0) {\n          setTimeout(() => {\n            agent.broadcast(events.select_hosts, id, wait_ms - 50)\n          }, 50)\n        }\n        return\n      }\n\n      setCurrentHosts(hosts)\n      setSelectedIds([id])\n    },\n    [hosts_data],\n  )\n\n  useOnBroadcast(events.reload_list, loadHostsData)\n\n  useOnBroadcast(events.hosts_content_changed, async (hosts_id: string) => {\n    let list: IHostsListObject[] = await actions.getList()\n    let hosts = findItemById(list, hosts_id)\n    if (!hosts || !hosts.on) return\n\n    // 当前 hosts 是开启状态，且内容发生了变化\n    await writeHostsToSystem(list)\n  })\n\n  useOnBroadcast(events.show_source, async (params: IFindShowSourceParam) => {\n    agent.broadcast(events.select_hosts, params.item_id)\n  })\n\n  return (\n    <div className={styles.root}>\n      {/*<SystemHostsItem/>*/}\n      <Tree\n        data={show_list}\n        selected_ids={selected_ids}\n        onChange={(list) => {\n          setShowList(list)\n          setList(list).catch((e) => console.error(e))\n        }}\n        onSelect={(ids: string[]) => {\n          // console.log(ids)\n          setSelectedIds(ids)\n        }}\n        nodeRender={(data) => (\n          <ListItem key={data.id} data={data} is_tray={is_tray} selected_ids={selected_ids} />\n        )}\n        collapseArrow={\n          <div\n            style={{\n              width: '20px',\n              height: '20px',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            <BiChevronRight />\n          </div>\n        }\n        nodeAttr={(item) => {\n          return {\n            can_drag: !item.is_sys && !is_tray,\n            can_drop_before: !item.is_sys,\n            can_drop_in: item.type === 'folder',\n            can_drop_after: !item.is_sys,\n          }\n        }}\n        draggingNodeRender={(data) => {\n          return (\n            <div className={clsx(styles.for_drag)}>\n              <span className={clsx(styles.icon, data.type === 'folder' && styles.folder)}>\n                <ItemIcon\n                  type={data.is_sys ? 'system' : data.type}\n                  is_collapsed={data.is_collapsed}\n                />\n              </span>\n              <span>\n                {data.title || lang.untitled}\n                {selected_ids.length > 1 ? (\n                  <span className={styles.items_count}>\n                    {selected_ids.length} {lang.items}\n                  </span>\n                ) : null}\n              </span>\n            </div>\n          )\n        }}\n        nodeClassName={styles.node}\n        nodeDropInClassName={styles.node_drop_in}\n        nodeSelectedClassName={styles.node_selected}\n        nodeCollapseArrowClassName={styles.arrow}\n        allowed_multiple_selection={true}\n      />\n    </div>\n  )\n}\n\nexport default List\n"
  },
  {
    "path": "src/renderer/components/Loading.module.scss",
    "content": ".root {\n  padding: 40px 20px;\n}\n"
  },
  {
    "path": "src/renderer/components/Loading.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport useI18n from '@renderer/models/useI18n'\nimport styles from './Loading.module.scss'\n\nconst Loading = () => {\n  const { i18n } = useI18n()\n\n  return <div className={styles.root}>{i18n.lang.loading}</div>\n}\n\nexport default Loading\n"
  },
  {
    "path": "src/renderer/components/MainPanel/index.module.scss",
    "content": ".root {\n  width: 100%;\n  height: calc(100vh - var(--swh-top-bar-height));\n  //overflow: auto;\n  background: var(--swh-main-bg);\n}\n"
  },
  {
    "path": "src/renderer/components/MainPanel/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport events from '@common/events'\nimport HostsEditor from '@renderer/components/Editor/HostsEditor'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport styles from './index.module.scss'\n\nconst MainPanel = () => {\n  useOnBroadcast(events.cmd_run_result, (result) => {\n    // console.log(result)\n    if (!result.success) {\n      console.error(result.stderr || 'cmd run error')\n    }\n  })\n\n  return (\n    <div className={styles.root}>\n      <HostsEditor />\n    </div>\n  )\n}\n\nexport default MainPanel\n"
  },
  {
    "path": "src/renderer/components/Pref/Advanced.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType } from '@common/default_configs'\nimport { Button, Checkbox, Group, Stack, Tooltip } from '@mantine/core'\nimport { actions } from '@renderer/core/agent'\nimport useI18n from '@renderer/models/useI18n'\nimport React, { useEffect, useState } from 'react'\nimport styles from './styles.module.scss'\n\ninterface IProps {\n  data: ConfigsType\n  onChange: (kv: Partial<ConfigsType>) => void\n}\n\nconst PathLink = (props: { link: string }) => {\n  const { link } = props\n  const { lang } = useI18n()\n  const isDisabled = !link\n  return (\n    <Tooltip label={lang.click_to_open}>\n      <a\n        className={styles.link}\n        onClick={(e: React.MouseEvent) => {\n          e.preventDefault()\n          e.stopPropagation()\n          if (isDisabled) return\n          actions.showItemInFolder(link)\n        }}\n        href={isDisabled ? undefined : 'file://' + link}\n        style={{ opacity: isDisabled ? 0.5 : 1, pointerEvents: isDisabled ? 'none' : 'auto' }}\n      >\n        {link}\n      </a>\n    </Tooltip>\n  )\n}\n\nconst Advanced = (props: IProps) => {\n  const { data, onChange } = props\n  const { i18n, lang } = useI18n()\n  const [hosts_path, setHostsPath] = useState('')\n  const [data_dir, setDataDir] = useState('')\n  const [default_data_dir, setDefaultDataDir] = useState('')\n\n  useEffect(() => {\n    actions.getPathOfSystemHosts().then((hosts_path) => setHostsPath(hosts_path))\n    actions.getDataDir().then((data_dir) => setDataDir(data_dir))\n    actions.getDefaultDataDir().then((default_data_dir) => setDefaultDataDir(default_data_dir))\n  }, [])\n\n  return (\n    <Stack gap=\"40px\">\n      <div style={{ width: '100%' }}>\n        <div>{lang.usage_data_title}</div>\n        <div style={{ marginBottom: 8, opacity: 0.7, fontSize: 12 }}>{lang.usage_data_help}</div>\n        <Checkbox\n          checked={data.send_usage_data}\n          label={lang.usage_data_agree}\n          onChange={(e) => onChange({ send_usage_data: e.target.checked })}\n        />\n      </div>\n\n      <div style={{ width: '100%' }}>\n        <div>{lang.where_is_my_hosts}</div>\n        <div style={{ marginBottom: 8, opacity: 0.7, fontSize: 12 }}>{lang.your_hosts_file_is}</div>\n        <PathLink link={hosts_path} />\n      </div>\n\n      <div style={{ width: '100%' }}>\n        <div>{lang.where_is_my_data}</div>\n        <div style={{ marginBottom: 8, opacity: 0.7, fontSize: 12 }}>{lang.your_data_is}</div>\n        <Group gap=\"8px\">\n          <PathLink link={data_dir} />\n          <Button\n            variant=\"subtle\"\n            onClick={async () => {\n              let r = await actions.cmdChangeDataDir()\n              console.log(r)\n            }}\n          >\n            {lang.change}\n          </Button>\n\n          {data_dir !== default_data_dir && (\n            <Button\n              variant=\"subtle\"\n              onClick={async () => {\n                if (!confirm(i18n.trans('reset_data_dir_confirm', [default_data_dir]))) {\n                  return\n                }\n                let r = await actions.cmdChangeDataDir(true)\n                console.log(r)\n              }}\n            >\n              {lang.reset}\n            </Button>\n          )}\n        </Group>\n      </div>\n    </Stack>\n  )\n}\n\nexport default Advanced\n"
  },
  {
    "path": "src/renderer/components/Pref/Commands.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType } from '@common/default_configs'\nimport { Box, Button, Stack, Textarea } from '@mantine/core'\nimport CommandsHistory from '@renderer/components/Pref/CommandsHistory'\nimport useI18n from '@renderer/models/useI18n'\nimport { useState } from 'react'\n\ninterface IProps {\n  data: ConfigsType\n  onChange: (kv: Partial<ConfigsType>) => void\n}\n\nconst Commands = (props: IProps) => {\n  const { data, onChange } = props\n  const { lang } = useI18n()\n  const [show_history, setShowHistory] = useState(false)\n\n  const toggleShowHistory = () => {\n    setShowHistory(!show_history)\n  }\n\n  return (\n    <Stack gap=\"16px\">\n      <Box w=\"100%\">\n        <Box>{lang.commands_title}</Box>\n        <Box style={{ marginBottom: 12, opacity: 0.7, fontSize: 12 }}>{lang.commands_help}</Box>\n        <Textarea\n          rows={6}\n          placeholder={'# echo \"ok!\"'}\n          value={data.cmd_after_hosts_apply}\n          onChange={(e) => onChange({ cmd_after_hosts_apply: e.target.value })}\n        />\n      </Box>\n\n      <Box>\n        <Button variant=\"light\" onClick={toggleShowHistory}>\n          {show_history ? lang.hide_history : lang.show_history}\n        </Button>\n      </Box>\n\n      <Box w=\"100%\">\n        <CommandsHistory is_show={show_history} />\n      </Box>\n    </Stack>\n  )\n}\n\nexport default Commands\n"
  },
  {
    "path": "src/renderer/components/Pref/CommandsHistory.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ICommandRunResult } from '@common/data'\nimport { ActionIcon, Alert, Box, Button, Center, Group, Stack } from '@mantine/core'\nimport { actions } from '@renderer/core/agent'\nimport useI18n from '@renderer/models/useI18n'\nimport dayjs from 'dayjs'\nimport { useEffect, useState } from 'react'\nimport { BiTrash } from 'react-icons/bi'\n\ninterface Props {\n  is_show: boolean\n}\n\nconst CommandsHistory = (props: Props) => {\n  const { is_show } = props\n  const [list, setList] = useState<ICommandRunResult[]>([])\n  const { lang } = useI18n()\n\n  const loadData = async () => {\n    let data = await actions.cmdGetHistoryList()\n    data = data.reverse()\n    setList(data)\n  }\n\n  const deleteOneRecord = async (_id: string) => {\n    await actions.cmdDeleteHistory(_id)\n    setList(list.filter((i) => i._id !== _id))\n  }\n\n  const clearAll = async () => {\n    await actions.cmdClearHistory()\n    setList([])\n  }\n\n  useEffect(() => {\n    if (is_show) {\n      loadData()\n    }\n  }, [is_show])\n\n  if (!is_show) {\n    return null\n  }\n\n  if (list.length === 0) {\n    return <Center h=\"100px\">{lang.no_record}</Center>\n  }\n\n  return (\n    <Stack gap=\"8px\">\n      {list.map((item, idx) => {\n        return (\n          <Alert key={idx} color={item.success ? 'green' : 'red'} style={{ width: '100%' }}>\n            <div>\n              <Group gap=\"8px\">\n                <span>#{item._id}</span>\n                <span style={{ fontWeight: 'normal' }}>\n                  {dayjs(item.add_time_ms).format('YYYY-MM-DD HH:mm:ss')}\n                </span>\n                <Box style={{ flex: 1 }} />\n                <ActionIcon\n                  aria-label=\"delete\"\n                  size=\"sm\"\n                  variant=\"subtle\"\n                  onClick={() => item._id && deleteOneRecord(item._id)}\n                >\n                  <BiTrash />\n                </ActionIcon>\n              </Group>\n              {item.stdout ? (\n                <>\n                  <Box>\n                    <strong>stdout:</strong>\n                  </Box>\n                  <Box>\n                    <pre>{item.stdout}</pre>\n                  </Box>\n                </>\n              ) : null}\n              {item.stderr ? (\n                <>\n                  <Box>\n                    <strong>stderr:</strong>\n                  </Box>\n                  <Box>\n                    <pre>{item.stderr}</pre>\n                  </Box>\n                </>\n              ) : null}\n            </div>\n          </Alert>\n        )\n      })}\n\n      <Box pt=\"40px\">\n        <Button onClick={clearAll} variant=\"subtle\">\n          {lang.clear_history}\n        </Button>\n      </Box>\n    </Stack>\n  )\n}\n\nexport default CommandsHistory\n"
  },
  {
    "path": "src/renderer/components/Pref/General.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { http_api_port } from '@common/constants'\nimport { ConfigsType, ThemeType } from '@common/default_configs'\nimport { LocaleName } from '@common/i18n'\nimport { Box, Checkbox, Group, NativeSelect, Radio, Stack, Text } from '@mantine/core'\nimport { agent } from '@renderer/core/agent'\nimport useI18n from '@renderer/models/useI18n'\n\ninterface IProps {\n  data: ConfigsType\n  onChange: (kv: Partial<ConfigsType>) => void\n}\n\nconst General = (props: IProps) => {\n  const { data, onChange } = props\n  const { i18n, lang } = useI18n()\n  const { platform } = agent\n\n  const label_width = 80\n\n  return (\n    <Stack gap=\"16px\">\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Box w={label_width}>{lang.language}</Box>\n          <NativeSelect\n            value={data.locale}\n            onChange={(e) => onChange({ locale: e.target.value as LocaleName })}\n            data={[\n              { value: 'zh', label: '简体中文' },\n              { value: 'zh_hant', label: '繁體中文' },\n              { value: 'en', label: 'English' },\n              { value: 'fr', label: 'Français' },\n              { value: 'de', label: 'Deutsch' },\n              { value: 'ja', label: '日本語' },\n              { value: 'tr', label: 'Türkçe' },\n              { value: 'ko', label: '한국어' },\n            ]}\n            w={200}\n          />\n        </Group>\n      </Box>\n\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Box w={label_width}>{lang.theme}</Box>\n          <NativeSelect\n            value={data.theme}\n            onChange={(e) => onChange({ theme: e.target.value as ThemeType })}\n            data={[\n              { value: 'light', label: lang.theme_light },\n              { value: 'dark', label: lang.theme_dark },\n            ]}\n            w={200}\n          />\n        </Group>\n      </Box>\n\n      <Box w=\"100%\">\n        <Group align=\"flex-start\" gap=\"8px\">\n          <Box w={label_width}>{lang.write_mode}</Box>\n          <Stack gap=\"24px\">\n            <Radio.Group\n              value={data.write_mode || ''}\n              onChange={(v) => onChange({ write_mode: v as ConfigsType['write_mode'] })}\n            >\n              <Group gap=\"40px\">\n                <Radio value=\"append\" label={lang.append} />\n                <Radio value=\"overwrite\" label={lang.overwrite} />\n              </Group>\n            </Radio.Group>\n            <Text maw={350} c=\"dimmed\" size=\"sm\">\n              {data.write_mode === 'append' && lang.write_mode_append_help}\n              {data.write_mode === 'overwrite' && lang.write_mode_overwrite_help}\n            </Text>\n          </Stack>\n        </Group>\n      </Box>\n\n      <Box pb=\"24px\" w=\"100%\">\n        <Group align=\"flex-start\" gap=\"8px\">\n          <Box w={label_width}>{lang.choice_mode}</Box>\n          <Stack gap=\"24px\">\n            <Radio.Group\n              value={data.choice_mode.toString()}\n              onChange={(v) => onChange({ choice_mode: parseInt(v) as ConfigsType['choice_mode'] })}\n            >\n              <Group gap=\"40px\">\n                <Radio value=\"1\" label={lang.choice_mode_single} />\n                <Radio value=\"2\" label={lang.choice_mode_multiple} />\n              </Group>\n            </Radio.Group>\n            <Text maw={350} c=\"dimmed\" size=\"sm\">\n              {lang.choice_mode_desc}\n            </Text>\n          </Stack>\n        </Group>\n      </Box>\n\n      {platform === 'darwin' ? (\n        <Box w=\"100%\">\n          <Group>\n            <Checkbox\n              checked={data.show_title_on_tray}\n              onChange={(e) => onChange({ show_title_on_tray: e.target.checked })}\n              label={lang.show_title_on_tray}\n            />\n          </Group>\n        </Box>\n      ) : null}\n\n      <Box w=\"100%\">\n        <Group>\n          <Checkbox\n            checked={data.hide_at_launch}\n            onChange={(e) => onChange({ hide_at_launch: e.target.checked })}\n            label={lang.hide_at_launch}\n          />\n        </Group>\n      </Box>\n\n      {agent.platform === 'linux' ? (\n        <Box w=\"100%\">\n          <Group>\n            <Checkbox\n              checked={data.use_system_window_frame}\n              onChange={(e) => onChange({ use_system_window_frame: e.target.checked })}\n              label={lang.use_system_window_frame}\n            />\n          </Group>\n        </Box>\n      ) : null}\n\n      {agent.platform === 'darwin' ? (\n        <Box w=\"100%\">\n          <Group>\n            <Checkbox\n              checked={data.hide_dock_icon}\n              onChange={(e) => onChange({ hide_dock_icon: e.target.checked })}\n              label={lang.hide_dock_icon}\n            />\n          </Group>\n        </Box>\n      ) : null}\n\n      <Box w=\"100%\">\n        <Stack gap=\"16px\">\n          <Checkbox\n            checked={data.remove_duplicate_records}\n            onChange={(e) => onChange({ remove_duplicate_records: e.target.checked })}\n            label={lang.remove_duplicate_records}\n          />\n          <Box pl=\"20px\" c=\"dimmed\" fz=\"sm\">\n            {lang.remove_duplicate_records_desc}\n          </Box>\n        </Stack>\n      </Box>\n\n      <Box w=\"100%\">\n        <Stack gap=\"16px\">\n          <Checkbox\n            checked={data.tray_mini_window}\n            onChange={(e) => onChange({ tray_mini_window: e.target.checked })}\n            label={lang.tray_mini_window}\n          />\n        </Stack>\n      </Box>\n\n      <Box w=\"100%\">\n        <Stack gap=\"16px\">\n          <Checkbox\n            checked={data.multi_chose_folder_switch_all}\n            onChange={(e) => onChange({ multi_chose_folder_switch_all: e.target.checked })}\n            label={lang.multi_chose_folder_switch_all}\n          />\n        </Stack>\n      </Box>\n\n      <Box w=\"100%\">\n        <Stack gap=\"16px\">\n          <Checkbox\n            checked={data.http_api_on}\n            onChange={(e) => onChange({ http_api_on: e.target.checked })}\n            label={lang.http_api_on}\n          />\n          <Box pl=\"20px\" c=\"dimmed\" fz=\"sm\">\n            {i18n.trans('http_api_on_desc', [http_api_port.toString()])}\n          </Box>\n          <Stack pl=\"24px\" mt=\"4px\" gap=\"4px\">\n            <Checkbox\n              disabled={!data.http_api_on}\n              checked={data.http_api_only_local}\n              onChange={(e) => onChange({ http_api_only_local: e.target.checked })}\n              label={lang.http_api_only_local}\n            />\n          </Stack>\n        </Stack>\n      </Box>\n    </Stack>\n  )\n}\n\nexport default General\n"
  },
  {
    "path": "src/renderer/components/Pref/Proxy.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType, ProtocolType } from '@common/default_configs'\nimport { Box, Checkbox, Group, NativeSelect, Stack, TextInput } from '@mantine/core'\nimport useI18n from '@renderer/models/useI18n'\nimport { useState } from 'react'\n\ninterface IProps {\n  data: ConfigsType\n  onChange: (kv: Partial<ConfigsType>) => void\n}\n\nconst General = (props: IProps) => {\n  const { data, onChange } = props\n  const { lang } = useI18n()\n  const [is_use, setIsUse] = useState(data.use_proxy)\n\n  const label_width = 80\n\n  return (\n    <Stack gap=\"16px\">\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Checkbox\n            checked={data.use_proxy}\n            onChange={(e) => {\n              let is_use = e.target.checked\n              setIsUse(is_use)\n              onChange({ use_proxy: is_use })\n            }}\n            label={lang.use_proxy}\n          />\n        </Group>\n      </Box>\n\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Box w={label_width}>{lang.protocol}</Box>\n          <NativeSelect\n            disabled={!is_use}\n            value={data.proxy_protocol}\n            onChange={(e) => onChange({ proxy_protocol: e.target.value as ProtocolType })}\n            data={[\n              { value: 'http', label: 'HTTP' },\n              { value: 'https', label: 'HTTPS' },\n            ]}\n            w={200}\n          />\n        </Group>\n      </Box>\n\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Box w={label_width}>{lang.host}</Box>\n          <TextInput\n            style={{ width: '200px' }}\n            disabled={!is_use}\n            value={data.proxy_host}\n            onChange={(e) => onChange({ proxy_host: e.target.value })}\n          />\n        </Group>\n      </Box>\n\n      <Box w=\"100%\">\n        <Group gap=\"8px\">\n          <Box w={label_width}>{lang.port}</Box>\n          <TextInput\n            style={{ width: '80px' }}\n            disabled={!is_use}\n            type=\"number\"\n            value={data.proxy_port || ''}\n            onChange={(e) => onChange({ proxy_port: parseInt(e.target.value) || 0 })}\n          />\n        </Group>\n      </Box>\n    </Stack>\n  )\n}\n\nexport default General\n"
  },
  {
    "path": "src/renderer/components/Pref/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType } from '@common/default_configs'\nimport events from '@common/events'\nimport { Button, Group, ScrollArea, Tabs } from '@mantine/core'\nimport Proxy from '@renderer/components/Pref/Proxy'\nimport SideDrawer from '@renderer/components/SideDrawer'\nimport { agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useConfigs from '@renderer/models/useConfigs'\nimport useI18n from '@renderer/models/useI18n'\nimport { IconAdjustments } from '@tabler/icons-react'\nimport { useEffect, useState } from 'react'\nimport Advanced from './Advanced'\nimport Commands from './Commands'\nimport General from './General'\nimport styles from './styles.module.scss'\n\nconst PreferencePanel = () => {\n  const [is_open, setIsOpen] = useState(false)\n  const { configs, updateConfigs } = useConfigs()\n  const [data, setData] = useState<ConfigsType | null>(configs)\n  const { lang } = useI18n()\n  const onClose = () => {\n    setIsOpen(false)\n    setData(configs)\n  }\n\n  const onUpdate = (kv: Partial<ConfigsType>) => {\n    if (!data) return\n    setData({ ...data, ...kv })\n  }\n\n  const onSave = async () => {\n    if (!data) return\n    await updateConfigs(data)\n    setIsOpen(false)\n\n    agent.broadcast(events.config_updated, data)\n  }\n\n  useEffect(() => {\n    setData(configs)\n  }, [configs])\n\n  useOnBroadcast(events.show_preferences, async () => {\n    setIsOpen(true)\n  })\n\n  if (!data) {\n    console.log('invalid config data!')\n    return null\n  }\n\n  return (\n    <SideDrawer\n      opened={is_open}\n      onClose={onClose}\n      size=\"lg\"\n      title={\n        <Group gap=\"8px\">\n          <IconAdjustments size={16} />\n          <span>{lang.preferences}</span>\n        </Group>\n      }\n      scrollAreaStyle={{\n        overflow: 'hidden',\n      }}\n      footer={\n        <Group justify=\"flex-end\" gap=\"12px\">\n          <Button variant=\"outline\" onClick={onClose}>\n            {lang.btn_cancel}\n          </Button>\n          <Button onClick={onSave} color=\"blue\">\n            {lang.btn_ok}\n          </Button>\n        </Group>\n      }\n    >\n      <div style={{ display: 'flex', height: '100%', minHeight: 0, flexDirection: 'column' }}>\n        <Tabs defaultValue=\"general\" className={styles.tabs}>\n          <Tabs.List>\n            <Tabs.Tab value=\"general\">{lang.general}</Tabs.Tab>\n            <Tabs.Tab value=\"commands\">{lang.commands}</Tabs.Tab>\n            <Tabs.Tab value=\"proxy\">{lang.proxy}</Tabs.Tab>\n            <Tabs.Tab value=\"advanced\">{lang.advanced}</Tabs.Tab>\n          </Tabs.List>\n          <div className={styles.tab_panels}>\n            <Tabs.Panel value=\"general\" className={styles.tab_panel}>\n              <ScrollArea className={styles.scroll_area} offsetScrollbars=\"y\" scrollbars=\"y\">\n                <div className={styles.tab_panel_content}>\n                  <General data={data} onChange={onUpdate} />\n                </div>\n              </ScrollArea>\n            </Tabs.Panel>\n            <Tabs.Panel value=\"commands\" className={styles.tab_panel}>\n              <ScrollArea className={styles.scroll_area} offsetScrollbars=\"y\" scrollbars=\"y\">\n                <div className={styles.tab_panel_content}>\n                  <Commands data={data} onChange={onUpdate} />\n                </div>\n              </ScrollArea>\n            </Tabs.Panel>\n            <Tabs.Panel value=\"proxy\" className={styles.tab_panel}>\n              <ScrollArea className={styles.scroll_area} offsetScrollbars=\"y\" scrollbars=\"y\">\n                <div className={styles.tab_panel_content}>\n                  <Proxy data={data} onChange={onUpdate} />\n                </div>\n              </ScrollArea>\n            </Tabs.Panel>\n            <Tabs.Panel value=\"advanced\" className={styles.tab_panel}>\n              <ScrollArea className={styles.scroll_area} offsetScrollbars=\"y\" scrollbars=\"y\">\n                <div className={styles.tab_panel_content}>\n                  <Advanced data={data} onChange={onUpdate} />\n                </div>\n              </ScrollArea>\n            </Tabs.Panel>\n          </div>\n        </Tabs>\n      </div>\n    </SideDrawer>\n  )\n}\n\nexport default PreferencePanel\n"
  },
  {
    "path": "src/renderer/components/Pref/styles.module.scss",
    "content": ".link {\n  text-decoration: underline;\n  color: inherit;\n}\n\n.tabs {\n  display: flex;\n  flex: 1;\n  flex-direction: column;\n  height: 100%;\n  min-height: 0;\n}\n\n.tab_panels {\n  display: flex;\n  flex: 1;\n  flex-direction: column;\n  min-height: 0;\n  overflow: hidden;\n}\n\n.tab_panel {\n  flex: 1;\n  min-height: 0;\n  overflow: hidden;\n}\n\n.scroll_area {\n  height: 100%;\n}\n\n.tab_panel_content {\n  padding: 20px 0;\n}\n"
  },
  {
    "path": "src/renderer/components/SetWriteMode.module.scss",
    "content": ".root {\n}\n\n.label {\n  margin: 10px 0 20px 0;\n}\n"
  },
  {
    "path": "src/renderer/components/SetWriteMode.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { WriteModeType } from '@common/default_configs'\nimport events from '@common/events'\nimport { Button, Group, Modal, Radio, Text } from '@mantine/core'\nimport { agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useI18n from '@renderer/models/useI18n'\nimport { useState } from 'react'\nimport useConfigs from '../models/useConfigs'\nimport styles from './SetWriteMode.module.scss'\n\ninterface Props {}\n\ninterface IPendingData {\n  id: string\n  on: boolean\n}\n\nconst SetWriteMode = () => {\n  const { updateConfigs } = useConfigs()\n  const { lang } = useI18n()\n  const [opened, setOpened] = useState(false)\n  const [writeMode, setWriteMode] = useState<WriteModeType>(null)\n  const [pendingData, setPendingData] = useState<IPendingData | undefined>(undefined)\n\n  const onCancel = () => {\n    setOpened(false)\n  }\n\n  const onOk = async () => {\n    await updateConfigs({ write_mode: writeMode })\n    setOpened(false)\n\n    if (pendingData && pendingData.id) {\n      agent.broadcast(events.toggle_item, pendingData.id, pendingData.on)\n    }\n  }\n\n  useOnBroadcast(\n    events.show_set_write_mode,\n    (data?: IPendingData) => {\n      setOpened(true)\n      setPendingData(data)\n      agent.broadcast(events.active_main_window)\n    },\n    [],\n  )\n\n  return (\n    <Modal opened={opened} onClose={onCancel} centered padding={0} withCloseButton={false}>\n      <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>\n        <Modal.CloseButton />\n        <div style={{ padding: 'var(--mantine-spacing-md)', paddingBottom: 24 }}>\n          <div className={styles.label}>{lang.write_mode_set}</div>\n          <Radio.Group\n            value={writeMode || ''}\n            onChange={(v) => setWriteMode((v || null) as WriteModeType)}\n          >\n            <Group gap=\"40px\">\n              <Radio value=\"append\" label={lang.append} />\n              <Radio value=\"overwrite\" label={lang.overwrite} />\n            </Group>\n          </Radio.Group>\n\n          <Text size=\"sm\" mt=\"16px\" mih=\"32px\" c=\"dimmed\">\n            {writeMode === 'append' && lang.write_mode_append_help}\n            {writeMode === 'overwrite' && lang.write_mode_overwrite_help}\n          </Text>\n        </div>\n        <Group\n          justify=\"flex-end\"\n          gap=\"12px\"\n          style={{\n            borderTop: '1px solid var(--swh-border-color-1)',\n            padding: 'var(--mantine-spacing-md)',\n          }}\n        >\n          <Button variant=\"outline\" onClick={onCancel}>\n            {lang.btn_cancel}\n          </Button>\n          <Button color=\"blue\" onClick={onOk}>\n            {lang.btn_ok}\n          </Button>\n        </Group>\n      </div>\n    </Modal>\n  )\n}\n\nexport default SetWriteMode\n"
  },
  {
    "path": "src/renderer/components/SideDrawer.tsx",
    "content": "import type { DrawerProps } from '@mantine/core'\nimport { Box, Drawer } from '@mantine/core'\nimport type { CSSProperties, ReactNode } from 'react'\n\ninterface SideDrawerProps extends Omit<DrawerProps, 'children' | 'position' | 'styles'> {\n  children: ReactNode\n  footer?: ReactNode\n  scrollAreaStyle?: CSSProperties\n}\n\nconst SideDrawer = ({ children, footer, scrollAreaStyle, ...props }: SideDrawerProps) => {\n  return (\n    <Drawer\n      position=\"right\"\n      styles={{\n        content: {\n          display: 'flex',\n          flexDirection: 'column',\n          overflow: 'hidden',\n        },\n        body: {\n          display: 'flex',\n          flex: 1,\n          flexDirection: 'column',\n          minHeight: 0,\n          padding: 0,\n        },\n      }}\n      {...props}\n    >\n      <Box\n        style={{\n          flex: 1,\n          minHeight: 0,\n          overflow: 'auto',\n          padding: '0 var(--mantine-spacing-md)',\n          ...scrollAreaStyle,\n        }}\n      >\n        {children}\n      </Box>\n      {footer ? (\n        <Box\n          style={{\n            // borderTop: '1px solid var(--swh-border-color-1)',\n            padding: 'var(--mantine-spacing-md)',\n          }}\n        >\n          {footer}\n        </Box>\n      ) : null}\n    </Drawer>\n  )\n}\n\nexport default SideDrawer\n"
  },
  {
    "path": "src/renderer/components/StatusBar.module.scss",
    "content": ".root {\n  color: var(--swh-status-bar-font-color);\n  height: var(--swh-status-bar-height);\n  line-height: var(--swh-status-bar-height);\n  border-top: 1px solid var(--swh-border-color-1);\n  background: var(--swh-status-bar-bg);\n  font-size: var(--swh-status-bar-font-size);\n}\n"
  },
  {
    "path": "src/renderer/components/StatusBar.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { Box, Flex, Group } from '@mantine/core'\nimport prettyBytes from 'pretty-bytes'\nimport useI18n from '../models/useI18n'\nimport styles from './StatusBar.module.scss'\n\ninterface Props {\n  line_count: number\n  bytes: number\n  read_only?: boolean\n}\n\nconst StatusBar = (props: Props) => {\n  const { line_count, bytes, read_only } = props\n  const { i18n } = useI18n()\n\n  return (\n    <Flex\n      className={styles.root}\n      style={{ paddingLeft: '10px', paddingRight: '10px', userSelect: 'none' }}\n    >\n      <Group gap=\"16px\">\n        <Box>\n          {line_count} {line_count > 1 ? i18n.lang.lines : i18n.lang.line}\n        </Box>\n        <Box>{prettyBytes(bytes)}</Box>\n        <Box>{read_only ? i18n.lang.read_only : ''}</Box>\n      </Group>\n      <Box style={{ flex: 1 }} />\n      <Box>{/* right */}</Box>\n    </Flex>\n  )\n}\n\nexport default StatusBar\n"
  },
  {
    "path": "src/renderer/components/SudoPasswordInput.module.scss",
    "content": ".root {\n}\n\n.label {\n  margin: 10px 0 20px 0;\n}\n"
  },
  {
    "path": "src/renderer/components/SudoPasswordInput.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IHostsListObject } from '@common/data'\nimport events from '@common/events'\nimport { Button, Group, Modal, PasswordInput } from '@mantine/core'\nimport { agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport React, { useState } from 'react'\nimport useI18n from '../models/useI18n'\nimport styles from './SudoPasswordInput.module.scss'\n\nconst SudoPasswordInput = () => {\n  const { lang } = useI18n()\n  const [opened, setOpened] = useState(false)\n  const [pswd, setPswd] = useState('')\n  const [tmpList, setTmpList] = useState<IHostsListObject[] | undefined>()\n  const ipt_ref = React.useRef<HTMLInputElement>(null)\n\n  const onCancel = () => {\n    setOpened(false)\n    setPswd('')\n  }\n\n  const onOk = async () => {\n    setOpened(false)\n    setPswd('')\n    agent.broadcast(events.write_hosts_to_system, tmpList, { sudo_pswd: pswd })\n  }\n\n  useOnBroadcast(\n    events.show_sudo_password_input,\n    (list?: IHostsListObject[]) => {\n      setTmpList(list)\n      setOpened(true)\n      agent.broadcast(events.active_main_window)\n    },\n    [tmpList],\n  )\n\n  return (\n    <Modal opened={opened} onClose={onCancel} centered withCloseButton={false}>\n      <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>\n        <div className={styles.label}>\n          {lang.sudo_prompt_title}\n          {lang.colon}\n        </div>\n        <PasswordInput\n          ref={ipt_ref}\n          value={pswd}\n          onChange={(e) => setPswd(e.target.value)}\n          autoFocus={true}\n          data-autofocus\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') onOk()\n          }}\n        />\n        <Group justify=\"flex-end\" gap=\"12px\" mt={20}>\n          <Button variant=\"outline\" onClick={onCancel}>\n            {lang.btn_cancel}\n          </Button>\n          <Button color=\"blue\" onClick={onOk}>\n            {lang.btn_ok}\n          </Button>\n        </Group>\n      </div>\n    </Modal>\n  )\n}\n\nexport default SudoPasswordInput\n"
  },
  {
    "path": "src/renderer/components/SwitchButton.module.scss",
    "content": "@use 'sass:math';\n\n$swh-btn-width: 1.6em;\n$swh-btn-height: 0.9em;\n\n.root {\n  position: relative;\n  display: inline-block;\n  top: -1px;\n  width: $swh-btn-width;\n  height: $swh-btn-height;\n  border-radius: math.div($swh-btn-height, 2);\n  background: var(--swh-switch-button-bg-color-off);\n  box-shadow: 0 0 0 1px var(--swh-switch-button-main-color-off);\n  cursor: pointer;\n  transition: 0.3s;\n}\n\n.handler {\n  position: absolute;\n  //right: unset;\n  right: $swh-btn-width - $swh-btn-height;\n  width: $swh-btn-height;\n  height: $swh-btn-height;\n  border-radius: math.div($swh-btn-height, 2);\n  background: var(--swh-switch-button-main-color-off);\n  border: 1px solid var(--swh-switch-button-bg-color-off);\n  transition: 0.3s;\n}\n\n.on {\n  &.root {\n    background: var(--swh-switch-button-bg-color-on);\n    box-shadow: 0 0 0 1px var(--swh-switch-button-main-color-on);\n  }\n\n  .handler {\n    right: 0;\n    background: var(--swh-switch-button-main-color-on);\n    border: 1px solid var(--swh-switch-button-bg-color-on);\n  }\n}\n\n.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n"
  },
  {
    "path": "src/renderer/components/SwitchButton.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport clsx from 'clsx'\nimport { useEffect, useState } from 'react'\nimport styles from './SwitchButton.module.scss'\n\ninterface Props {\n  on: boolean\n  onChange?: (on: boolean) => void\n  disabled?: boolean\n}\n\nconst SwitchButton = (props: Props) => {\n  const { on, onChange, disabled } = props\n  const [is_on, setIsOn] = useState(on)\n  const [is_disabled, setIsDisabled] = useState(disabled)\n\n  const onClick = () => {\n    if (disabled) return\n\n    let new_status = !is_on\n    setIsOn(new_status)\n    if (typeof onChange === 'function') {\n      onChange(new_status)\n    }\n  }\n\n  useEffect(() => {\n    setIsOn(on)\n    setIsDisabled(disabled)\n  }, [on, disabled])\n\n  return (\n    <div\n      className={clsx(styles.root, is_on && styles.on, is_disabled && styles.disabled)}\n      onClick={onClick}\n    >\n      <div className={styles.handler} />\n    </div>\n  )\n}\n\nexport default SwitchButton\n"
  },
  {
    "path": "src/renderer/components/TopBar/ConfigMenu.module.scss",
    "content": ".menu_list {\n  max-height: calc(100vh - 80px);\n  overflow-y: auto;\n\n  button > span {\n    font-size: 1em;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/TopBar/ConfigMenu.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { feedback_url, homepage_url } from '@common/constants'\nimport events from '@common/events'\nimport { ActionIcon, Menu } from '@mantine/core'\nimport ImportFromUrl from '@renderer/components/TopBar/ImportFromUrl'\nimport { actions, agent } from '@renderer/core/agent'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport {\n  IconAdjustments,\n  IconCloudDownload,\n  IconCode,\n  IconDownload,\n  IconHome,\n  IconInfoCircle,\n  IconLogout,\n  IconMessage2,\n  IconRefresh,\n  IconSettings,\n  IconUpload,\n} from '@tabler/icons-react'\nimport { useState } from 'react'\nimport styles from './ConfigMenu.module.scss'\n\ninterface IProps {\n  iconSize?: number\n}\n\nconst ConfigMenu = (props: IProps) => {\n  const { iconSize = 16 } = props\n  const { lang } = useI18n()\n  const { loadHostsData, setCurrentHosts } = useHostsData()\n  const [show_import_from_url, setShowImportFromUrl] = useState(false)\n\n  const strokeWidth = 1.5\n\n  return (\n    <>\n      <Menu shadow=\"md\" withinPortal>\n        <Menu.Target>\n          <ActionIcon variant=\"subtle\" color=\"gray\">\n            <IconSettings size={iconSize} />\n          </ActionIcon>\n        </Menu.Target>\n        <Menu.Dropdown className={styles.menu_list}>\n          <Menu.Item\n            leftSection={<IconInfoCircle size={iconSize} stroke={strokeWidth} />}\n            onClick={() => agent.broadcast(events.show_about)}\n          >\n            {lang.about}\n          </Menu.Item>\n\n          <Menu.Divider />\n\n          <Menu.Item\n            leftSection={<IconRefresh size={iconSize} stroke={strokeWidth} />}\n            onClick={async () => {\n              let r = await actions.checkUpdate()\n              if (r === false) {\n                console.log(lang.is_latest_version_inform)\n              } else if (r === null) {\n                console.error(lang.check_update_failed)\n              }\n            }}\n          >\n            {lang.check_update}\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconMessage2 size={iconSize} stroke={strokeWidth} />}\n            onClick={() => actions.openUrl(feedback_url)}\n          >\n            {lang.feedback}\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconHome size={iconSize} stroke={strokeWidth} />}\n            onClick={() => actions.openUrl(homepage_url)}\n          >\n            {lang.homepage}\n          </Menu.Item>\n\n          <Menu.Divider />\n\n          <Menu.Item\n            leftSection={<IconUpload size={iconSize} stroke={strokeWidth} />}\n            onClick={async () => {\n              let r = await actions.exportData()\n              if (r === null) {\n                return\n              } else if (r === false) {\n                console.error(lang.import_fail)\n              } else {\n                console.log(lang.export_done)\n              }\n            }}\n          >\n            {lang.export}\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconDownload size={iconSize} stroke={strokeWidth} />}\n            onClick={async () => {\n              let r = await actions.importData()\n              if (r === null) {\n                return\n              } else if (r === true) {\n                console.log(lang.import_done)\n                await loadHostsData()\n                setCurrentHosts(null)\n              } else {\n                let description = lang.import_fail\n                if (typeof r === 'string') {\n                  description += ` [${r}]`\n                }\n\n                console.error(description)\n              }\n            }}\n          >\n            {lang.import}\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconCloudDownload size={iconSize} stroke={strokeWidth} />}\n            onClick={async () => {\n              setShowImportFromUrl(true)\n            }}\n          >\n            {lang.import_from_url}\n          </Menu.Item>\n\n          <Menu.Divider />\n\n          <Menu.Item\n            leftSection={<IconAdjustments size={iconSize} stroke={strokeWidth} />}\n            onClick={() => agent.broadcast(events.show_preferences)}\n          >\n            {lang.preferences}\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconCode size={iconSize} stroke={strokeWidth} />}\n            onClick={() => actions.cmdToggleDevTools()}\n          >\n            {lang.toggle_developer_tools}\n          </Menu.Item>\n\n          <Menu.Divider />\n\n          <Menu.Item\n            leftSection={<IconLogout size={iconSize} stroke={strokeWidth} />}\n            onClick={() => actions.quit()}\n          >\n            {lang.quit}\n          </Menu.Item>\n        </Menu.Dropdown>\n      </Menu>\n      <ImportFromUrl is_show={show_import_from_url} setIsShow={setShowImportFromUrl} />\n    </>\n  )\n}\n\nexport default ConfigMenu\n"
  },
  {
    "path": "src/renderer/components/TopBar/ImportFromUrl.module.scss",
    "content": ".root {\n}\n\n.label {\n  margin: 10px 0 20px 0;\n}\n"
  },
  {
    "path": "src/renderer/components/TopBar/ImportFromUrl.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { Button, Group, Modal, TextInput } from '@mantine/core'\nimport { actions } from '@renderer/core/agent'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport React, { useState } from 'react'\nimport styles from './ImportFromUrl.module.scss'\n\ninterface Props {\n  is_show: boolean\n  setIsShow: (show: boolean) => void\n}\n\nconst ImportFromUrl = (props: Props) => {\n  const { is_show: opened, setIsShow } = props\n  const { lang } = useI18n()\n  const { loadHostsData, setCurrentHosts } = useHostsData()\n  const [url, setUrl] = useState('')\n  const ipt_ref = React.useRef<HTMLInputElement>(null)\n\n  const onCancel = () => {\n    setIsShow(false)\n    setUrl('')\n  }\n\n  const onOk = async () => {\n    setIsShow(false)\n    console.log(`url: ${url}`)\n\n    if (url) {\n      let r = await actions.importDataFromUrl(url)\n      console.log(r)\n\n      if (r === true) {\n        // import success\n        console.log(lang.import_done)\n        await loadHostsData()\n        setCurrentHosts(null)\n      } else {\n        let description = lang.import_fail\n        if (typeof r === 'string') {\n          description += ` [${r}]`\n        }\n\n        console.error(description)\n      }\n    }\n    setUrl('')\n  }\n\n  return (\n    <Modal opened={opened} onClose={onCancel} centered padding={0} withCloseButton={false}>\n      <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>\n        <Modal.CloseButton />\n        <div style={{ padding: 'var(--mantine-spacing-md)', paddingBottom: 24 }}>\n          <div className={styles.label}>{lang.import_from_url}</div>\n          <TextInput\n            ref={ipt_ref}\n            value={url}\n            onChange={(e) => setUrl(e.target.value)}\n            autoFocus={true}\n            data-autofocus\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') onOk()\n            }}\n            placeholder={'http:// or https://'}\n          />\n        </div>\n        <Group\n          justify=\"flex-end\"\n          gap=\"12px\"\n          style={{\n            borderTop: '1px solid var(--swh-border-color-1)',\n            padding: 'var(--mantine-spacing-md)',\n          }}\n        >\n          <Button variant=\"outline\" onClick={onCancel}>\n            {lang.btn_cancel}\n          </Button>\n          <Button color=\"blue\" onClick={onOk} disabled={!url || !url.match(/^https?:\\/\\/\\w+/i)}>\n            {lang.btn_ok}\n          </Button>\n        </Group>\n      </div>\n    </Modal>\n  )\n}\n\nexport default ImportFromUrl\n"
  },
  {
    "path": "src/renderer/components/TopBar/index.module.scss",
    "content": "@use '../../styles/common';\n\n.root {\n  $w: 150px;\n  $p: 10px;\n\n  background: var(--swh-top-bar-bg);\n  border-bottom: 1px solid var(--swh-border-color-0);\n  display: flex;\n  width: 100%;\n  padding: 0 $p;\n  align-items: center;\n  align-content: center;\n  user-select: none;\n\n  .title {\n    max-width: calc(100vw - ($w + $p) * 2);\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    margin: 0 auto;\n  }\n}\n\n.title_wrapper {\n  display: flex;\n  flex: 1;\n  align-items: center;\n  justify-content: center;\n\n  @include common.win-drag;\n}\n\n.hosts_icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n:global(.platform-darwin) {\n  .root {\n    padding-left: 88px;\n  }\n}\n\n.read_only {\n  // color: var(--swh-font-color-weak);\n  background-color: var(--swh-top-bar-read-only-bg);\n  border-radius: var(--swh-border-radius);\n  font-size: 10px;\n  padding: 2px 6px;\n}\n"
  },
  {
    "path": "src/renderer/components/TopBar/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport events from '@common/events'\nimport { ActionIcon, Box, Flex } from '@mantine/core'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport SwitchButton from '@renderer/components/SwitchButton'\nimport ConfigMenu from '@renderer/components/TopBar/ConfigMenu'\nimport { actions, agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport {\n  IconHistory,\n  IconLayoutSidebarLeftCollapse,\n  IconLayoutSidebarLeftExpand,\n  IconPlus,\n  IconX,\n} from '@tabler/icons-react'\nimport { useEffect, useState } from 'react'\nimport styles from './index.module.scss'\n\ninterface IProps {\n  show_left_panel: boolean\n  use_system_window_frame: boolean\n}\n\nexport default (props: IProps) => {\n  const { show_left_panel, use_system_window_frame } = props\n  const { lang } = useI18n()\n  const { isHostsInTrashcan, current_hosts, isReadOnly } = useHostsData()\n  const [is_on, setIsOn] = useState(!!current_hosts?.on)\n  const iconSize = 20\n\n  const show_toggle_switch =\n    !show_left_panel && current_hosts && !isHostsInTrashcan(current_hosts.id)\n  const show_history = !current_hosts\n  const show_close_button =\n    (agent.platform === 'linux' && !use_system_window_frame) ||\n    (agent.platform !== 'darwin' && agent.platform !== 'linux')\n\n  useEffect(() => {\n    setIsOn(!!current_hosts?.on)\n  }, [current_hosts])\n\n  useOnBroadcast(\n    events.set_hosts_on_status,\n    (id: string, on: boolean) => {\n      if (current_hosts && current_hosts.id === id) {\n        setIsOn(on)\n      }\n    },\n    [current_hosts],\n  )\n\n  return (\n    <div className={styles.root}>\n      <Flex align=\"center\" justify=\"center\" gap={8}>\n        <ActionIcon\n          aria-label=\"Toggle sidebar\"\n          onClick={() => {\n            agent.broadcast(events.toggle_left_panel, !show_left_panel)\n          }}\n          variant=\"subtle\"\n          color=\"gray\"\n        >\n          {show_left_panel ? (\n            <IconLayoutSidebarLeftCollapse size={iconSize} />\n          ) : (\n            <IconLayoutSidebarLeftExpand size={iconSize} />\n          )}\n        </ActionIcon>\n        <ActionIcon\n          aria-label=\"Add\"\n          onClick={() => agent.broadcast(events.add_new)}\n          variant=\"subtle\"\n          color=\"gray\"\n        >\n          <IconPlus size={iconSize} />\n        </ActionIcon>\n      </Flex>\n\n      <Box className={styles.title_wrapper}>\n        <Flex className={styles.title} gap={8} align=\"center\" justify=\"center\">\n          {current_hosts ? (\n            <>\n              <span className={styles.hosts_icon}>\n                <ItemIcon type={current_hosts.type} is_collapsed={!current_hosts.folder_open} />\n              </span>\n              <span className={styles.hosts_title}>{current_hosts.title || lang.untitled}</span>\n            </>\n          ) : (\n            <>\n              <span className={styles.hosts_icon}>\n                <ItemIcon type=\"system\" />\n              </span>\n              <span className={styles.hosts_title}>{lang.system_hosts}</span>\n            </>\n          )}\n\n          {isReadOnly(current_hosts) ? (\n            <span className={styles.read_only}>{lang.read_only}</span>\n          ) : null}\n        </Flex>\n      </Box>\n\n      <Flex align=\"center\" justify=\"flex-end\" gap={8}>\n        {show_toggle_switch ? (\n          <Box mr=\"12px\">\n            <SwitchButton\n              on={is_on}\n              onChange={(on) => {\n                current_hosts && agent.broadcast(events.toggle_item, current_hosts.id, on)\n              }}\n            />\n          </Box>\n        ) : null}\n        {show_history ? (\n          <ActionIcon\n            aria-label=\"Show history\"\n            variant=\"subtle\"\n            color=\"gray\"\n            onClick={() => agent.broadcast(events.show_history)}\n          >\n            <IconHistory size={iconSize} />\n          </ActionIcon>\n        ) : null}\n\n        <ConfigMenu iconSize={iconSize} />\n\n        {show_close_button ? (\n          <ActionIcon\n            aria-label=\"Close window\"\n            variant=\"subtle\"\n            color=\"gray\"\n            onClick={() => actions.closeMainWindow()}\n          >\n            <IconX size={iconSize} />\n          </ActionIcon>\n        ) : null}\n      </Flex>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/Transfer.module.scss",
    "content": ".root {\n\n}\n\n.title {\n  span {\n    font-size: 80%;\n    color: var(--swh-font-color-weak);\n  }\n}\n\n.list {\n  height: 200px;\n  overflow: auto;\n}\n\n.item {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  user-select: none;\n}\n\n.selected {\n  background: var(--swh-tree-selected-bg);\n}\n"
  },
  {
    "path": "src/renderer/components/Transfer.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ActionIcon, Box, Center, Stack } from '@mantine/core'\nimport clsx from 'clsx'\nimport React, { useState } from 'react'\nimport { IoArrowBack, IoArrowForward } from 'react-icons/io5'\nimport useI18n from '../models/useI18n'\nimport styles from './Transfer.module.scss'\n\ntype IdType = string\n\ninterface ITransferSourceObject {\n  id: IdType\n\n  [key: string]: any\n}\n\ninterface IListProps {\n  data: ITransferSourceObject[]\n  selected_keys: IdType[]\n  setSelectedKeys: (ids: IdType[]) => void\n}\n\ninterface Props {\n  dataSource: ITransferSourceObject[]\n  targetKeys: IdType[]\n  render?: (obj: ITransferSourceObject) => React.ReactElement\n  onChange?: (next_target_keys: IdType[]) => void\n}\n\nconst Transfer = (props: Props) => {\n  const { dataSource, targetKeys, render, onChange } = props\n  const { lang } = useI18n()\n  const [right_keys, setRightKeys] = useState<IdType[]>(targetKeys)\n  const [left_selected_keys, setLeftSelectedKeys] = useState<IdType[]>([])\n  const [right_selected_keys, setRightSelectedKeys] = useState<IdType[]>([])\n\n  const List = (list_props: IListProps) => {\n    const { data, selected_keys, setSelectedKeys } = list_props\n\n    const toggleSelect = (id: IdType) => {\n      setSelectedKeys(\n        selected_keys.includes(id) ? selected_keys.filter((i) => i != id) : [...selected_keys, id],\n      )\n    }\n\n    return (\n      <div className={styles.list}>\n        {data.map((item) => {\n          if (!item || !item.id) return null\n          const is_selected = selected_keys.includes(item.id)\n\n          return (\n            <Box\n              key={item.id}\n              className={clsx(styles.item, is_selected && styles.selected)}\n              px=\"12px\"\n              py=\"4px\"\n              onClick={() => toggleSelect(item.id)}\n            >\n              {render ? render(item) : item.title || item.id}\n            </Box>\n          )\n        })}\n      </div>\n    )\n  }\n\n  const moveLeftToRight = () => {\n    let result = [...right_keys, ...left_selected_keys]\n    setRightKeys(result)\n    setLeftSelectedKeys([])\n    onChange && onChange(result)\n  }\n\n  const moveRightToLeft = () => {\n    let result = right_keys.filter((i) => !right_selected_keys.includes(i))\n    setRightKeys(result)\n    setRightSelectedKeys([])\n    onChange && onChange(result)\n  }\n\n  return (\n    <div className={styles.root}>\n      <Box\n        style={{\n          display: 'grid',\n          gridTemplateColumns: 'minmax(0, 1fr) 40px minmax(0, 1fr)',\n          gap: 4,\n        }}\n      >\n        <Box style={{ border: '1px solid var(--swh-border-color-0)', borderRadius: 6 }}>\n          <Box\n            className={styles.title}\n            px=\"12px\"\n            py=\"4px\"\n            style={{ borderBottom: '1px solid var(--swh-border-color-0)' }}\n          >\n            {lang.all}{' '}\n            <span>\n              (\n              {left_selected_keys.length === 0\n                ? dataSource.length\n                : `${left_selected_keys.length}/${dataSource.length}`}\n              )\n            </span>\n          </Box>\n          <List\n            data={dataSource.filter((i) => !right_keys.includes(i.id))}\n            selected_keys={left_selected_keys}\n            setSelectedKeys={setLeftSelectedKeys}\n          />\n        </Box>\n        <Center h=\"100%\">\n          <Stack gap=\"8px\">\n            <ActionIcon\n              size=\"sm\"\n              variant=\"outline\"\n              aria-label=\"Move to right\"\n              disabled={left_selected_keys.length === 0}\n              onClick={moveLeftToRight}\n            >\n              <IoArrowForward />\n            </ActionIcon>\n            <ActionIcon\n              size=\"sm\"\n              variant=\"outline\"\n              aria-label=\"Move to left\"\n              disabled={right_selected_keys.length === 0}\n              onClick={moveRightToLeft}\n            >\n              <IoArrowBack />\n            </ActionIcon>\n          </Stack>\n        </Center>\n        <Box style={{ border: '1px solid var(--swh-border-color-0)', borderRadius: 6 }}>\n          <Box\n            className={styles.title}\n            px=\"12px\"\n            py=\"4px\"\n            style={{ borderBottom: '1px solid var(--swh-border-color-0)' }}\n          >\n            {lang.selected}{' '}\n            <span>\n              (\n              {right_selected_keys.length === 0\n                ? right_keys.length\n                : `${right_selected_keys.length}/${right_keys.length}`}\n              )\n            </span>\n          </Box>\n          <List\n            data={\n              right_keys.map((id) => dataSource.find((i) => i.id === id)) as ITransferSourceObject[]\n            }\n            selected_keys={right_selected_keys}\n            setSelectedKeys={setRightSelectedKeys}\n          />\n        </Box>\n      </Box>\n    </div>\n  )\n}\n\nexport default Transfer\n"
  },
  {
    "path": "src/renderer/components/Tree/Node.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ITreeNodeData, NodeIdType } from '@common/tree'\nimport clsx from 'clsx'\nimport lodash from 'lodash'\nimport React, { useRef } from 'react'\nimport { isChildOf, isSelfOrChild } from './fn'\nimport styles from './style.module.scss'\nimport { DropWhereType, MultipleSelectType } from './Tree'\n\ndeclare global {\n  interface Window {\n    _t_dragover_id?: string\n    _t_dragover_ts: number\n  }\n}\n\nexport type NodeUpdate = (data: Partial<ITreeNodeData>) => void\n\ninterface INodeProps {\n  tree: ITreeNodeData[]\n  data: ITreeNodeData\n  nodeClassName?: string\n  nodeDropInClassName?: string\n  nodeSelectedClassName?: string\n  nodeCollapseArrowClassName?: string\n  drag_source_id: NodeIdType | null\n  drop_target_id: NodeIdType | null\n  drag_target_where: DropWhereType | null\n  onDragStart: (id: NodeIdType) => void\n  onDragEnd: () => void\n  setDropTargetId: (id: NodeIdType | null) => void\n  setDropWhere: (where: DropWhereType | null) => void\n  selected_ids: NodeIdType[]\n  onSelect: (id: NodeIdType, multiple_type?: MultipleSelectType) => void\n  level: number\n  is_dragging: boolean\n  render?: (data: ITreeNodeData, update: NodeUpdate) => React.ReactElement | null\n  draggingNodeRender?: (data: ITreeNodeData, source_ids: string[]) => React.ReactElement\n  collapseArrow?: string | React.ReactElement\n  onChange: (id: NodeIdType, data: Partial<ITreeNodeData>) => void\n  indent_px?: number\n  nodeAttr?: (node: ITreeNodeData) => Partial<ITreeNodeData>\n  has_no_child: boolean\n  no_child_no_indent?: boolean\n  allowed_multiple_selection?: boolean\n}\n\nconst Node = (props: INodeProps) => {\n  const {\n    data,\n    setDropTargetId,\n    setDropWhere,\n    drag_source_id,\n    drop_target_id,\n    drag_target_where,\n    level,\n    is_dragging,\n    render,\n    draggingNodeRender,\n    indent_px,\n    selected_ids,\n    onSelect,\n    onChange,\n    nodeAttr,\n    nodeClassName,\n    nodeCollapseArrowClassName,\n  } = props\n\n  const el_node = useRef<HTMLDivElement>(null)\n  const el_dragging = useRef<HTMLDivElement>(null)\n\n  const attr = nodeAttr ? nodeAttr(data) : data\n\n  const getTargetId = (el: HTMLElement | null): string | undefined => {\n    if (!el) return\n    let id = el.getAttribute('data-id')\n    return id || getTargetId(el.parentNode as HTMLElement)\n  }\n\n  const makeDraggingElement = (ne: DragEvent) => {\n    let el = el_dragging.current\n    if (!el) return\n\n    el.style.display = 'block'\n    ne.dataTransfer?.setDragImage(el, -4, -4)\n  }\n\n  const onDragStart = (e: React.DragEvent) => {\n    let ne = e.nativeEvent\n    if (ne.dataTransfer) {\n      ne.dataTransfer.dropEffect = 'move'\n      ne.dataTransfer.effectAllowed = 'move'\n      // ne.dataTransfer.setData('text', data.id)\n      // makeDraggingElement(ne)\n\n      if (draggingNodeRender) {\n        makeDraggingElement(ne)\n      }\n    }\n\n    props.onDragStart(data.id)\n  }\n\n  // const onDragEnter = (e: React.DragEvent) => {\n  //   console.log(`enter: ` + data.id)\n  // }\n\n  const onDragOver = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n\n    if (!is_dragging || !drag_source_id) return\n\n    let el_target = e.target as HTMLElement\n    if (!el_target) return\n\n    if (data.id === drag_source_id) return\n    if (isChildOf(props.tree, data.id, drag_source_id)) return\n\n    setDropTargetId(data.id)\n\n    let now = new Date().getTime()\n    if (window._t_dragover_id !== data.id) {\n      window._t_dragover_id = data.id\n      window._t_dragover_ts = now\n    }\n    if (data.children?.length && data.is_collapsed && now - window._t_dragover_ts > 1000) {\n      props.onChange(data.id, { is_collapsed: false })\n    }\n\n    // where\n    let ne = e.nativeEvent\n    let h = el_target.offsetHeight\n    let y = ne.offsetY\n    let where: DropWhereType | null = null\n    let h_2 = h >> 1\n    let h_4 = h >> 2\n    let h_threshold = attr.can_drop_in === false ? h_2 : h_4\n    if (y <= h_threshold) {\n      if (attr.can_drop_before === false) {\n        setDropWhere(null)\n        return\n      }\n      where = 'before'\n    } else if (y >= h - h_threshold) {\n      if (attr.can_drop_after === false) {\n        setDropWhere(null)\n        return\n      }\n      where = 'after'\n    } else {\n      if (attr.can_drop_in === false) {\n        setDropWhere(null)\n        return\n      }\n      where = 'in'\n    }\n    setDropWhere(where)\n  }\n\n  // const onDragLeave = (e: React.DragEvent) => {\n  //   console.log(`leave: ` + data.id)\n  // }\n\n  const onDragEnd = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    // console.log('onDragEnd.')\n    props.onDragEnd()\n\n    window._t_dragover_id = ''\n\n    el_dragging.current && (el_dragging.current.style.display = 'none')\n  }\n\n  const onUpdate = (kv: Partial<ITreeNodeData>) => {\n    onChange(data.id, kv)\n  }\n\n  const is_drag_source = drag_source_id === data.id\n  const is_drop_target = drop_target_id === data.id\n  const is_selected = selected_ids.includes(data.id)\n  const is_parent_is_drag_source = drag_source_id\n    ? isChildOf(props.tree, data.id, drag_source_id)\n    : false\n  const has_children = Array.isArray(data.children) && data.children.length > 0\n\n  return (\n    <>\n      <div\n        ref={el_node}\n        className={clsx(\n          styles.node,\n          is_dragging && styles.is_dragging,\n          (is_drag_source || is_parent_is_drag_source) && styles.is_source,\n          is_drop_target && drag_target_where === 'before' && styles.drop_before,\n          is_drop_target &&\n            drag_target_where === 'in' &&\n            (props.nodeDropInClassName || styles.drop_in),\n          is_drop_target && drag_target_where === 'after' && styles.drop_after,\n          is_selected && (props.nodeSelectedClassName || styles.selected),\n          nodeClassName,\n        )}\n        data-selected={is_selected ? '1' : '0'}\n        data-id={data.id}\n        draggable={attr.can_drag !== false}\n        onDragStart={onDragStart}\n        // onDragEnter={onDragEnter}\n        onDragOver={onDragOver}\n        // onDragLeave={onDragLeave}\n        onDragEnd={onDragEnd}\n        onDrop={onDragEnd}\n        onClick={(e) => {\n          if (attr.can_select === false) {\n            return\n          }\n          let multiple_type: MultipleSelectType = 0\n          if (e.shiftKey) {\n            multiple_type = 2\n          } else if (e.metaKey) {\n            multiple_type = 1\n          }\n          onSelect(data.id, multiple_type)\n        }}\n        style={{\n          paddingLeft: level * (indent_px || 20) + 4,\n        }}\n      >\n        <div\n          className={clsx(\n            styles.content,\n            props.has_no_child && props.no_child_no_indent && styles.no_children,\n          )}\n        >\n          <div className={styles.ln_header} data-role=\"tree-node-header\">\n            {has_children ? (\n              <div\n                className={clsx(\n                  styles.arrow,\n                  nodeCollapseArrowClassName,\n                  data.is_collapsed && styles.collapsed,\n                )}\n                data-collapsed={!!data.is_collapsed}\n                onClick={() => {\n                  props.onChange(data.id, { is_collapsed: !data.is_collapsed })\n                }}\n              >\n                {props.collapseArrow ? props.collapseArrow : '>'}\n              </div>\n            ) : null}\n          </div>\n          <div className={styles.ln_body} data-role=\"tree-node-body\">\n            {render ? render(data, onUpdate) : data.title || `node#${data.id}`}\n          </div>\n        </div>\n      </div>\n      {draggingNodeRender && (\n        <div ref={el_dragging} className={styles.for_dragging}>\n          {draggingNodeRender(data, selected_ids.includes(data.id) ? selected_ids : [data.id])}\n        </div>\n      )}\n      {has_children && data.children && !data.is_collapsed\n        ? data.children.map((node) => (\n            <Node {...props} key={node.id} data={node} level={level + 1} />\n          ))\n        : null}\n    </>\n  )\n}\n\nfunction diff<T>(a: T[], b: T[]): T[] {\n  return [...a.filter((i) => !b.includes(i)), ...b.filter((i) => !a.includes(i))]\n}\n\nfunction isEqual(prevProps: INodeProps, nextProps: INodeProps): boolean {\n  let { data, selected_ids, allowed_multiple_selection } = nextProps\n\n  if (!lodash.isEqual(prevProps.data, data)) {\n    return false\n  }\n\n  // select\n  let prev_selected_ids = prevProps.selected_ids\n\n  let diff_ids = diff<NodeIdType>(prev_selected_ids, selected_ids)\n  if (diff_ids.length > 0) {\n    if (allowed_multiple_selection) {\n      return false\n    } else {\n      for (let id of diff_ids) {\n        if (isSelfOrChild(data, id)) {\n          return false\n        }\n      }\n    }\n  }\n\n  // drag\n  if (prevProps.is_dragging !== nextProps.is_dragging) {\n    return false\n  }\n\n  let { drag_source_id, drop_target_id } = nextProps\n  if (\n    isSelfOrChild(data, drag_source_id) ||\n    isSelfOrChild(data, drop_target_id) ||\n    isSelfOrChild(data, prevProps.drag_source_id) ||\n    isSelfOrChild(data, prevProps.drop_target_id)\n  ) {\n    return false\n  }\n\n  return true\n}\n\nexport default React.memo(Node, isEqual)\n"
  },
  {
    "path": "src/renderer/components/Tree/Tree.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ITreeNodeData, NodeIdType } from '@common/tree'\nimport clsx from 'clsx'\nimport lodash from 'lodash'\nimport React, { useEffect, useState } from 'react'\nimport { canBeSelected, flatten, getNodeById, selectTo, treeMoveNode } from './fn'\nimport Node, { NodeUpdate } from './Node'\nimport styles from './style.module.scss'\n\nexport type DropWhereType = 'before' | 'in' | 'after'\nexport type MultipleSelectType = 0 | 1 | 2\n\ninterface ITreeProps {\n  data: ITreeNodeData[]\n  className?: string\n  nodeClassName?: string\n  nodeSelectedClassName?: string\n  nodeDropInClassName?: string\n  nodeCollapseArrowClassName?: string\n  nodeRender?: (node: ITreeNodeData, update: NodeUpdate) => React.ReactElement | null\n  nodeAttr?: (node: ITreeNodeData) => Partial<ITreeNodeData>\n  draggingNodeRender?: (node: ITreeNodeData, source_ids: string[]) => React.ReactElement\n  collapseArrow?: string | React.ReactElement\n  onChange?: (tree: ITreeNodeData[]) => void\n  indent_px?: number\n  selected_ids: NodeIdType[]\n  onSelect?: (ids: NodeIdType[]) => void\n  no_child_no_indent?: boolean\n  allowed_multiple_selection?: boolean\n}\n\nconst Tree = (props: ITreeProps) => {\n  const { data, className, onChange, allowed_multiple_selection } = props\n  const [tree, setTree] = useState<ITreeNodeData[]>([])\n  const [is_dragging, setIsDragging] = useState(false)\n  const [drag_source_id, setDragSourceId] = useState<NodeIdType | null>(null)\n  const [drop_target_id, setDropTargetId] = useState<NodeIdType | null>(null)\n  const [selected_ids, setSelectedIds] = useState<NodeIdType[]>(props.selected_ids || [])\n  const [drop_where, setDropWhere] = useState<DropWhereType | null>(null)\n\n  useEffect(() => {\n    setTree(lodash.cloneDeep(data))\n  }, [data])\n\n  useEffect(() => {\n    if (props.selected_ids && props.selected_ids.join(',') !== selected_ids.join(',')) {\n      setSelectedIds(props.selected_ids)\n    }\n  }, [props.selected_ids])\n\n  const onDragStart = (id: NodeIdType) => {\n    // console.log('onDragStart...')\n    setIsDragging(true)\n    setDragSourceId(id)\n    setDropTargetId(null)\n    setDropWhere(null)\n  }\n\n  const onDragEnd = () => {\n    // console.log(`onDragEnd, ${is_dragging}`)\n    if (!is_dragging) return\n\n    if (drag_source_id && drop_target_id && drop_where) {\n      // console.log(`onDragEnd: ${source_id} -> ${target_id} | ${drop_where}`)\n      let source_ids: string[]\n      if (selected_ids.includes(drag_source_id)) {\n        source_ids = selected_ids\n      } else {\n        source_ids = [drag_source_id]\n      }\n      let tree2 = treeMoveNode(tree, source_ids, drop_target_id, drop_where)\n      if (tree2) {\n        setTree(tree2)\n        onTreeChange(tree2)\n      }\n    }\n\n    setIsDragging(false)\n    setDragSourceId(null)\n    setDropTargetId(null)\n    setDropWhere(null)\n  }\n\n  const onTreeChange = (tree: ITreeNodeData[]) => {\n    // console.log('onTreeChange...')\n    onChange && onChange(tree)\n  }\n\n  const onNodeChange = (id: NodeIdType, data: Partial<ITreeNodeData>) => {\n    let tree2 = lodash.cloneDeep(tree)\n    let node = getNodeById(tree2, id)\n    if (!node) return\n\n    Object.assign(node, data)\n    setTree(tree2)\n    onTreeChange(tree2)\n  }\n\n  const onSelectOne = (id: NodeIdType, multiple_type: MultipleSelectType = 0) => {\n    // console.log('multiple_type:', multiple_type, 'ids:', selected_ids, 'id:', id)\n    const { onSelect } = props\n    let new_selected_ids: NodeIdType[] = []\n\n    if (!allowed_multiple_selection) {\n      multiple_type = 0\n    }\n\n    if (multiple_type === 0) {\n      new_selected_ids = [id]\n    } else if (multiple_type === 1) {\n      // 按住 cmd/ctrl 多选\n      if (!canBeSelected(tree, selected_ids, id)) {\n        return\n      }\n      if (selected_ids.includes(id)) {\n        new_selected_ids = selected_ids.filter((i) => i !== id)\n      } else {\n        new_selected_ids = [...selected_ids, id]\n      }\n    } else if (multiple_type === 2) {\n      // 按住 shift 多选\n      new_selected_ids = selectTo(tree, selected_ids, id)\n    }\n\n    setSelectedIds(new_selected_ids)\n    onSelect && onSelect(new_selected_ids)\n  }\n\n  const has_no_child = flatten(tree).length === tree.length\n\n  return (\n    <div className={clsx(styles.root, className)} onDrop={onDragEnd}>\n      {tree.map((node) => {\n        return (\n          <Node\n            key={node.id}\n            tree={tree}\n            data={node}\n            onDragStart={onDragStart}\n            onDragEnd={onDragEnd}\n            setDropTargetId={setDropTargetId}\n            setDropWhere={setDropWhere}\n            drag_source_id={drag_source_id}\n            drop_target_id={drop_target_id}\n            drag_target_where={drop_where}\n            is_dragging={is_dragging}\n            level={0}\n            render={props.nodeRender}\n            draggingNodeRender={props.draggingNodeRender}\n            collapseArrow={props.collapseArrow}\n            onChange={onNodeChange}\n            indent_px={props.indent_px}\n            selected_ids={selected_ids}\n            onSelect={onSelectOne}\n            nodeAttr={props.nodeAttr}\n            nodeClassName={props.nodeClassName}\n            nodeDropInClassName={props.nodeDropInClassName}\n            nodeSelectedClassName={props.nodeSelectedClassName}\n            nodeCollapseArrowClassName={props.nodeCollapseArrowClassName}\n            has_no_child={has_no_child}\n            no_child_no_indent={props.no_child_no_indent}\n            allowed_multiple_selection={allowed_multiple_selection}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\nexport default Tree\n"
  },
  {
    "path": "src/renderer/components/Tree/fn.ts",
    "content": "import { ITreeNodeData, NodeIdType } from '@common/tree'\nimport lodash from 'lodash'\nimport { DropWhereType } from './Tree'\n\ninterface IObj {\n  [key: string]: any\n}\n\nexport type KeyMapType = [string, string]\n\nexport function flatten(tree_list: ITreeNodeData[]): ITreeNodeData[] {\n  let arr: any[] = []\n\n  Array.isArray(tree_list) &&\n    tree_list.map((item) => {\n      if (!item) return\n\n      arr.push(item)\n\n      if (Array.isArray(item.children)) {\n        let a2 = flatten(item.children)\n        arr = arr.concat(a2)\n      }\n    })\n\n  return arr\n}\n\nexport function getParentList(tree_list: ITreeNodeData[], id: NodeIdType): ITreeNodeData[] {\n  if (tree_list.findIndex((i) => i.id === id) > -1) {\n    return tree_list\n  }\n\n  let flat = flatten(tree_list)\n  for (let node of flat) {\n    if (Array.isArray(node.children) && node.children.findIndex((i) => i.id === id) > -1) {\n      return node.children\n    }\n  }\n\n  return tree_list\n}\n\nexport const treeMoveNode = (\n  tree_list: ITreeNodeData[],\n  source_ids: NodeIdType[],\n  target_id: NodeIdType,\n  where: DropWhereType,\n): ITreeNodeData[] | null => {\n  tree_list = lodash.cloneDeep(tree_list)\n\n  if (source_ids.includes(target_id)) return null\n\n  // console.log(JSON.stringify(tree_list))\n  let source_parent_list = getParentList(tree_list, source_ids[0])\n  // console.log(JSON.stringify(source_parent_list))\n\n  let source_nodes: ITreeNodeData[] = []\n  while (true) {\n    let idx = source_parent_list.findIndex((i) => source_ids.includes(i.id))\n    if (idx === -1) break\n    let node = source_parent_list.splice(idx, 1)[0]\n    source_nodes.push(node)\n  }\n\n  let target_parent_list = getParentList(tree_list, target_id)\n  let target_idx = target_parent_list.findIndex((i) => i.id === target_id)\n  if (target_idx === -1) {\n    // console.log('target_idx === -1')\n    return null\n  }\n\n  if (where === 'in') {\n    let target_node = target_parent_list[target_idx]\n    if (!Array.isArray(target_node.children)) {\n      target_node.children = []\n    }\n    target_node.children.splice(target_node.children.length, 0, ...source_nodes)\n  } else if (where === 'before') {\n    target_parent_list.splice(target_idx, 0, ...source_nodes)\n  } else if (where === 'after') {\n    target_parent_list.splice(target_idx + 1, 0, ...source_nodes)\n  }\n\n  return tree_list\n}\n\nexport function getNodeById(tree_list: ITreeNodeData[], id: NodeIdType): ITreeNodeData | undefined {\n  return flatten(tree_list).find((i) => i.id === id)\n}\n\n/**\n * a is child of b\n */\nexport function isChildOf(tree_list: ITreeNodeData[], a_id: NodeIdType, b_id: NodeIdType): boolean {\n  if (a_id === b_id) return false\n\n  let target_node = getNodeById(tree_list, b_id)\n  if (!target_node || !Array.isArray(target_node.children)) return false\n\n  return flatten(target_node.children).findIndex((i) => i.id === a_id) > -1\n}\n\nexport function isSelfOrChild(item: ITreeNodeData, id: NodeIdType | null): boolean {\n  if (!id) return false\n  if (item.id === id) return true\n  return flatten(item.children || []).findIndex((i) => i.id === id) > -1\n}\n\nexport function objKeyMap(obj: IObj, key_maps: KeyMapType[], reversed: boolean = false): IObj {\n  if (reversed) {\n    key_maps = keyMapReverse(key_maps)\n  }\n\n  let keys = Object.keys(obj)\n  let new_obj: IObj = {}\n\n  keys.map((key) => {\n    let map = key_maps.find((i) => i[0] === key)\n    let value = obj[key]\n\n    if (Array.isArray(value)) {\n      value = treeKeyMap(value, key_maps)\n    } else if (typeof value === 'object' && value) {\n      value = objKeyMap(value, key_maps)\n    }\n\n    if (map) {\n      new_obj[map[1]] = value\n    } else {\n      new_obj[key] = value\n    }\n  })\n\n  return new_obj\n}\n\nexport function treeKeyMap(\n  tree_list: IObj[],\n  key_maps: KeyMapType[],\n  reversed: boolean = false,\n): any[] {\n  if (reversed) {\n    key_maps = keyMapReverse(key_maps)\n  }\n\n  return tree_list.map((item) => objKeyMap(item, key_maps))\n}\n\nexport function keyMapReverse(key_maps: KeyMapType[]): KeyMapType[] {\n  return key_maps.map(([a, b]) => [b, a])\n}\n\nexport function isParent(tree_list: ITreeNodeData[], item: ITreeNodeData, id: string): boolean {\n  let parents = getParentList(tree_list, item.id)\n  return parents.findIndex((i) => i.id === id) > -1\n}\n\nexport function canBeSelected(\n  tree_list: ITreeNodeData[],\n  selected_ids: NodeIdType[],\n  new_id: NodeIdType,\n): boolean {\n  let id_one = selected_ids[0]\n  if (!id_one) return true\n\n  if (\n    tree_list.findIndex((i) => i.id === id_one) > -1 &&\n    tree_list.findIndex((i) => i.id === new_id) > -1\n  ) {\n    return true\n  }\n\n  let flat = flatten(tree_list)\n  let parent = flat.find((i) => i.children && i.children.findIndex((j) => j.id === id_one) > -1)\n  if (!parent || !parent.children) {\n    return false\n  }\n\n  return parent.children.findIndex((i) => i.id === new_id) > -1\n}\n\nexport function selectTo(\n  tree_list: ITreeNodeData[],\n  selected_ids: NodeIdType[],\n  new_id: NodeIdType,\n): NodeIdType[] {\n  if (!canBeSelected(tree_list, selected_ids, new_id)) {\n    return selected_ids\n  }\n\n  let list: ITreeNodeData[]\n  if (tree_list.findIndex((i) => i.id === new_id) > -1) {\n    list = tree_list\n  } else {\n    let flat = flatten(tree_list)\n    let parent = flat.find((i) => i.children && i.children.findIndex((j) => j.id === new_id) > -1)\n    if (!parent || !parent.children) {\n      return selected_ids\n    }\n    list = parent.children\n  }\n\n  let new_id_idx: number = -1\n  let first_selected_idx: number = -1\n  let last_selected_idx: number = -1\n  list.map((i, idx) => {\n    if (first_selected_idx < 0 && selected_ids.includes(i.id)) {\n      first_selected_idx = idx\n    }\n    if (selected_ids.includes(i.id)) {\n      last_selected_idx = idx\n    }\n    if (i.id === new_id) {\n      new_id_idx = idx\n    }\n  })\n\n  let from_idx: number = first_selected_idx\n  let to_idx: number = last_selected_idx\n  if (new_id_idx < first_selected_idx) {\n    from_idx = new_id_idx\n  } else {\n    to_idx = new_id_idx\n  }\n\n  let new_selected_ids: NodeIdType[] = []\n  for (let idx = from_idx; idx <= to_idx; idx++) {\n    let item = list[idx]\n    if (item.can_select !== false) {\n      new_selected_ids.push(item.id)\n    }\n  }\n\n  return new_selected_ids\n}\n"
  },
  {
    "path": "src/renderer/components/Tree/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { KeyMapType as _KeyMapType } from './fn'\n\nexport { objKeyMap, treeKeyMap } from './fn'\nexport { default as Tree } from './Tree'\nexport type KeyMapType = _KeyMapType\n"
  },
  {
    "path": "src/renderer/components/Tree/style.module.scss",
    "content": ".root {\n  $select-bg-color: var(--tree-drag-select-bg-color);\n  $drop-indicator-color: var(--tree-drag-indicator-color);\n\n  .node {\n    &.is_dragging {\n    }\n\n    &.is_source {\n      opacity: 0.5;\n    }\n\n    &.selected {\n      background: $select-bg-color;\n      color: #fff;\n    }\n  }\n\n  .ln_header {\n    display: grid;\n    justify-items: center;\n    align-items: center;\n  }\n\n  .arrow {\n    min-width: 20px;\n    min-height: 20px;\n    overflow: hidden;\n    text-align: center;\n    line-height: 20px;\n    cursor: pointer;\n    transform: rotate(90deg);\n\n    &.collapsed {\n      transform: rotate(0);\n    }\n  }\n\n  .ln_body {\n    width: 100%;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n  }\n\n  @mixin indicator_circle {\n    content: '';\n    display: block;\n    position: absolute;\n    border: 2px solid $drop-indicator-color;\n    width: 8px;\n    height: 8px;\n    border-radius: 50%;\n    z-index: 1000;\n  }\n\n  .indicator_circle {\n    @include indicator_circle;\n  }\n\n  .drop_before {\n    .ln_body {\n      box-shadow: 0 -2px 0 0 $drop-indicator-color;\n\n      &:before {\n        @include indicator_circle;\n        margin: -5px 0 0 -6px;\n      }\n    }\n  }\n\n  .drop_in {\n    .ln_body {\n      background: $drop-indicator-color;\n    }\n  }\n\n  .drop_after {\n    .ln_body {\n      box-shadow: 0 2px 0 0 $drop-indicator-color;\n\n      &:after {\n        @include indicator_circle;\n        margin: -3px 0 0 -6px;\n      }\n    }\n  }\n\n  .content {\n    margin: 2px 0;\n    display: grid;\n    grid-template-columns: 20px 1fr;\n\n    &.no_children {\n      grid-template-columns: 0 1fr;\n    }\n  }\n}\n\n.for_dragging {\n  display: none;\n  position: absolute;\n  z-index: 100000;\n  top: -100000px;\n  left: -100000px;\n}\n"
  },
  {
    "path": "src/renderer/components/UpdateDialog.tsx",
    "content": "import events from '@common/events'\nimport { AppDownloadedUpdateInfo, AppUpdateInfo, AppUpdateProgress } from '@common/update'\nimport { Button, Group, Modal, Progress, Stack, Text } from '@mantine/core'\nimport { actions } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useI18n from '@renderer/models/useI18n'\nimport { useState } from 'react'\n\ntype UpdateStage = 'available' | 'downloading' | 'downloaded'\n\nconst emptyProgress: AppUpdateProgress = {\n  percent: 0,\n  transferred: 0,\n  total: 0,\n  bytesPerSecond: 0,\n}\n\nconst UpdateDialog = () => {\n  const { i18n, lang } = useI18n()\n  const [opened, setOpened] = useState(false)\n  const [stage, setStage] = useState<UpdateStage>('available')\n  const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null)\n  const [progress, setProgress] = useState<AppUpdateProgress>(emptyProgress)\n  const [isInstalling, setIsInstalling] = useState(false)\n\n  // The dialog mirrors the updater lifecycle as a small state machine:\n  // available -> downloading -> downloaded.\n  useOnBroadcast(events.new_version, (info: AppUpdateInfo) => {\n    setUpdateInfo(info)\n    setProgress(emptyProgress)\n    setStage('available')\n    setOpened(true)\n  })\n\n  useOnBroadcast(events.update_download_progress, (info: AppUpdateProgress) => {\n    setProgress(info)\n    setStage('downloading')\n    setOpened(true)\n  })\n\n  useOnBroadcast(events.update_downloaded, (info: AppDownloadedUpdateInfo) => {\n    setUpdateInfo(info)\n    setProgress({\n      ...emptyProgress,\n      percent: 100,\n    })\n    setStage('downloaded')\n    setOpened(true)\n  })\n\n  const onClose = () => {\n    if (stage === 'downloading' || isInstalling) {\n      return\n    }\n\n    setOpened(false)\n  }\n\n  const onDownload = async () => {\n    if (!updateInfo) {\n      return\n    }\n\n    setProgress(emptyProgress)\n    setStage('downloading')\n\n    try {\n      await actions.downloadUpdate()\n    } catch (error) {\n      console.error(error)\n      setStage('available')\n      alert(error instanceof Error ? error.message : String(error))\n    }\n  }\n\n  const onInstall = async () => {\n    setIsInstalling(true)\n\n    try {\n      await actions.installUpdate()\n    } catch (error) {\n      console.error(error)\n      setIsInstalling(false)\n      alert(error instanceof Error ? error.message : String(error))\n    }\n  }\n\n  if (!updateInfo) {\n    return null\n  }\n\n  const isDownloading = stage === 'downloading'\n  const progressText = `${Math.round(progress.percent)}%`\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      centered\n      title={lang.new_version_found}\n      withCloseButton={!isDownloading && !isInstalling}\n      closeOnClickOutside={!isDownloading && !isInstalling}\n      closeOnEscape={!isDownloading && !isInstalling}\n    >\n      <Stack gap=\"16px\">\n        {updateInfo.releaseName ? <Text fw={600}>{updateInfo.releaseName}</Text> : null}\n\n        <Text size=\"sm\" c=\"dimmed\">\n          {stage === 'available' && i18n.trans('latest_version_desc', [updateInfo.version])}\n          {stage === 'downloading' &&\n            i18n.trans('update_downloading_desc', [updateInfo.version, progressText])}\n          {stage === 'downloaded' && i18n.trans('update_ready_desc', [updateInfo.version])}\n        </Text>\n\n        {updateInfo.releaseNotes ? (\n          <Text size=\"xs\" c=\"dimmed\" style={{ whiteSpace: 'pre-wrap' }} lineClamp={6}>\n            {updateInfo.releaseNotes}\n          </Text>\n        ) : null}\n\n        {isDownloading ? (\n          <Stack gap=\"8px\">\n            <Progress value={progress.percent} animated />\n            <Text size=\"xs\" c=\"dimmed\">\n              {progressText}\n            </Text>\n          </Stack>\n        ) : null}\n\n        <Group justify=\"flex-end\">\n          {stage !== 'downloading' ? (\n            <Button variant=\"outline\" onClick={onClose} disabled={isInstalling}>\n              {lang.btn_cancel}\n            </Button>\n          ) : null}\n\n          {stage === 'available' ? (\n            <Button onClick={onDownload}>{lang.update_download_now}</Button>\n          ) : null}\n\n          {stage === 'downloaded' ? (\n            <Button onClick={onInstall} loading={isInstalling}>\n              {lang.update_install_now}\n            </Button>\n          ) : null}\n        </Group>\n      </Stack>\n    </Modal>\n  )\n}\n\nexport default UpdateDialog\n"
  },
  {
    "path": "src/renderer/core/PopupMenu.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { IMenuItemOption } from '@common/types'\nimport { agent } from '@renderer/core/agent'\n\nlet _idx: number = 0\n\ntype OffFunction = () => void\n\nexport class PopupMenu {\n  private _id: string\n  private _items: IMenuItemOption[]\n  private _offs: any[] = []\n\n  constructor(menu_items: IMenuItemOption[]) {\n    this._id = `popup_menu_${Math.floor(Math.random() * 1e8)}`\n    this._items = menu_items\n  }\n\n  show() {\n    // console.log('show')\n    this.onHide()\n\n    let items = this._items.map((i) => {\n      let d = { ...i }\n\n      if (typeof d.click === 'function') {\n        const r = Math.floor(Math.random() * 1e8)\n        const evt = `popup_menu_item_${_idx++}_${r}`\n        let off = agent.once(evt, d.click)\n        this._offs.push(off)\n        d._click_evt = evt\n        delete d.click\n      }\n\n      return d\n    })\n\n    agent.popupMenu({\n      menu_id: this._id,\n      items,\n    })\n    ;((offs: OffFunction[]) => {\n      agent.once(`popup_menu_close:${this._id}`, () => {\n        // console.log(`on popup_menu_close:${this._id}`)\n        setTimeout(() => {\n          offs.map((o) => o())\n        }, 100)\n      })\n    })(this._offs)\n  }\n\n  private onHide() {\n    // console.log('hide...')\n    this._offs.map((o) => o())\n    this._offs = []\n  }\n}\n"
  },
  {
    "path": "src/renderer/core/agent.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { Actions } from '@common/types'\n\nexport const actions: Actions = new Proxy(\n  {},\n  {\n    get(obj, key: keyof Actions) {\n      return (...params: any[]) => window._agent.call(key, ...params)\n    },\n  },\n) as Actions\n\nexport const agent = window._agent\n"
  },
  {
    "path": "src/renderer/core/useOnBroadcast.ts",
    "content": "import { EventHandler } from '@main/preload'\nimport { agent } from '@renderer/core/agent'\nimport { useEffect } from 'react'\n\nconst useOnBroadcast = (\n  event: string,\n  handler: EventHandler,\n  deps: any[] = [],\n) => {\n  // agent.on will return an off function for clean up\n  useEffect(() => agent.on(event, handler), deps)\n}\n\nexport default useOnBroadcast\n"
  },
  {
    "path": "src/renderer/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>SwitchHosts</title>\n</head>\n<body>\n<div id=\"root\"></div>\n<script type=\"module\" src=\"index.tsx\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "src/renderer/index.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { MantineProvider } from '@mantine/core'\nimport '@mantine/core/styles.css'\nimport PageWrapper from '@renderer/common/PageWrapper'\nimport useConfigs from '@renderer/models/useConfigs'\nimport IndexPage from '@renderer/pages'\nimport FindPage from '@renderer/pages/find'\nimport TrayPage from '@renderer/pages/tray'\nimport { createRoot } from 'react-dom/client'\nimport { createHashRouter, RouterProvider } from 'react-router'\nimport './styles/global.scss'\n\nconst router = createHashRouter([\n  {\n    path: '/',\n    element: <IndexPage />,\n  },\n  {\n    path: '/find',\n    element: <FindPage />,\n  },\n  {\n    path: '/tray',\n    element: <TrayPage />,\n  },\n])\n\nconst container = document.getElementById('root')\nif (container == null) throw new Error('container is null')\n\nconst AppRoot = () => {\n  const { configs } = useConfigs()\n\n  return (\n    <MantineProvider forceColorScheme={configs?.theme === 'dark' ? 'dark' : 'light'}>\n      <PageWrapper>\n        <RouterProvider router={router} />\n      </PageWrapper>\n    </MantineProvider>\n  )\n}\n\nconst root = createRoot(container)\nroot.render(<AppRoot />)\n"
  },
  {
    "path": "src/renderer/models/useConfigs.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType } from '@common/default_configs'\nimport { actions } from '@renderer/core/agent'\nimport { configs_atom } from '@renderer/stores/configs'\nimport { useAtom } from 'jotai'\n\nexport default function useConfigs() {\n  const [configs, setConfigs] = useAtom(configs_atom)\n\n  const loadConfigs = async () => {\n    setConfigs(await actions.configAll())\n  }\n\n  const updateConfigs = async (kv: Partial<ConfigsType>) => {\n    if (!configs) return\n    // console.log('update configs:', kv)\n    let new_configs = { ...configs, ...kv }\n    setConfigs(new_configs)\n    await actions.configUpdate(new_configs)\n  }\n\n  return {\n    configs,\n    loadConfigs,\n    updateConfigs,\n  }\n}\n"
  },
  {
    "path": "src/renderer/models/useHostsData.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport version from '@/version.json'\nimport { IHostsBasicData, IHostsListObject, VersionType } from '@common/data'\nimport { actions } from '@renderer/core/agent'\nimport { current_hosts_atom, hosts_data_atom } from '@renderer/stores/hosts_data'\nimport { useAtom } from 'jotai'\n\nexport default function useHostsData() {\n  const [hosts_data, setHostsData] = useAtom(hosts_data_atom)\n  const [current_hosts, setCurrentHosts] = useAtom(current_hosts_atom)\n\n  const loadHostsData = async () => {\n    setHostsData(await actions.getBasicData())\n  }\n\n  const setList = async (list: IHostsListObject[]) => {\n    list = list.filter((i) => !i.is_sys)\n\n    let data: IHostsBasicData = {\n      list,\n      trashcan: hosts_data.trashcan,\n      version: version as VersionType,\n    }\n\n    setHostsData(data)\n    await actions.setList(list)\n    await actions.updateTrayTitle()\n  }\n\n  const isHostsInTrashcan = (id: string): boolean => {\n    return hosts_data.trashcan.findIndex((i) => i.data.id === id) > -1\n  }\n\n  const isReadOnly = (hosts?: IHostsListObject | null): boolean => {\n    hosts = hosts || current_hosts\n\n    if (!hosts) {\n      return true\n    }\n\n    if (hosts.id === '0') {\n      return true // system hosts\n    }\n\n    if (hosts.type && ['group', 'remote', 'folder', 'trashcan'].includes(hosts.type)) {\n      return true\n    }\n\n    if (isHostsInTrashcan(hosts.id)) {\n      return true\n    }\n\n    // ..\n    return false\n  }\n\n  return {\n    hosts_data,\n    setHostsData,\n    loadHostsData,\n\n    setList,\n\n    current_hosts,\n    setCurrentHosts,\n\n    isHostsInTrashcan,\n    isReadOnly,\n  }\n}\n"
  },
  {
    "path": "src/renderer/models/useI18n.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { LocaleName } from '@common/i18n'\nimport { i18n_atom, lang_atom, locale_atom } from '@renderer/stores/i18n'\nimport { useAtom } from 'jotai'\n\nexport default function useI18n() {\n  const [locale, setLocale] = useAtom(locale_atom)\n  const [i18n] = useAtom(i18n_atom)\n  const [lang] = useAtom(lang_atom)\n\n  return {\n    locale,\n    setLocale: (locale?: LocaleName) => setLocale(locale || 'en'),\n    i18n,\n    lang,\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/find.module.scss",
    "content": "@use \"../styles/common\";\n\n.root {\n  width: 100vw;\n  height: 100vh;\n}\n\n.result_row {\n  border-bottom: 1px solid var(--swh-border-color-0);\n  display: grid;\n  grid-template-columns: 1fr 20% 60px;\n  grid-column-gap: 4px;\n  line-height: 28px;\n  padding-left: 8px;\n  cursor: pointer;\n  user-select: none;\n\n  &.selected {\n    background: var(--swh-tree-selected-bg);\n  }\n\n  &.disabled {\n    color: var(--swh-font-color-weak);\n    text-decoration: line-through;\n  }\n\n  &.readonly {\n    color: var(--swh-font-color-weak);\n  }\n}\n\n.result_content {\n  //display: flex;\n\n  @include common.code;\n  @include common.ell;\n\n  span {\n    //display: inline-block;\n  }\n\n  span:first-child {\n    //max-width: 40%;\n    //.ell;\n  }\n}\n\n.result_title {\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n\n  span {\n    display: inline-block;\n    margin-left: 4px;\n    @include common.ell;\n  }\n}\n\n.result_line {\n  color: var(--swh-font-color-weak);\n}\n\n.highlight {\n  background: var(--swh-highlight-bg);\n}\n\n.read_only {\n  //.read_only_tag;\n\n  font-size: 10px;\n  color: var(--swh-font-color-weak);\n  margin-right: 8px;\n}\n"
  },
  {
    "path": "src/renderer/pages/find.tsx",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { HostsType } from '@common/data'\nimport events from '@common/events'\nimport { IFindItem, IFindPosition, IFindShowSourceParam } from '@common/types'\nimport { ActionIcon, Box, Button, Checkbox, Group, Loader, Stack, TextInput } from '@mantine/core'\nimport ItemIcon from '@renderer/components/ItemIcon'\nimport { actions, agent } from '@renderer/core/agent'\nimport { PopupMenu } from '@renderer/core/PopupMenu'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport { useDebounce, useDebounceFn } from 'ahooks'\nimport clsx from 'clsx'\nimport lodash from 'lodash'\nimport React, { useEffect, useRef, useState } from 'react'\nimport {\n  IoArrowBackOutline,\n  IoArrowForwardOutline,\n  IoChevronDownOutline,\n  IoSearch,\n} from 'react-icons/io5'\nimport scrollIntoView from 'smooth-scroll-into-view-if-needed'\nimport useConfigs from '../models/useConfigs'\nimport useI18n from '../models/useI18n'\nimport styles from './find.module.scss'\n\ninterface IFindPositionShow extends IFindPosition {\n  item_id: string\n  item_title: string\n  item_type: HostsType\n  index: number\n  is_disabled?: boolean\n  is_readonly?: boolean\n}\n\nconst flushedInputStyles = {\n  input: {\n    borderTop: 0,\n    borderLeft: 0,\n    borderRight: 0,\n    borderRadius: 0,\n    borderBottom: '1px solid var(--swh-border-color-0)',\n    backgroundColor: 'transparent',\n    paddingLeft: 52,\n    paddingRight: 12,\n    '&:focus': {\n      borderBottomColor: 'var(--swh-primary-color)',\n    },\n  },\n  section: {\n    width: 52,\n    color: 'var(--swh-font-color-weak)',\n  },\n} as const\n\nconst FindPage = () => {\n  const { lang, i18n, setLocale } = useI18n()\n  const { configs, loadConfigs } = useConfigs()\n  const [keyword, setKeyword] = useState('')\n  const [replace_to, setReplaceTo] = useState('')\n  const [is_regexp, setIsRegExp] = useState(false)\n  const [is_ignore_case, setIsIgnoreCase] = useState(false)\n  const [find_result, setFindResult] = useState<IFindItem[]>([])\n  const [find_positions, setFindPositions] = useState<IFindPositionShow[]>([])\n  const [is_searching, setIsSearching] = useState(false)\n  const [current_result_idx, setCurrentResultIdx] = useState(0)\n  const [last_scroll_result_idx, setlastScrollResultIdx] = useState(-1)\n  const debounced_keyword = useDebounce(keyword, { wait: 500 })\n  const ipt_kw = useRef<HTMLInputElement>(null)\n\n  const init = async () => {\n    if (!configs) return\n\n    setLocale(configs.locale)\n\n    let theme = configs.theme\n    let cls = document.body.className\n    document.body.className = cls.replace(/\\btheme-\\w+/gi, '')\n    document.body.classList.add(`platform-${agent.platform}`, `theme-${theme}`)\n  }\n\n  useEffect(() => {\n    if (!configs) return\n    init().catch((e) => console.error(e))\n  }, [configs])\n\n  useEffect(() => {\n    document.title = lang.find_and_replace\n  }, [lang])\n\n  useEffect(() => {\n    doFind(debounced_keyword)\n  }, [debounced_keyword, is_regexp, is_ignore_case])\n\n  useEffect(() => {\n    const onFocus = () => {\n      if (ipt_kw.current) {\n        ipt_kw.current.focus()\n      }\n    }\n\n    window.addEventListener('focus', onFocus, false)\n    return () => window.removeEventListener('focus', onFocus, false)\n  }, [])\n\n  useOnBroadcast(events.config_updated, loadConfigs)\n\n  useOnBroadcast(events.close_find, () => {\n    setFindResult([])\n    setFindPositions([])\n    setKeyword('')\n    setReplaceTo('')\n    setIsRegExp(false)\n    setIsIgnoreCase(false)\n    setCurrentResultIdx(-1)\n    setlastScrollResultIdx(-1)\n  })\n\n  const parsePositionShow = (find_items: IFindItem[]) => {\n    let positions_show: IFindPositionShow[] = []\n\n    find_items.map((item) => {\n      let { item_id, item_title, item_type, positions } = item\n      positions.map((p, index) => {\n        positions_show.push({\n          item_id,\n          item_title,\n          item_type,\n          ...p,\n          index,\n          is_readonly: item_type !== 'local',\n        })\n      })\n    })\n\n    setFindPositions(positions_show)\n  }\n\n  const { run: doFind } = useDebounceFn(\n    async (v: string) => {\n      if (!v) {\n        setFindResult([])\n        setFindPositions([])\n        return\n      }\n\n      setIsSearching(true)\n      let result = await actions.findBy(v, {\n        is_regexp,\n        is_ignore_case,\n      })\n      setCurrentResultIdx(0)\n      setlastScrollResultIdx(0)\n      setFindResult(result)\n      parsePositionShow(result)\n      setIsSearching(false)\n\n      await actions.findAddHistory({\n        value: v,\n        is_regexp,\n        is_ignore_case,\n      })\n    },\n    { wait: 500 },\n  )\n\n  const toShowSource = async (result_item: IFindPositionShow) => {\n    await actions.cmdFocusMainWindow()\n    agent.broadcast(\n      events.show_source,\n      lodash.pick<IFindShowSourceParam>(result_item, [\n        'item_id',\n        'start',\n        'end',\n        'match',\n        'line',\n        'line_pos',\n        'end_line',\n        'end_line_pos',\n      ]),\n    )\n  }\n\n  const replaceOne = async () => {\n    let pos: IFindPositionShow = find_positions[current_result_idx]\n    if (!pos) return\n\n    setFindPositions([\n      ...find_positions.slice(0, current_result_idx),\n      {\n        ...pos,\n        is_disabled: true,\n      },\n      ...find_positions.slice(current_result_idx + 1),\n    ])\n\n    if (replace_to) {\n      actions.findAddReplaceHistory(replace_to).catch((e) => console.error(e))\n    }\n\n    let r = find_result.find((i) => i.item_id === pos.item_id)\n    if (!r) return\n    let splitters = r.splitters\n    let sp = splitters[pos.index]\n    if (!sp) return\n    sp.replace = replace_to\n\n    const content = splitters\n      .map((splitter) => `${splitter.before}${splitter.replace ?? splitter.match}${splitter.after}`)\n      .join('')\n    await actions.setHostsContent(pos.item_id, content)\n    agent.broadcast(events.hosts_refreshed_by_id, pos.item_id)\n\n    if (current_result_idx < find_positions.length - 1) {\n      setCurrentResultIdx(current_result_idx + 1)\n    }\n  }\n\n  const replaceAll = async () => {\n    for (let item of find_result) {\n      let { item_id, item_type, splitters } = item\n      if (item_type !== 'local' || splitters.length === 0) continue\n      const content = splitters\n        .map((splitter) => `${splitter.before}${replace_to}${splitter.after}`)\n        .join('')\n      await actions.setHostsContent(item_id, content)\n      agent.broadcast(events.hosts_refreshed_by_id, item_id)\n    }\n\n    setFindPositions(\n      find_positions.map((pos) => ({\n        ...pos,\n        is_disabled: !pos.is_readonly,\n      })),\n    )\n\n    if (replace_to) {\n      actions.findAddReplaceHistory(replace_to).catch((e) => console.error(e))\n    }\n  }\n\n  const ResultRow = ({ data, index }: { data: IFindPositionShow; index: number }) => {\n    const el = useRef<HTMLDivElement>(null)\n    const is_selected = current_result_idx === index\n\n    useEffect(() => {\n      if (el.current && is_selected && current_result_idx !== last_scroll_result_idx) {\n        setlastScrollResultIdx(current_result_idx)\n        scrollIntoView(el.current, {\n          behavior: 'smooth',\n          scrollMode: 'if-needed',\n        })\n      }\n    }, [current_result_idx, is_selected, last_scroll_result_idx])\n\n    return (\n      <Box\n        className={clsx(\n          styles.result_row,\n          is_selected && styles.selected,\n          data.is_disabled && styles.disabled,\n          data.is_readonly && styles.readonly,\n        )}\n        onClick={() => {\n          setCurrentResultIdx(index)\n        }}\n        onDoubleClick={() => toShowSource(data)}\n        ref={el}\n        title={lang.to_show_source}\n      >\n        <div className={styles.result_content}>\n          {data.is_readonly ? <span className={styles.read_only}>{lang.read_only}</span> : null}\n          <span>{data.before}</span>\n          <span className={styles.highlight}>{data.match}</span>\n          <span>{data.after}</span>\n        </div>\n        <div className={styles.result_title}>\n          <ItemIcon type={data.item_type} />\n          <span>{data.item_title}</span>\n        </div>\n        <div className={styles.result_line}>{data.line}</div>\n      </Box>\n    )\n  }\n\n  const showKeywordHistory = async () => {\n    let history = await actions.findGetHistory()\n    if (history.length === 0) return\n\n    let menu = new PopupMenu(\n      history.reverse().map((i) => ({\n        label: i.value,\n        click() {\n          setKeyword(i.value)\n          setIsRegExp(i.is_regexp)\n          setIsIgnoreCase(i.is_ignore_case)\n        },\n      })),\n    )\n\n    menu.show()\n  }\n\n  const showReplaceHistory = async () => {\n    let history = await actions.findGetReplaceHistory()\n    if (history.length === 0) return\n\n    let menu = new PopupMenu(\n      history.reverse().map((v) => ({\n        label: v,\n        click() {\n          setReplaceTo(v)\n        },\n      })),\n    )\n\n    menu.show()\n  }\n\n  let can_replace = true\n  if (current_result_idx > -1) {\n    let pos = find_positions[current_result_idx]\n    if (pos?.is_disabled || pos?.is_readonly) {\n      can_replace = false\n    }\n  }\n\n  const leftSection = (onClick: () => void) => (\n    <Group gap={0} wrap=\"nowrap\" onClick={onClick} style={{ cursor: 'pointer' }}>\n      <IoSearch />\n      <IoChevronDownOutline style={{ fontSize: 10 }} />\n    </Group>\n  )\n\n  return (\n    <div className={styles.root}>\n      <Stack gap={0} h=\"100%\">\n        <TextInput\n          autoFocus={true}\n          placeholder=\"keywords\"\n          value={keyword}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n            setKeyword(e.target.value)\n          }}\n          ref={ipt_kw}\n          leftSection={leftSection(showKeywordHistory)}\n          leftSectionPointerEvents=\"all\"\n          styles={flushedInputStyles}\n        />\n\n        <TextInput\n          placeholder=\"replace to\"\n          value={replace_to}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n            setReplaceTo(e.target.value)\n          }}\n          leftSection={leftSection(showReplaceHistory)}\n          leftSectionPointerEvents=\"all\"\n          styles={flushedInputStyles}\n        />\n\n        <Group w=\"100%\" py=\"8px\" px=\"16px\" gap=\"16px\">\n          <Checkbox\n            checked={is_regexp}\n            onChange={(e) => setIsRegExp(e.target.checked)}\n            label={lang.regexp}\n          />\n          <Checkbox\n            checked={is_ignore_case}\n            onChange={(e) => setIsIgnoreCase(e.target.checked)}\n            label={lang.ignore_case}\n          />\n        </Group>\n\n        <Box w=\"100%\" style={{ borderTop: '1px solid var(--swh-border-color-0)' }}>\n          <div className={styles.result_row}>\n            <div>{lang.match}</div>\n            <div>{lang.title}</div>\n            <div>{lang.line}</div>\n          </div>\n        </Box>\n\n        <Box\n          w=\"100%\"\n          style={{\n            flex: 1,\n            overflowY: 'auto',\n            backgroundColor: 'var(--swh-editor-read-only-bg)',\n          }}\n        >\n          {find_positions.map((item, idx) => (\n            <ResultRow key={`${item.item_id}-${idx}`} data={item} index={idx} />\n          ))}\n        </Box>\n\n        <Group w=\"100%\" py=\"8px\" px=\"16px\" gap=\"16px\">\n          {is_searching ? (\n            <Loader size=\"sm\" />\n          ) : (\n            <span>\n              {i18n.trans(find_positions.length > 1 ? 'items_found' : 'item_found', [\n                find_positions.length.toLocaleString(),\n              ])}\n            </span>\n          )}\n          <Box style={{ flex: 1 }} />\n          <Button\n            size=\"sm\"\n            variant=\"outline\"\n            disabled={is_searching || find_positions.length === 0}\n            onClick={replaceAll}\n          >\n            {lang.replace_all}\n          </Button>\n          <Button\n            size=\"sm\"\n            variant=\"filled\"\n            color=\"blue\"\n            disabled={is_searching || find_positions.length === 0 || !can_replace}\n            onClick={replaceOne}\n          >\n            {lang.replace}\n          </Button>\n\n          <Group gap={0}>\n            <ActionIcon\n              aria-label=\"previous\"\n              variant=\"outline\"\n              size=\"lg\"\n              onClick={() => {\n                let idx = current_result_idx - 1\n                if (idx < 0) idx = 0\n                setCurrentResultIdx(idx)\n              }}\n              disabled={current_result_idx <= 0}\n            >\n              <IoArrowBackOutline />\n            </ActionIcon>\n            <ActionIcon\n              aria-label=\"next\"\n              variant=\"outline\"\n              size=\"lg\"\n              onClick={() => {\n                let idx = current_result_idx + 1\n                if (idx > find_positions.length - 1) idx = find_positions.length - 1\n                setCurrentResultIdx(idx)\n              }}\n              disabled={current_result_idx >= find_positions.length - 1}\n            >\n              <IoArrowForwardOutline />\n            </ActionIcon>\n          </Group>\n        </Group>\n      </Stack>\n    </div>\n  )\n}\n\nexport default FindPage\n"
  },
  {
    "path": "src/renderer/pages/index.module.scss",
    "content": ".root {\n  display: grid;\n  grid-template-rows: var(--swh-top-bar-height) 1fr;\n}\n\n.left {\n  position: fixed;\n  top: var(--swh-top-bar-height);\n  left: 0;\n  bottom: 0;\n  background: var(--swh-left-panel-bg);\n  border-right: 1px solid var(--swh-border-color-0);\n  transition: 0.3s;\n}\n\n.main {\n  position: fixed;\n  top: var(--swh-top-bar-height);\n  right: 0;\n  bottom: 0;\n  background: var(--swh-main-bg);\n  transition: 0.3s;\n}\n\n.new_version {\n  button {\n    color: #fff;\n    font-weight: bold;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/index.tsx",
    "content": "import events from '@common/events'\nimport About from '@renderer/components/About'\nimport EditHostsInfo from '@renderer/components/EditHostsInfo'\nimport History from '@renderer/components/History'\nimport LeftPanel from '@renderer/components/LeftPanel'\nimport Loading from '@renderer/components/Loading'\nimport MainPanel from '@renderer/components/MainPanel'\nimport PreferencePanel from '@renderer/components/Pref'\nimport SetWriteMode from '@renderer/components/SetWriteMode'\nimport SudoPasswordInput from '@renderer/components/SudoPasswordInput'\nimport UpdateDialog from '@renderer/components/UpdateDialog'\nimport { actions, agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useConfigs from '@renderer/models/useConfigs'\nimport clsx from 'clsx'\nimport { useEffect, useState } from 'react'\nimport TopBar from '../components/TopBar'\nimport useHostsData from '../models/useHostsData'\nimport useI18n from '../models/useI18n'\nimport styles from './index.module.scss'\n\nexport default () => {\n  const [loading, setLoading] = useState(true)\n  const { lang, setLocale } = useI18n()\n  const { loadHostsData } = useHostsData()\n  const { configs } = useConfigs()\n  const [left_width, setLeftWidth] = useState(0)\n  const [left_show, setLeftShow] = useState(true)\n  const [use_system_window_frame, setSystemFrame] = useState(false)\n  const [show_migration, setShowMigration] = useState(false)\n\n  const migrate = async (do_migrate: boolean) => {\n    if (do_migrate) {\n      await actions.migrateData()\n    } else {\n      setShowMigration(false)\n    }\n    await loadHostsData()\n    setLoading(false)\n  }\n\n  const init = async () => {\n    let if_migrate = await actions.migrateCheck()\n    if (if_migrate) {\n      setShowMigration(true)\n      return\n    }\n\n    await loadHostsData()\n    setLoading(false)\n  }\n\n  const onConfigsUpdate = async () => {\n    if (!configs) return\n\n    setLocale(configs.locale)\n    setLeftWidth(configs.left_panel_width)\n    setLeftShow(configs.left_panel_show)\n    setSystemFrame(configs.use_system_window_frame)\n\n    let theme = configs.theme\n    let cls = document.body.className\n    document.body.className = cls.replace(/\\btheme-\\w+/gi, '')\n    document.body.classList.add(`platform-${agent.platform}`, `theme-${theme}`)\n    await agent.darkModeToggle(theme)\n  }\n\n  useEffect(() => {\n    init().catch((e) => console.error(e))\n  }, [])\n\n  useEffect(() => {\n    onConfigsUpdate().catch((e) => console.error(e))\n  }, [configs])\n\n  useOnBroadcast(events.toggle_left_panel, (show: boolean) => setLeftShow(show))\n\n  if (loading) {\n    if (show_migration) {\n      setTimeout(() => {\n        migrate(confirm(lang.migrate_confirm)).catch((e) => alert(e.message))\n      }, 200)\n    }\n\n    return <Loading />\n  }\n\n  return (\n    <div className={styles.root}>\n      <TopBar show_left_panel={left_show} use_system_window_frame={use_system_window_frame} />\n\n      <div>\n        <div\n          className={styles.left}\n          style={{\n            width: left_width,\n            left: left_show ? 0 : -left_width,\n          }}\n        >\n          <LeftPanel width={left_width} />\n        </div>\n        <div\n          className={clsx(styles.main)}\n          style={{ width: `calc(100% - ${left_show ? left_width : 0}px)` }}\n        >\n          <MainPanel />\n        </div>\n      </div>\n\n      <EditHostsInfo />\n      <SudoPasswordInput />\n      <SetWriteMode />\n      <PreferencePanel />\n      <History />\n      <UpdateDialog />\n      <About />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/pages/tray.module.scss",
    "content": "@use \"../styles/common\";\n\n.root {\n  //background: var(--swh-left-panel-bg);\n  //width: 100vh;\n  height: 100vh;\n  //box-shadow: inset 0 0 0 1px #000;\n  //border: 1px solid #000;\n  padding: 0;\n  overflow: hidden;\n}\n\n.header {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 40px;\n  margin: 0;\n  padding: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--swh-tray-header-bg);\n  color: var(--swh-tray-font-color);\n  font-size: 16px;\n  font-weight: normal;\n  border-bottom: 1px solid var(--swh-border-color-1);\n  -webkit-user-select: none;\n  -webkit-app-region: drag;\n}\n\n.body {\n  padding: 8px;\n  position: fixed;\n  top: 40px;\n  bottom: 30px;\n  left: 0;\n  right: 0;\n  overflow: auto;\n}\n\n.footer {\n  position: fixed;\n  height: 30px;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: var(--swh-status-bar-bg);\n  border-top: 1px solid var(--swh-border-color-0);\n  padding: 0 16px;\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  font-size: 16px;\n\n  svg {\n    cursor: pointer;\n  }\n}\n\n:global(.platform-win32) {\n  //@bd-color: #000;\n\n  .root {\n    //box-shadow: inset 0 0 0 1px #000;\n    //border: 1px solid @bd-color;\n    //box-sizing: border-box;\n  }\n\n  .header {\n    //height: 41px;\n    //border: 1px solid @bd-color;\n    //border-bottom: none;\n  }\n\n  .body {\n    //top: 41px;\n    //bottom: 31px;\n    //left: 1px;\n    //right: 1px;\n    @include common.swh-scroll-y();\n  }\n\n  .footer {\n    //left: 1px;\n    //right: 1px;\n    //bottom: 1px;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/tray.tsx",
    "content": "import events from '@common/events'\nimport List from '@renderer/components/List'\nimport { agent } from '@renderer/core/agent'\nimport useOnBroadcast from '@renderer/core/useOnBroadcast'\nimport useConfigs from '@renderer/models/useConfigs'\nimport useHostsData from '@renderer/models/useHostsData'\nimport useI18n from '@renderer/models/useI18n'\nimport { useEffect } from 'react'\nimport { BiArea } from 'react-icons/bi'\nimport styles from './tray.module.scss'\n\nexport default () => {\n  const { loadHostsData } = useHostsData()\n  const { setLocale } = useI18n()\n  const { configs, loadConfigs } = useConfigs()\n\n  const update = () => {\n    if (!configs) return\n\n    setLocale(configs.locale)\n    loadHostsData().catch((e) => console.error(e))\n\n    let cls = document.body.className\n    document.body.className = cls.replace(/\\btheme-\\w+/gi, '')\n    document.body.classList.add(`platform-${agent.platform}`, `theme-${configs.theme}`)\n  }\n\n  useEffect(update, [configs])\n  useOnBroadcast(events.config_updated, loadConfigs, [configs])\n\n  const showMain = () => {\n    agent.broadcast(events.active_main_window)\n  }\n\n  return (\n    <div className={styles.root}>\n      <h1 className={styles.header}>SwitchHosts</h1>\n      <div className={styles.body}>\n        <List is_tray={true} />\n      </div>\n      <div className={styles.footer}>\n        <span onClick={showMain}>\n          <BiArea />\n        </span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/stores/configs.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { ConfigsType } from '@common/default_configs'\nimport { actions } from '@renderer/core/agent'\nimport { atom } from 'jotai'\n\nexport const configs_atom = atom<ConfigsType | null>(null)\nconfigs_atom.onMount = (setAtom) => {\n  actions.configAll().then(setAtom)\n}\n"
  },
  {
    "path": "src/renderer/stores/hosts_data.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport version from '@/version.json'\nimport { IHostsBasicData, IHostsListObject, VersionType } from '@common/data'\nimport { atom } from 'jotai'\n\nexport const hosts_data_atom = atom<IHostsBasicData>({\n  list: [],\n  trashcan: [],\n  version: version as VersionType,\n})\n\nexport const current_hosts_atom = atom<IHostsListObject | null>(null)\n"
  },
  {
    "path": "src/renderer/stores/i18n.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { I18N, LocaleName } from '@common/i18n'\nimport { atom } from 'jotai'\n\nlet _locale = localStorage.getItem('locale') as LocaleName | undefined\n\nexport const locale_atom = atom<LocaleName>(_locale || 'en')\nexport const i18n_atom = atom((get) => new I18N(get(locale_atom)))\nexport const is_half_width_atom = atom((get) => get(i18n_atom).lang.colon.startsWith(':'))\nexport const lang_atom = atom((get) => get(i18n_atom).lang)\n"
  },
  {
    "path": "src/renderer/styles/common.scss",
    "content": "@forward \"fn\";\n@forward \"var\";\n@forward \"scrollbar\";\n"
  },
  {
    "path": "src/renderer/styles/fn.scss",
    "content": "@mixin code {\n  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n}\n\n@mixin ell {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n@mixin win-drag {\n  -webkit-app-region: drag;\n}\n"
  },
  {
    "path": "src/renderer/styles/global.scss",
    "content": "html,\nbody {\n  margin: 0;\n  padding: 0;\n  font-size: 14px;\n  line-height: 1.5em;\n  box-sizing: border-box;\n  color: var(--swh-font-color);\n  color-scheme: var(--swh-color-scheme);\n}\n\nbutton,\na,\ninput {\n  &:focus:not(:focus-visible) {\n    outline: 0;\n    box-shadow: none;\n  }\n}\n\na {\n  color: var(--swh-primary-color);\n  text-decoration: none;\n\n  &:hover {\n    opacity: 0.8;\n  }\n}\n"
  },
  {
    "path": "src/renderer/styles/scrollbar.scss",
    "content": "$swh-scroll-size: 8px;\n\n@mixin swh-scroll-0() {\n  &::-webkit-scrollbar {\n    position: absolute;\n    width: $swh-scroll-size;\n    height: $swh-scroll-size;\n  }\n  &::-webkit-scrollbar-track {\n    background: var(--swh-scrollbar-track);\n    //border-radius: 16px;\n  }\n  &::-webkit-scrollbar-corner {\n    background: var(--swh-scrollbar-corner);\n  }\n  &::-webkit-scrollbar-thumb {\n    background: var(--swh-scrollbar-thumb);\n    //border-radius: 16px;\n  }\n}\n\n@mixin swh-scroll-none() {\n  &::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n@mixin swh-scroll-x() {\n  overflow-x: auto;\n  overflow-y: hidden;\n\n  &::-webkit-scrollbar {\n    width: 0;\n    //height: 0;\n  }\n\n  &:hover {\n    @include swh-scroll-0();\n  }\n}\n\n@mixin swh-scroll-y() {\n  overflow-x: hidden;\n  overflow-y: auto;\n\n  &::-webkit-scrollbar {\n    width: $swh-scroll-size;\n    height: 0;\n  }\n\n  //&:hover {\n  @include swh-scroll-0();\n  //}\n}\n\n@mixin swh-scroll-xy() {\n  overflow: auto;\n\n  &::-webkit-scrollbar {\n    width: 0;\n    height: 0;\n  }\n\n  &:hover {\n    &::-webkit-scrollbar {\n      width: $swh-scroll-size;\n      height: $swh-scroll-size;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/styles/themes/dark.scss",
    "content": ":global(.theme-dark) {\n  --swh-primary-color: #2a6cb1;\n  --swh-border-color-0: #464b52;\n  --swh-border-color-1: #585858;\n  --swh-input-bg: #dad8de;\n  --swh-font-color: #aaa;\n  --swh-font-color-weak: #666;\n  --swh-font-color-reverse: #a7a7a7;\n  --swh-border-radius: 4px;\n  --swh-highlight-bg: #993;\n\n  //  top bar\n  --swh-top-bar-height: 40px;\n  --swh-top-tool-bar-height: 28px;\n  --swh-top-bar-bg: #272b34;\n  --swh-top-bar-read-only-bg: #3c434e;\n\n  // left\n  --swh-left-panel-bg: #1f242a;\n  //--swh-left-panel-status-bar-bg: #f6f6f6;\n  --swh-tree-row-height: 2em;\n  --swh-tree-node-drag-bg: #475566;\n  --swh-tree-selected-bg: #475566;\n  --swh-tree-hover-bg: #323c48;\n\n  // main\n  --swh-main-bg: #262b33;\n\n  // status bar\n  --swh-status-bar-height: 22px;\n  --swh-status-bar-bg: #272b34;\n  --swh-status-bar-font-color: var(--swh-font-color-weak);\n  --swh-status-bar-font-size: 10px;\n\n  // right bar\n  --swh-right-bar-width: 48px;\n  --swh-right-bar-bg: #f6f6f6;\n\n  // switch button\n  --swh-switch-button-main-color-off: #bfbfbf;\n  --swh-switch-button-bg-color-off: #656565;\n  --swh-switch-button-main-color-on: #1bab00;\n  --swh-switch-button-bg-color-on: #656565;\n\n  // editor & viewer\n  --swh-editor-text-color: #aaa;\n  --swh-editor-comment-color: #090;\n  --swh-editor-ip-color: #009;\n  --swh-editor-error-color: #900;\n  --swh-editor-line-number-color: #999;\n  --swh-editor-line-number-bg: #fff;\n  --swh-editor-read-only-bg: #2d333c;\n  --swh-editor-font-size: 1em;\n  --swh-editor-line-height: 1.8em;\n\n  --swh-editor-bg-color: #272c35;\n  --swh-editor-ip: #3787de;\n  --swh-editor-keyword: #06427d;\n  --swh-editor-error: #ff5990;\n  --swh-editor-comment: #91af91;\n  --swh-editor-hl-bg: #ff0;\n  --swh-editor-gutter-bg: #282c34;\n\n  // tray\n  --swh-tray-header-bg: #272b34;\n  --swh-tray-font-color: #fff;\n\n  // scroll bar\n  --swh-scrollbar-track: rgba(204, 204, 204, 0.25);\n  --swh-scrollbar-corner: #fff;\n  --swh-scrollbar-thumb: #ccc;\n  --swh-scrollbar-filler: #2b2b2b;\n\n  // other componets\n  --tree-drag-select-bg-color: var(--swh-tree-selected-bg);\n  --tree-drag-indicator-color: var(--swh-primary-color);\n\n  //color-scheme\n  --swh-color-scheme: dark;\n}\n"
  },
  {
    "path": "src/renderer/styles/themes/light.scss",
    "content": ":global(.theme-light) {\n  --swh-primary-color: #007aff;\n  --swh-border-color-0: #ccc;\n  --swh-border-color-1: #d5d3d9;\n  --swh-input-bg: #dad8de;\n  --swh-font-color: #000;\n  --swh-font-color-weak: #999;\n  --swh-font-color-reverse: #fff;\n  --swh-border-radius: 4px;\n  --swh-highlight-bg: #ee0;\n\n  //  top bar\n  --swh-top-bar-height: 40px;\n  --swh-top-tool-bar-height: 28px;\n  --swh-top-bar-bg: #fff;\n  --swh-top-bar-read-only-bg: #f5f5f5;\n\n  // left\n  --swh-left-panel-bg: #edebf1;\n  //--swh-left-panel-status-bar-bg: #f6f6f6;\n  --swh-tree-row-height: 2em;\n  --swh-tree-node-drag-bg: #fff;\n  --swh-tree-selected-bg: #cbdef6;\n  --swh-tree-hover-bg: #f2f1f5;\n\n  // main\n  --swh-main-bg: #fff;\n\n  // status bar\n  --swh-status-bar-height: 22px;\n  --swh-status-bar-bg: #f0f1f1;\n  --swh-status-bar-font-color: var(--swh-font-color-weak);\n  --swh-status-bar-font-size: 10px;\n\n  // right bar\n  --swh-right-bar-width: 48px;\n  --swh-right-bar-bg: #f6f6f6;\n\n  // switch button\n  --swh-switch-button-main-color-off: #999;\n  --swh-switch-button-bg-color-off: #ccc;\n  --swh-switch-button-main-color-on: #91d982;\n  --swh-switch-button-bg-color-on: #fff;\n\n  // editor & viewer\n  --swh-editor-text-color: #000;\n  --swh-editor-comment-color: #090;\n  --swh-editor-ip-color: #009;\n  --swh-editor-error-color: #900;\n  --swh-editor-line-number-color: #999;\n  --swh-editor-line-number-bg: #fff;\n  --swh-editor-read-only-bg: #f5f5f5;\n  --swh-editor-font-size: 1em;\n  --swh-editor-line-height: 1.8em;\n\n  --swh-editor-bg-color: #fff;\n  --swh-editor-ip: #096dd9;\n  --swh-editor-keyword: #06427d;\n  --swh-editor-error: #c36;\n  --swh-editor-comment: #090;\n  --swh-editor-hl-bg: #ff0;\n  --swh-editor-gutter-bg: #fff;\n\n  // tray\n  --swh-tray-header-bg: #edebf1;\n  --swh-tray-font-color: #000;\n\n  // scroll bar\n  --swh-scrollbar-track: rgba(204, 204, 204, 0.25);\n  --swh-scrollbar-corner: #fff;\n  --swh-scrollbar-thumb: #ccc;\n  --swh-scrollbar-filler: #fafafa;\n\n  // other componets\n  --tree-drag-select-bg-color: var(--swh-tree-selected-bg);\n  --tree-drag-indicator-color: var(--swh-primary-color);\n\n  //color-scheme\n  --swh-color-scheme: light;\n}\n"
  },
  {
    "path": "src/renderer/styles/var.scss",
    "content": "@use \"./themes/light\";\n@use \"./themes/dark\";\n\n$font-editor: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;\n"
  },
  {
    "path": "src/renderer/utils/css-var.ts",
    "content": "/**\n * css-var\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nexport const getCssVar = (name: string): string => {\n  return getComputedStyle(document.documentElement)\n    .getPropertyValue(name)\n    .trim()\n}\n"
  },
  {
    "path": "src/version.json",
    "content": "[4, 3, 0, 6137]"
  },
  {
    "path": "test/_base.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport * as path from 'path'\nimport Db from 'potdb'\nimport { fileURLToPath } from 'url'\nimport { getSwhDb, swhdb } from '../src/main/data'\n\nconst dirname = path.dirname(fileURLToPath(import.meta.url))\n\nglobal.db_dir = path.join(dirname, 'tmp', 'db')\n\ndeclare global {\n  namespace NodeJS {\n    interface Global {\n      db_dir?: string;\n      swhdb: Db;\n    }\n  }\n}\n\nconst clearData = async () => {\n  const db = swhdb || (await getSwhDb())\n  await db.collection.hosts.remove()\n  await db.collection.trashcan.remove()\n  await db.list.tree.remove()\n}\n\nexport {\n  clearData,\n}\n"
  },
  {
    "path": "test/common/hostsFn.test.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { describe, expect, it } from 'vitest'\nimport type { IHostsListObject } from '../../src/common/data'\nimport { findItemById, setOnStateOfItem } from '../../src/common/hostsFn'\n\ndescribe('hostsFn test', () => {\n  const makeAList = (): IHostsListObject[] => {\n    return [\n      { id: '1' },\n      {\n        id: '2', type: 'folder', folder_mode: 0, children: [\n          { id: '2.1' },\n          { id: '2.2' },\n          { id: '2.3' },\n        ],\n      },\n      {\n        id: '3', type: 'folder', folder_mode: 1, children: [\n          { id: '3.1' },\n          { id: '3.2' },\n          { id: '3.3' },\n          {\n            id: '3.4', type: 'folder', folder_mode: 2, children: [\n              { id: '3.4.1' },\n              { id: '3.4.2' },\n              { id: '3.4.3' },\n            ],\n          },\n        ],\n      },\n      {\n        id: '4', type: 'folder', folder_mode: 2, children: [\n          { id: '4.1' },\n          { id: '4.2' },\n          { id: '4.3' },\n          {\n            id: '4.4', type: 'folder', folder_mode: 1, children: [\n              { id: '4.4.1' },\n              { id: '4.4.2' },\n              { id: '4.4.3' },\n            ],\n          },\n        ],\n      },\n      { id: '5' },\n      { id: '6' },\n    ]\n  }\n\n  const getItem = (list: IHostsListObject[], id: string): any => findItemById(list, id) || {}\n  const expectItemOn = (list: IHostsListObject[], id: string, value: boolean) => {\n    expect(Boolean(getItem(list, id).on)).toBe(value)\n  }\n  const expectTopLevelOn = (list: IHostsListObject[], expected: boolean[]) => {\n    expect(list.slice(0, expected.length).map((item) => Boolean(item.on))).toEqual(expected)\n  }\n\n  it('updateOneItem top level test', () => {\n    let list: IHostsListObject[] = [\n      { id: '1' },\n    ]\n    list = setOnStateOfItem(list, '1', true, 0)\n    expectTopLevelOn(list, [true])\n\n    list = [\n      { id: '1' },\n      { id: '2' },\n    ]\n    list = setOnStateOfItem(list, '1', true, 0)\n    expectTopLevelOn(list, [true, false])\n\n    list = setOnStateOfItem(list, '2', true, 0)\n    expectTopLevelOn(list, [true, true])\n\n    list = [\n      { id: '1' },\n      { id: '2', on: true },\n      { id: '3', on: true },\n    ]\n    list = setOnStateOfItem(list, '1', true, 1)\n    expectTopLevelOn(list, [true, false, false])\n\n    list = [\n      { id: '1' },\n      { id: '2', on: true },\n      { id: '3', on: true },\n    ]\n    list = setOnStateOfItem(list, '1', true, 2)\n    expectTopLevelOn(list, [true, true, true])\n  })\n\n  it('updateOneItem folder test', () => {\n    let list = makeAList()\n    list = setOnStateOfItem(list, '1', true, 1)\n    expectTopLevelOn(list, [true, false, false, false])\n\n    list = setOnStateOfItem(list, '2', true, 1)\n    expectTopLevelOn(list, [false, true, false, false])\n\n    list = setOnStateOfItem(list, '2.1', true, 0)\n    expectItemOn(list, '2.1', true)\n    expectItemOn(list, '2.2', false)\n    expectItemOn(list, '2.3', false)\n\n    list = setOnStateOfItem(list, '2.2', true, 0)\n    expectItemOn(list, '2.1', true)\n    expectItemOn(list, '2.2', true)\n    expectItemOn(list, '2.3', false)\n\n    list = setOnStateOfItem(list, '2.3', true, 1)\n    expectItemOn(list, '2.1', false)\n    expectItemOn(list, '2.2', false)\n    expectItemOn(list, '2.3', true)\n\n    list = setOnStateOfItem(list, '2.1', true, 1)\n    expectItemOn(list, '2.1', true)\n    expectItemOn(list, '2.2', false)\n    expectItemOn(list, '2.3', false)\n\n    list = setOnStateOfItem(list, '2.2', true, 2)\n    expectItemOn(list, '2.1', true)\n    expectItemOn(list, '2.2', true)\n    expectItemOn(list, '2.3', false)\n\n    list = setOnStateOfItem(list, '3.1', true, 0)\n    expectItemOn(list, '3.1', true)\n    expectItemOn(list, '3.2', false)\n    expectItemOn(list, '3.3', false)\n\n    list = setOnStateOfItem(list, '3.2', true, 0)\n    expectItemOn(list, '3.1', false)\n    expectItemOn(list, '3.2', true)\n    expectItemOn(list, '3.3', false)\n\n    list = setOnStateOfItem(list, '3.3', true, 1)\n    expectItemOn(list, '3.1', false)\n    expectItemOn(list, '3.2', false)\n    expectItemOn(list, '3.3', true)\n\n    list = setOnStateOfItem(list, '3.1', true, 2)\n    expectItemOn(list, '3.1', true)\n    expectItemOn(list, '3.2', false)\n    expectItemOn(list, '3.3', false)\n    expectItemOn(list, '3.4', false)\n\n    list = setOnStateOfItem(list, '3.4.1', true, 0)\n    expectItemOn(list, '3.4.1', true)\n    expectItemOn(list, '3.4.2', false)\n    expectItemOn(list, '3.4.3', false)\n\n    list = setOnStateOfItem(list, '3.4.2', true, 0)\n    expectItemOn(list, '3.4.1', true)\n    expectItemOn(list, '3.4.2', true)\n    expectItemOn(list, '3.4.3', false)\n\n    list = setOnStateOfItem(list, '3.4.3', true, 1)\n    expectItemOn(list, '3.4.1', true)\n    expectItemOn(list, '3.4.2', true)\n    expectItemOn(list, '3.4.3', true)\n\n    list = setOnStateOfItem(list, '3.4.3', false, 2)\n    expectItemOn(list, '3.4.1', true)\n    expectItemOn(list, '3.4.2', true)\n    expectItemOn(list, '3.4.3', false)\n\n    list = setOnStateOfItem(list, '4.1', true, 0)\n    expectItemOn(list, '4.1', true)\n    expectItemOn(list, '4.2', false)\n    expectItemOn(list, '4.3', false)\n\n    list = setOnStateOfItem(list, '4.2', true, 1)\n    expectItemOn(list, '4.1', true)\n    expectItemOn(list, '4.2', true)\n    expectItemOn(list, '4.3', false)\n\n    list = setOnStateOfItem(list, '4.3', true, 2)\n    expectItemOn(list, '4.1', true)\n    expectItemOn(list, '4.2', true)\n    expectItemOn(list, '4.3', true)\n\n    list = setOnStateOfItem(list, '4.4.1', true, 0)\n    expectItemOn(list, '4.4.1', true)\n    expectItemOn(list, '4.4.2', false)\n    expectItemOn(list, '4.4.3', false)\n\n    list = setOnStateOfItem(list, '4.4.2', true, 1)\n    expectItemOn(list, '4.4.1', false)\n    expectItemOn(list, '4.4.2', true)\n    expectItemOn(list, '4.4.3', false)\n\n    list = setOnStateOfItem(list, '4.4.3', true, 2)\n    expectItemOn(list, '4.4.1', false)\n    expectItemOn(list, '4.4.2', false)\n    expectItemOn(list, '4.4.3', true)\n\n    list = setOnStateOfItem(list, '4.4.3', false, 2)\n    expectItemOn(list, '4.4.1', false)\n    expectItemOn(list, '4.4.2', false)\n    expectItemOn(list, '4.4.3', false)\n  })\n})\n"
  },
  {
    "path": "test/common/mock/normalize.001.input.hosts",
    "content": "127.0.0.1 localhost\n127.0.0.1 test.com # valid\n127.0.0.2 test.com  # invalid\n\n# a comment\n\n127.0.0.1 a.com # some note1\n127.0.0.1 b.com # some note2\n127.0.0.1 c.com # another note2\n127.0.0.1 d.com # some e.com note\n\n127.0.0.1                   d.com\n127.0.0.1 d.com # some e.com note\n\n127.0.0.2 b.com     c.com   e.com  d.com   # comment for e.com\n\n::1\t\t    localhost\n"
  },
  {
    "path": "test/common/mock/normalize.001.output.hosts",
    "content": "127.0.0.1 localhost\n127.0.0.1 test.com # valid\n# invalid hosts (repeated): 127.0.0.2 test.com\n\n# a comment\n\n127.0.0.1 a.com # some note1\n127.0.0.1 b.com # some note2\n127.0.0.1 c.com # another note2\n127.0.0.1 d.com # some e.com note\n\n# invalid hosts (repeated): 127.0.0.1 d.com\n# invalid hosts (repeated): 127.0.0.1 d.com\n\n127.0.0.2 e.com # comment for e.com\n# invalid hosts (repeated): 127.0.0.2 b.com c.com d.com\n\n::1 localhost\n"
  },
  {
    "path": "test/common/newlines.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  getLineEndingForPlatform,\n  normalizeLineEndings,\n  restoreLineEndings,\n} from '../../src/common/newlines'\n\ndescribe('newlines', () => {\n  it('normalizes CRLF and CR to LF', () => {\n    expect(normalizeLineEndings('a\\r\\nb\\rc')).toBe('a\\nb\\nc')\n  })\n\n  it('uses CRLF on Windows', () => {\n    expect(getLineEndingForPlatform('win32')).toBe('\\r\\n')\n  })\n\n  it('uses LF on non-Windows platforms', () => {\n    expect(getLineEndingForPlatform('darwin')).toBe('\\n')\n    expect(getLineEndingForPlatform('linux')).toBe('\\n')\n  })\n\n  it('restores normalized text to CRLF', () => {\n    expect(restoreLineEndings('a\\nb\\n', '\\r\\n')).toBe('a\\r\\nb\\r\\n')\n  })\n})\n"
  },
  {
    "path": "test/common/normalize.test.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { promises as fs } from 'fs'\nimport * as path from 'path'\nimport { fileURLToPath } from 'url'\nimport { describe, expect, it } from 'vitest'\nimport normalize, { parseLine } from '../../src/common/normalize'\n\nconst dirname = path.dirname(fileURLToPath(import.meta.url))\nconst mock_dir = path.join(dirname, 'mock')\n\ndescribe('normalize test', () => {\n  const loadData = async (fn: string) => {\n    return fs.readFile(path.join(mock_dir, fn), 'utf-8')\n  }\n\n  it('basic test', () => {\n    expect(normalize('aaa')).toBe('aaa')\n  })\n\n  it('paresLine test', () => {\n    let d = parseLine('1.2.3.4 abc.com')\n    expect(d.ip).toBe('1.2.3.4')\n    expect(d.domains).toEqual([ 'abc.com' ])\n    expect(d.comment).toBe('')\n\n    d = parseLine('1.2.3.4  \\t abc.com abc2.com  abc3.com\\ttest.com  ')\n    expect(d.ip).toBe('1.2.3.4')\n    expect(d.domains).toEqual([ 'abc.com', 'abc2.com', 'abc3.com', 'test.com' ])\n    expect(d.comment).toBe('')\n\n    d = parseLine('1.2.3.4  \\t abc.com abc2.com  abc3.com\\ttest.com  # this is comment ')\n    expect(d.ip).toBe('1.2.3.4')\n    expect(d.domains).toEqual([ 'abc.com', 'abc2.com', 'abc3.com', 'test.com' ])\n    expect(d.comment).toBe('this is comment')\n\n    d = parseLine('1.2.3.4  \\t  # this is comment ')\n    expect(d.ip).toBe('1.2.3.4')\n    expect(d.domains).toEqual([])\n    expect(d.comment).toBe('this is comment')\n\n    d = parseLine('  \\t  # this is comment ')\n    expect(d.ip).toBe('')\n    expect(d.domains).toEqual([])\n    expect(d.comment).toBe('this is comment')\n\n    d = parseLine('# this is comment ')\n    expect(d.ip).toBe('')\n    expect(d.domains).toEqual([])\n    expect(d.comment).toBe('this is comment')\n  })\n\n  it('duplicate test', async () => {\n    const eq = async (number: string) => {\n      const input = await loadData(`normalize.${number}.input.hosts`)\n      const output = await loadData(`normalize.${number}.output.hosts`)\n\n      expect(normalize(input, { remove_duplicate_records: true })).toBe(output)\n    }\n\n    await eq('001')\n  })\n})\n"
  },
  {
    "path": "test/main/basic.test.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest'\nimport { clearData } from '../_base'\nimport {\n  getBasicData,\n  getHostsContent,\n  getList,\n  setHostsContent,\n  setList,\n} from '../../src/main/actions'\nimport { swhdb } from '../../src/main/data'\n\ndescribe('basic test', () => {\n  beforeEach(async () => {\n    await clearData()\n  })\n\n  it('add hosts', async () => {\n    const basic_data = await getBasicData()\n    expect(basic_data.list).toHaveLength(0)\n    expect(basic_data.trashcan).toHaveLength(0)\n    expect(basic_data.version).toHaveLength(4)\n\n    await swhdb.collection.hosts.insert({ id: '1' })\n    let items = await swhdb.collection.hosts.all()\n    expect(items).toHaveLength(1)\n\n    await setHostsContent('1', '# 111')\n    expect(await getHostsContent('1')).toBe('# 111')\n\n    let list = await getList()\n    expect(list).toHaveLength(0)\n    await setList([ { id: '1' } ])\n    list = await getList()\n    expect(list).toHaveLength(1)\n    expect(list[0].id).toBe('1')\n  })\n\n  it('normalizes CRLF when reading and writing hosts content', async () => {\n    await swhdb.collection.hosts.insert({\n      id: 'crlf-item',\n      content: '127.0.0.1 localhost\\r\\n# comment\\r\\n',\n    })\n\n    expect(await getHostsContent('crlf-item')).toBe('127.0.0.1 localhost\\n# comment\\n')\n\n    await setHostsContent('crlf-item', '127.0.0.1 localhost\\r\\n# next\\r\\n')\n\n    const raw = await swhdb.collection.hosts.find<{ content: string }>((i) => i.id === 'crlf-item')\n    expect(raw?.content).toBe('127.0.0.1 localhost\\n# next\\n')\n    expect(await getHostsContent('crlf-item')).toBe('127.0.0.1 localhost\\n# next\\n')\n  })\n\n  it('group hosts', async () => {\n    await setList([\n      { id: '1' },\n      { id: '2' },\n      { id: '3', type: 'group', include: [ '1', '2' ] },\n    ])\n    const c1 = '# 425748244153'\n    const c2 = '# 642156457548'\n    await setHostsContent('1', c1)\n    await setHostsContent('2', c2)\n\n    expect(await getHostsContent('1')).toBe(c1)\n    expect(await getHostsContent('2')).toBe(c2)\n\n    const c3 = await getHostsContent('3')\n    expect(c3).toContain(c1)\n    expect(c3.indexOf(c2)).toBeGreaterThan(c3.indexOf(c1))\n\n    await setList([\n      { id: '1' },\n      { id: '2' },\n      {\n        id: '4', type: 'folder', children: [\n          { id: '5', type: 'group', include: [ '1', '2' ] },\n        ],\n      },\n    ])\n\n    const c5 = await getHostsContent('5')\n    expect(c3).toBe(c5)\n  })\n})\n"
  },
  {
    "path": "test/main/findInContent.test.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { describe, expect, it } from 'vitest'\nimport { default as findInContent } from '../../src/main/actions/find/findPositionsInContent'\n\ndescribe('find in content test', () => {\n  it('basic test 1', () => {\n    const content = `abc12 abc123 abc`\n    const matches = findInContent(content, /bc/ig)\n\n    expect(matches).toHaveLength(3)\n    expect(matches[0]).toMatchObject({\n      line: 1,\n      start: 1,\n      end: 3,\n      before: 'a',\n      match: 'bc',\n    })\n    expect(matches[0].after).toEqual(expect.any(String))\n    expect(matches[1]).toMatchObject({\n      line: 1,\n      start: 7,\n      end: 9,\n      before: 'abc12 a',\n      match: 'bc',\n      after: '123 abc',\n    })\n    expect(matches[2]).toMatchObject({\n      line: 1,\n      start: 14,\n      end: 16,\n      before: 'abc12 abc123 a',\n      match: 'bc',\n      after: '',\n    })\n  })\n\n  it('basic test 2', () => {\n    const content = `abc12 abc123 abc\\nxyza3b`\n    const matches = findInContent(content, /a\\w*3/ig)\n\n    expect(matches).toHaveLength(2)\n    expect(matches[0]).toMatchObject({\n      line: 1,\n      start: 6,\n      end: 12,\n      before: 'abc12 ',\n      match: 'abc123',\n      after: ' abc',\n    })\n    expect(matches[1]).toMatchObject({\n      line: 2,\n      start: 20,\n      end: 22,\n      before: 'xyz',\n      match: 'a3',\n      after: 'b',\n    })\n  })\n})\n"
  },
  {
    "path": "test/main/http.test.ts",
    "content": "import { ipcMain } from 'electron'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { http_api_port } from '../../src/common/constants'\nimport events from '../../src/common/events'\nimport { setList } from '../../src/main/actions'\nimport { clearData } from '../_base'\n\nconst { closeMock, serveMock } = vi.hoisted(() => {\n  const close = vi.fn()\n  const serve = vi.fn((options: object, callback?: () => void) => {\n    callback?.()\n    return {\n      close,\n      options,\n    }\n  })\n\n  return {\n    closeMock: close,\n    serveMock: serve,\n  }\n})\n\nvi.mock('@hono/node-server', () => ({\n  serve: serveMock,\n}))\n\nimport { app, start, stop } from '../../src/main/http'\n\ndescribe('http api test', () => {\n  beforeEach(async () => {\n    await clearData()\n  })\n\n  afterEach(() => {\n    vi.restoreAllMocks()\n    serveMock.mockClear()\n    closeMock.mockClear()\n  })\n\n  it('should log request metadata for incoming requests', async () => {\n    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})\n\n    const response = await app.request('/api/list', {\n      headers: {\n        'user-agent': 'vitest',\n      },\n    })\n\n    expect(response.status).toBe(200)\n    expect(logSpy).toHaveBeenCalledWith(\n      expect.stringMatching(/^> \".+\"$/),\n      'GET',\n      '/api/list',\n      '\"vitest\"',\n    )\n  })\n\n  it('should respond on root endpoint', async () => {\n    const response = await app.request('/')\n\n    expect(response.status).toBe(200)\n    expect(await response.text()).toBe('Hello SwitchHosts!')\n  })\n\n  it('should respond on remote test endpoint', async () => {\n    const response = await app.request('/remote-test')\n\n    expect(response.status).toBe(200)\n    expect(await response.text()).toMatch(/^# remote-test\\n# .+/)\n  })\n\n  it('should flatten list data for api list endpoint', async () => {\n    await setList([\n      { id: 'top-1', title: 'Top 1' },\n      {\n        id: 'folder-1',\n        type: 'folder',\n        children: [\n          { id: 'child-1', title: 'Child 1' },\n          { id: 'child-2', title: 'Child 2' },\n        ],\n      },\n    ])\n\n    const response = await app.request('/api/list')\n    const body = await response.json()\n\n    expect(response.status).toBe(200)\n    expect(body.success).toBe(true)\n    expect(body.data.map((item: { id: string }) => item.id)).toEqual([\n      'top-1',\n      'folder-1',\n      'child-1',\n      'child-2',\n    ])\n  })\n\n  it('should reject toggle requests without id', async () => {\n    const response = await app.request('/api/toggle')\n\n    expect(response.status).toBe(200)\n    expect(await response.text()).toBe('bad id.')\n  })\n\n  it('should return not found for unknown toggle id', async () => {\n    const response = await app.request('/api/toggle?id=missing')\n\n    expect(response.status).toBe(200)\n    expect(await response.text()).toBe('not found.')\n  })\n\n  it('should broadcast toggle event for existing item', async () => {\n    const emitSpy = vi.spyOn(ipcMain, 'emit')\n\n    await setList([\n      { id: 'item-1', on: false, title: 'Item 1' },\n    ])\n\n    const response = await app.request('/api/toggle?id=item-1')\n\n    expect(response.status).toBe(200)\n    expect(await response.text()).toBe('ok')\n    expect(emitSpy).toHaveBeenCalledWith('x_broadcast', null, {\n      event: events.toggle_item,\n      args: [ 'item-1', true ],\n    })\n  })\n\n  it('should listen on localhost when local-only mode is enabled', () => {\n    expect(start(true)).toBe(true)\n    expect(serveMock.mock.calls[0]?.[0]).toEqual({\n      fetch: app.fetch,\n      port: http_api_port,\n      hostname: '127.0.0.1',\n    })\n    expect(typeof serveMock.mock.calls[0]?.[1]).toBe('function')\n\n    stop()\n    expect(closeMock).toHaveBeenCalledOnce()\n  })\n\n  it('should listen on all interfaces when local-only mode is disabled', () => {\n    expect(start(false)).toBe(true)\n    expect(serveMock.mock.calls[0]?.[0]).toEqual({\n      fetch: app.fetch,\n      port: http_api_port,\n      hostname: '0.0.0.0',\n    })\n    expect(typeof serveMock.mock.calls[0]?.[1]).toBe('function')\n\n    stop()\n    expect(closeMock).toHaveBeenCalledOnce()\n  })\n\n  it('should return false when serve throws', () => {\n    const error = new Error('listen failed')\n    serveMock.mockImplementationOnce(() => {\n      throw error\n    })\n    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})\n\n    expect(start(true)).toBe(false)\n    expect(errorSpy).toHaveBeenCalledWith(error)\n  })\n\n  it('should swallow close errors when stopping server', () => {\n    const error = new Error('close failed')\n    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})\n    const failingClose = vi.fn(() => {\n      throw error\n    })\n\n    serveMock.mockImplementationOnce((options: object, callback?: () => void) => {\n      callback?.()\n      return {\n        close: failingClose,\n        options,\n      }\n    })\n\n    expect(start(true)).toBe(true)\n\n    stop()\n\n    expect(errorSpy).toHaveBeenCalledWith(error)\n  })\n})\n"
  },
  {
    "path": "test/main/setSystemHosts.test.ts",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { clearData } from '../_base'\nimport configSet from '../../src/main/actions/config/set'\n\nconst dirname = path.dirname(fileURLToPath(import.meta.url))\nconst systemHostsPath = path.join(dirname, '..', 'tmp', 'system-hosts')\n\nvi.mock('../../src/main/actions/hosts/getPathOfSystemHostsPath', () => ({\n  default: async () => systemHostsPath,\n}))\n\ndescribe('setSystemHosts', () => {\n  beforeEach(async () => {\n    await clearData()\n    ;(global as typeof global & { tracer?: { add: (message: string) => void } }).tracer = {\n      add() {},\n    }\n    await configSet('write_mode', 'overwrite')\n    await configSet('cmd_after_hosts_apply', '')\n    await fs.mkdir(path.dirname(systemHostsPath), { recursive: true })\n    await fs.writeFile(systemHostsPath, '127.0.0.1 localhost\\n', 'utf-8')\n  })\n\n  it('writes CRLF when the platform line ending is Windows', async () => {\n    vi.doMock('../../src/common/newlines', async () => {\n      const actual = await vi.importActual<typeof import('../../src/common/newlines')>(\n        '../../src/common/newlines',\n      )\n\n      return {\n        ...actual,\n        getLineEndingForPlatform: () => '\\r\\n' as const,\n      }\n    })\n\n    const { default: setSystemHosts } = await import('../../src/main/actions/hosts/setSystemHosts')\n\n    const result = await setSystemHosts('1.1.1.1 example.test\\n# note\\n')\n    expect(result.success).toBe(true)\n    expect(await fs.readFile(systemHostsPath, 'utf-8')).toBe('1.1.1.1 example.test\\r\\n# note\\r\\n')\n  })\n})\n"
  },
  {
    "path": "test/main/splitContent.test.ts",
    "content": "/**\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { describe, expect, it } from 'vitest'\nimport { default as findInContent } from '../../src/main/actions/find/findPositionsInContent'\nimport { default as splitContent } from '../../src/main/actions/find/splitContent'\n\ndescribe('split content test', () => {\n  it('basic test 1', () => {\n    const content = `abc12 abc123 abc44`\n    const matches = findInContent(content, /bc/ig)\n    const parts = splitContent(content, matches)\n\n    expect(parts[0]).toMatchObject({ before: 'a', after: '' })\n    expect(parts[1]).toMatchObject({ before: '12 a', after: '' })\n    expect(parts[2]).toMatchObject({ before: '123 a', after: '44' })\n\n    const rebuilt = parts.map((item) => `${item.before}${item.match}${item.after}`).join('')\n    expect(rebuilt).toBe(content)\n  })\n})\n"
  },
  {
    "path": "test/main/trashcan.test.ts",
    "content": "/**\n * trashcan.test.ts\n * @author: oldj\n * @homepage: https://oldj.net\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest'\nimport { clearData } from '../_base'\nimport type { IHostsContentObject } from '../../src/common/data'\nimport {\n  clearTrashcan,\n  deleteItemFromTrashcan,\n  getBasicData,\n  getHostsContent,\n  getList,\n  getTrashcanList,\n  moveToTrashcan,\n  restoreItemFromTrashcan,\n  setHostsContent,\n  setList,\n} from '../../src/main/actions'\nimport { swhdb } from '../../src/main/data'\n\ndescribe('trashcan test', () => {\n  beforeEach(async () => {\n    await clearData()\n  })\n\n  it('basic add and delete hosts', async () => {\n    let { list, trashcan } = await getBasicData()\n    expect(list).toHaveLength(0)\n    expect(trashcan).toHaveLength(0)\n\n    await setList([ { id: '111' } ])\n    list = await getList()\n    expect(list).toHaveLength(1)\n    expect((await getBasicData()).list).toHaveLength(1)\n    expect(await getTrashcanList()).toHaveLength(0)\n\n    await moveToTrashcan('111')\n    expect(await getList()).toHaveLength(0)\n\n    let tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(1)\n    expect(tlist[0].data.id).toBe('111')\n    const ts = (new Date()).getTime()\n    expect(tlist[0].add_time_ms).toBeGreaterThan(ts - 1000)\n    expect(tlist[0].add_time_ms).toBeLessThanOrEqual(ts)\n\n    await restoreItemFromTrashcan(tlist[0].data.id)\n    list = await getList()\n    expect(list).toHaveLength(1)\n    expect(list[0].id).toBe('111')\n    expect(await getTrashcanList()).toHaveLength(0)\n\n    await setHostsContent('111', 'hosts_111')\n    expect(await getHostsContent('111')).toBe('hosts_111')\n\n    await moveToTrashcan('111')\n    expect(await getList()).toHaveLength(0)\n    expect(await getTrashcanList()).toHaveLength(1)\n    expect(await getHostsContent('111')).toBe('hosts_111')\n\n    await deleteItemFromTrashcan('111')\n    expect(await getList()).toHaveLength(0)\n    expect(await getTrashcanList()).toHaveLength(0)\n    expect(await getHostsContent('111')).toBe('')\n  })\n\n  it('folder test', async () => {\n    await setList([\n      { id: '1' },\n      { id: '2' },\n      {\n        id: '3', type: 'folder', children: [\n          { id: '3.1' },\n          { id: '3.2' },\n          {\n            id: '3.3', type: 'folder', children: [\n              { id: '3.3.1' },\n              { id: '3.3.2' },\n              { id: '3.3.3' },\n            ],\n          },\n          { id: '3.4' },\n        ],\n      },\n      { id: '4' },\n    ])\n\n    let list = await getList()\n    expect(list).toHaveLength(4)\n    let tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(0)\n\n    await moveToTrashcan('2')\n    list = await getList()\n    expect(list).toHaveLength(3)\n    tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(1)\n    expect(tlist[0].data.id).toBe('2')\n    expect(tlist[0].parent_id).toBeNull()\n\n    await restoreItemFromTrashcan('2')\n    list = await getList()\n    expect(list).toHaveLength(4)\n    expect(list[3].id).toBe('2')\n\n    await moveToTrashcan('3.3.1')\n    list = await getList()\n    expect(list).toHaveLength(4)\n    expect(list[1].id).toBe('3')\n    expect(list[1].children?.[2].id).toBe('3.3')\n    expect(list[1].children?.[2].children).toHaveLength(2)\n    expect(list[1].children?.[2].children?.[0].id).toBe('3.3.2')\n    tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(1)\n    expect(tlist[0].data.id).toBe('3.3.1')\n    expect(tlist[0].parent_id).toBe('3.3')\n\n    await restoreItemFromTrashcan('3.3.1')\n    list = await getList()\n    expect(list[1].children?.[2].id).toBe('3.3')\n    expect(list[1].children?.[2].children).toHaveLength(3)\n    expect(list[1].children?.[2].children?.[0].id).toBe('3.3.2')\n    expect(list[1].children?.[2].children?.[1].id).toBe('3.3.3')\n    expect(list[1].children?.[2].children?.[2].id).toBe('3.3.1')\n  })\n\n  it('folder delete test', async () => {\n    await setList([\n      { id: '1' },\n      { id: '2' },\n      {\n        id: '3', type: 'folder', children: [\n          { id: '3.1' },\n          { id: '3.2' },\n          {\n            id: '3.3', type: 'folder', children: [\n              { id: '3.3.1' },\n              { id: '3.3.2' },\n              { id: '3.3.3' },\n            ],\n          },\n          { id: '3.4' },\n        ],\n      },\n      { id: '4' },\n    ])\n\n    let hs: IHostsContentObject[] = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(0)\n\n    await setHostsContent('1', '# 1')\n    await setHostsContent('2', '# 2')\n    await setHostsContent('3', '# 3')\n    await setHostsContent('3.1', '# 3.1')\n    await setHostsContent('3.2', '# 3.2')\n    await setHostsContent('3.3', '# 3.3')\n    await setHostsContent('3.3.1', '# 3.3.1')\n    await setHostsContent('3.3.2', '# 3.3.2')\n    await setHostsContent('3.3.3', '# 3.3.3')\n    await setHostsContent('3.4', '# 3.4')\n    await setHostsContent('4', '# 4')\n\n    const list = await getList()\n    expect(list).toHaveLength(4)\n    const tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(0)\n\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(11)\n    expect(hs[0].content).toBe('# 1')\n\n    await moveToTrashcan('3.2')\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(11)\n    await deleteItemFromTrashcan('3.2')\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(10)\n\n    await moveToTrashcan('3')\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(10)\n    await deleteItemFromTrashcan('3')\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(3)\n  })\n\n  it('clear test', async () => {\n    await setList([\n      { id: '1' },\n      { id: '2' },\n      {\n        id: '3', type: 'folder', children: [\n          { id: '3.1' },\n          { id: '3.2' },\n          {\n            id: '3.3', type: 'folder', children: [\n              { id: '3.3.1' },\n              { id: '3.3.2' },\n              { id: '3.3.3' },\n            ],\n          },\n          { id: '3.4' },\n        ],\n      },\n      { id: '4' },\n    ])\n\n    let hs: IHostsContentObject[] = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(0)\n\n    await setHostsContent('1', '# 1')\n    await setHostsContent('2', '# 2')\n    await setHostsContent('3', '# 3')\n    await setHostsContent('3.1', '# 3.1')\n    await setHostsContent('3.2', '# 3.2')\n    await setHostsContent('3.3', '# 3.3')\n    await setHostsContent('3.3.1', '# 3.3.1')\n    await setHostsContent('3.3.2', '# 3.3.2')\n    await setHostsContent('3.3.3', '# 3.3.3')\n    await setHostsContent('3.4', '# 3.4')\n    await setHostsContent('4', '# 4')\n\n    const list = await getList()\n    expect(list).toHaveLength(4)\n    const tlist = await getTrashcanList()\n    expect(tlist).toHaveLength(0)\n\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(11)\n\n    await moveToTrashcan('1')\n    await moveToTrashcan('2')\n    await moveToTrashcan('3')\n    await moveToTrashcan('4')\n\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(11)\n\n    await clearTrashcan()\n    hs = await swhdb.collection.hosts.all()\n    expect(hs).toHaveLength(0)\n    expect(await getList()).toHaveLength(0)\n    expect(await getTrashcanList()).toHaveLength(0)\n  })\n})\n"
  },
  {
    "path": "test/scripts/upload-diagnostics.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  attachDiagnostic,\n  buildDebugPayload,\n  buildDiagnostic,\n  extractErrorDetails,\n  formatDiagnosticSummary,\n  formatRetrySummary,\n} from '../../scripts/upload-diagnostics.mjs'\n\ndescribe('upload diagnostics', () => {\n  it('extracts network cause details from fetch failures', () => {\n    const error = new TypeError('fetch failed', {\n      cause: {\n        code: 'ECONNRESET',\n        message: 'socket hang up',\n        syscall: 'read',\n      },\n    })\n\n    expect(extractErrorDetails(error)).toMatchObject({\n      causeCode: 'ECONNRESET',\n      causeMessage: 'socket hang up',\n      causeSyscall: 'read',\n      errorMessage: 'fetch failed',\n      errorName: 'TypeError',\n    })\n  })\n\n  it('formats upload failures with attempt, file index and cause code', () => {\n    const diagnostic = buildDiagnostic({\n      attempt: 3,\n      error: new TypeError('fetch failed', {\n        cause: {\n          code: 'ECONNRESET',\n          message: 'socket hang up',\n        },\n      }),\n      fileIndex: 5,\n      fileName: 'SwitchHosts-v4.3.0.6136-linux-amd64.deb',\n      maxAttempts: 3,\n      method: 'POST',\n      progressSnapshot: {\n        currentFileBytes: 123,\n        totalFiles: 24,\n        totalUploadedBytes: 456,\n      },\n      retryable: false,\n      stage: 'upload-asset',\n      target: '/upload',\n    })\n\n    expect(formatDiagnosticSummary(diagnostic)).toContain('upload-asset failed')\n    expect(formatDiagnosticSummary(diagnostic)).toContain('attempt 3/3')\n    expect(formatDiagnosticSummary(diagnostic)).toContain('file 5/24')\n    expect(formatDiagnosticSummary(diagnostic)).toContain('cause=ECONNRESET')\n  })\n\n  it('formats dns failures with the underlying cause code', () => {\n    const diagnostic = buildDiagnostic({\n      attempt: 2,\n      error: new TypeError('fetch failed', {\n        cause: {\n          code: 'EAI_AGAIN',\n          hostname: 'api.github.com',\n          message: 'getaddrinfo EAI_AGAIN api.github.com',\n        },\n      }),\n      maxAttempts: 3,\n      method: 'GET',\n      retryable: true,\n      stage: 'find-release',\n      target: '/repos/oldj/SwitchHosts/releases?per_page=100&page=1',\n    })\n\n    expect(formatRetrySummary(diagnostic, '1.5s')).toContain('cause=EAI_AGAIN')\n    expect(formatDiagnosticSummary(diagnostic)).toContain('message=getaddrinfo EAI_AGAIN api.github.com')\n  })\n\n  it('includes http status for api failures', () => {\n    const diagnostic = buildDiagnostic({\n      attempt: 3,\n      error: new Error('GET /repos/... failed: 503 Service Unavailable'),\n      httpStatus: 503,\n      maxAttempts: 3,\n      method: 'GET',\n      retryable: false,\n      stage: 'find-release',\n      target: '/repos/oldj/SwitchHosts/releases?per_page=100&page=1',\n    })\n\n    expect(formatDiagnosticSummary(diagnostic)).toContain('status=503')\n    expect(formatRetrySummary(diagnostic, '3.0s')).toContain('status=503')\n  })\n\n  it('builds debug payloads with stack and raw cause fields', () => {\n    const error = new TypeError('fetch failed', {\n      cause: {\n        code: 'ECONNRESET',\n        errno: -54,\n        message: 'socket hang up',\n      },\n    })\n    const diagnostic = buildDiagnostic({\n      attempt: 3,\n      error,\n      maxAttempts: 3,\n      method: 'POST',\n      retryable: false,\n      stage: 'upload-asset',\n      target: '/upload',\n    })\n\n    expect(buildDebugPayload(diagnostic, error)).toMatchObject({\n      diagnostic: {\n        causeCode: 'ECONNRESET',\n        stage: 'upload-asset',\n      },\n      error: {\n        cause: {\n          code: 'ECONNRESET',\n          errno: -54,\n          message: 'socket hang up',\n        },\n        errorMessage: 'fetch failed',\n      },\n    })\n  })\n\n  it('falls back to the top-level error message when no cause exists', () => {\n    const diagnostic = buildDiagnostic({\n      attempt: 1,\n      error: new Error('plain failure'),\n      maxAttempts: 3,\n      method: 'DELETE',\n      retryable: false,\n      stage: 'delete-asset',\n      target: '/repos/oldj/SwitchHosts/releases/assets/1',\n    })\n\n    expect(formatDiagnosticSummary(diagnostic)).toContain('message=plain failure')\n  })\n\n  it('attaches diagnostics to error instances for top-level reporting', () => {\n    const error = new Error('plain failure')\n    const diagnostic = buildDiagnostic({\n      attempt: 1,\n      error,\n      maxAttempts: 3,\n      method: 'DELETE',\n      retryable: false,\n      stage: 'delete-asset',\n      target: '/repos/oldj/SwitchHosts/releases/assets/1',\n    })\n\n    const attached = attachDiagnostic(error, diagnostic)\n    expect(attached.diagnostic).toEqual(diagnostic)\n  })\n})\n"
  },
  {
    "path": "test/scripts/upload-progress.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  createUploadProgressTracker,\n  formatEta,\n  formatProgressMessage,\n  formatTtyProgressLines,\n  fitFileNameToWidth,\n  truncateFileName,\n} from '../../scripts/upload-progress.mjs'\n\ndescribe('upload progress tracker', () => {\n  it('aggregates total files and bytes across multiple uploads', () => {\n    let currentTime = 0\n    const tracker = createUploadProgressTracker({\n      isTTY: false,\n      log() {},\n      now: () => currentTime,\n      totalBytes: 400,\n      totalFiles: 2,\n    })\n\n    tracker.startFile({ name: 'first.zip', size: 100 }, 1)\n    currentTime = 1000\n    tracker.advance(100)\n    tracker.completeFile()\n\n    tracker.startFile({ name: 'second.zip', size: 300 }, 2)\n    currentTime = 2000\n    tracker.advance(60)\n\n    const snapshot = tracker.getSnapshot()\n\n    expect(snapshot.currentFileIndex).toBe(2)\n    expect(snapshot.totalFiles).toBe(2)\n    expect(snapshot.totalBytes).toBe(400)\n    expect(snapshot.totalUploadedBytes).toBe(160)\n    expect(snapshot.currentFileBytes).toBe(60)\n    expect(snapshot.totalPercent).toBeCloseTo(40)\n    expect(snapshot.currentFilePercent).toBeCloseTo(20)\n    expect(snapshot.speedBytesPerSecond).toBeCloseTo(80)\n  })\n\n  it('reports a safe eta before any bytes are uploaded', () => {\n    const tracker = createUploadProgressTracker({\n      isTTY: false,\n      log() {},\n      now: () => 0,\n      totalBytes: 200,\n      totalFiles: 1,\n    })\n\n    tracker.startFile({ name: 'asset.zip', size: 200 }, 1)\n\n    expect(tracker.getSnapshot().etaSeconds).toBeNull()\n    expect(formatEta(tracker.getSnapshot().etaSeconds)).toBe('--:--')\n  })\n\n  it('reaches 100 percent after the last chunk and finish', () => {\n    let currentTime = 0\n    const tracker = createUploadProgressTracker({\n      isTTY: false,\n      log() {},\n      now: () => currentTime,\n      totalBytes: 120,\n      totalFiles: 1,\n    })\n\n    tracker.startFile({ name: 'asset.zip', size: 120 }, 1)\n    currentTime = 1000\n    tracker.advance(120)\n    tracker.completeFile()\n    tracker.finish()\n\n    const snapshot = tracker.getSnapshot()\n\n    expect(snapshot.totalUploadedBytes).toBe(120)\n    expect(snapshot.totalPercent).toBe(100)\n    expect(snapshot.currentFilePercent).toBe(100)\n    expect(snapshot.etaSeconds).toBe(0)\n  })\n\n  it('can roll back the current file progress before a retry', () => {\n    let currentTime = 0\n    const tracker = createUploadProgressTracker({\n      isTTY: false,\n      log() {},\n      now: () => currentTime,\n      totalBytes: 400,\n      totalFiles: 2,\n    })\n\n    tracker.startFile({ name: 'first.zip', size: 100 }, 1)\n    currentTime = 1000\n    tracker.advance(100)\n    tracker.completeFile()\n\n    tracker.startFile({ name: 'second.zip', size: 300 }, 2)\n    currentTime = 2000\n    tracker.advance(120)\n    tracker.resetCurrentFile()\n\n    const snapshot = tracker.getSnapshot()\n\n    expect(snapshot.totalUploadedBytes).toBe(100)\n    expect(snapshot.currentFileBytes).toBe(0)\n    expect(snapshot.totalPercent).toBeCloseTo(25)\n    expect(snapshot.currentFilePercent).toBe(0)\n  })\n\n  it('throttles non-tty progress logs instead of logging every chunk', () => {\n    let currentTime = 0\n    const logs: string[] = []\n    const tracker = createUploadProgressTracker({\n      isTTY: false,\n      log(message) {\n        logs.push(message)\n      },\n      now: () => currentTime,\n      percentStep: 5,\n      throttleMs: 1000,\n      totalBytes: 1000,\n      totalFiles: 1,\n    })\n\n    tracker.startFile({ name: 'asset.zip', size: 1000 }, 1)\n    currentTime = 100\n    tracker.advance(10)\n    currentTime = 200\n    tracker.advance(20)\n    currentTime = 300\n    tracker.advance(30)\n\n    expect(logs).toHaveLength(2)\n    expect(logs[0]).toContain('file 1/1')\n    expect(logs[1]).toContain('progress 6.0%')\n  })\n\n  it('interrupts an active tty progress bar before printing status messages', () => {\n    const logs: string[] = []\n    const writes: string[] = []\n    class FakeProgressBar {\n      complete = false\n      curr = 0\n      total = 100\n\n      interrupt(message: string) {\n        writes.push(`interrupt:${message}`)\n      }\n\n      render() {}\n\n      terminate() {}\n\n      tick(delta: number) {\n        this.curr += delta\n      }\n\n      update(ratio: number) {\n        this.curr = Math.floor(this.total * ratio)\n      }\n    }\n\n    const fakeStream = {\n      clearLine() {},\n      cursorTo() {},\n      isTTY: true,\n      moveCursor() {},\n      write(chunk: string) {\n        writes.push(chunk)\n        return true\n      },\n    }\n\n    const tracker = createUploadProgressTracker({\n      ProgressBarClass: FakeProgressBar as never,\n      isTTY: true,\n      log(message) {\n        logs.push(message)\n      },\n      stream: fakeStream as never,\n      totalBytes: 100,\n      totalFiles: 1,\n    })\n\n    tracker.startFile({ name: 'asset.zip', size: 100 }, 1)\n    tracker.interrupt('[release:upload] replacing existing asset asset.zip')\n\n    expect(logs).toEqual([])\n    expect(writes.length).toBeGreaterThan(0)\n    expect(writes.join('')).toContain('[release:upload] replacing existing asset asset.zip')\n  })\n\n  it('truncates long file names while keeping the suffix visible', () => {\n    const fileName = 'SwitchHosts-macos-universal-v4.2.0.12345-very-long-artifact-name.zip'\n    const truncated = truncateFileName(fileName, 36)\n\n    expect(truncated).toHaveLength(36)\n    expect(truncated).toContain('...')\n    expect(truncated.endsWith('ifact-name.zip')).toBe(true)\n    expect(\n      formatProgressMessage({\n        currentFileIndex: 1,\n        currentFilePercent: 12.34,\n        currentFileSize: 10,\n        currentFileBytes: 1,\n        currentFileName: fileName,\n        displayFileName: truncated,\n        etaLabel: '00:12',\n        etaSeconds: 12,\n        speedBytesPerSecond: 10,\n        speedLabel: '10 B/s',\n        totalBytes: 100,\n        totalFiles: 2,\n        totalPercent: 45.67,\n        totalUploadedBytes: 46,\n        totalLabel: '100 B',\n        transferredLabel: '46 B',\n      }),\n    ).toContain(truncated)\n  })\n\n  it('shows the full file name in tty output when there is enough space', () => {\n    const fileName = 'SwitchHosts-v4.3.0.6136-linux-amd64.deb'\n    const lines = formatTtyProgressLines(\n      {\n        currentFileIndex: 5,\n        currentFilePercent: 30.4,\n        currentFileSize: 100,\n        currentFileBytes: 30,\n        currentFileName: fileName,\n        displayFileName: fileName,\n        etaLabel: '02:21:58',\n        etaSeconds: 8518,\n        speedBytesPerSecond: 175000,\n        speedLabel: '175 kB/s',\n        totalBytes: 1520000000,\n        totalFiles: 24,\n        totalPercent: 1.9,\n        totalUploadedBytes: 28100000,\n        totalLabel: '1.52 GB',\n        transferredLabel: '28.1 MB',\n      },\n      '[                        ]',\n      140,\n    )\n\n    expect(lines[1]).toContain(fileName)\n  })\n\n  it('truncates the tty file name only when the line is too narrow', () => {\n    const fileName = 'SwitchHosts-v4.3.0.6136-linux-amd64.deb'\n\n    expect(fitFileNameToWidth(fileName, 100)).toBe(fileName)\n    expect(fitFileNameToWidth(fileName, 20)).toContain('...')\n  })\n})\n"
  },
  {
    "path": "test/setup.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { vi } from 'vitest'\n\nconst testDir = path.dirname(fileURLToPath(import.meta.url))\nconst tmpDir = path.join(testDir, 'tmp')\nconst testTmpDir = path.join(tmpDir, 'electron')\nconst testHomeDir = path.join(tmpDir, 'home')\n\nfs.rmSync(tmpDir, { force: true, recursive: true })\nfs.mkdirSync(testHomeDir, { recursive: true })\n\nprocess.env.HOME = testHomeDir\nprocess.env.USERPROFILE = testHomeDir\n;(globalThis as typeof globalThis & { data_dir?: string }).data_dir = path.join(testHomeDir, '.SwitchHosts')\n\nclass BrowserWindowMock {\n  static fromWebContents() {\n    return new BrowserWindowMock()\n  }\n\n  private bounds = { x: 0, y: 0, width: 300, height: 600 }\n  private focused = false\n  private visible = false\n\n  webContents = {\n    closeDevTools() {},\n    openDevTools() {},\n    once() {},\n    session: {\n      setPermissionRequestHandler() {},\n      webRequest: {\n        onBeforeSendHeaders() {},\n        onHeadersReceived() {},\n      },\n    },\n    toggleDevTools() {},\n  }\n\n  constructor(options?: { width?: number; height?: number }) {\n    if (options?.width) this.bounds.width = options.width\n    if (options?.height) this.bounds.height = options.height\n  }\n\n  focus() {\n    this.focused = true\n  }\n\n  getBounds() {\n    return this.bounds\n  }\n\n  hide() {\n    this.focused = false\n    this.visible = false\n  }\n\n  isFocused() {\n    return this.focused\n  }\n\n  isVisible() {\n    return this.visible\n  }\n\n  loadURL() {\n    return Promise.resolve()\n  }\n\n  on() {\n    return this\n  }\n\n  setPosition(x: number, y: number) {\n    this.bounds.x = x\n    this.bounds.y = y\n  }\n\n  setVisibleOnAllWorkspaces() {}\n\n  show() {\n    this.focused = true\n    this.visible = true\n  }\n}\n\nconst electronMock = {\n  app: {\n    getPath(name: string) {\n      if (name === 'userData') {\n        fs.mkdirSync(testTmpDir, { recursive: true })\n        return testTmpDir\n      }\n\n      return testTmpDir\n    },\n    quit() {},\n    whenReady() {\n      return new Promise(() => {})\n    },\n    dock: {\n      hide() {},\n      show() {\n        return Promise.resolve()\n      },\n    },\n  },\n  BrowserWindow: BrowserWindowMock,\n  Menu: {\n    buildFromTemplate() {\n      return {}\n    },\n  },\n  MenuItem: class MenuItem {},\n  Tray: class Tray {\n    setToolTip() {}\n    setContextMenu() {}\n    on() {}\n    popUpContextMenu() {}\n    getBounds() {\n      return { x: 0, y: 0, width: 20, height: 20 }\n    }\n  },\n  screen: {\n    getCursorScreenPoint() {\n      return { x: 0, y: 0 }\n    },\n    getDisplayNearestPoint() {\n      return {\n        bounds: { x: 0, y: 0, width: 1200, height: 800 },\n        workAreaSize: { width: 1200, height: 800 },\n      }\n    },\n  },\n  shell: {\n    openExternal() {\n      return Promise.resolve()\n    },\n    showItemInFolder() {\n      return Promise.resolve()\n    },\n  },\n  dialog: {\n    showOpenDialog() {\n      return Promise.resolve({ canceled: true, filePaths: [] })\n    },\n    showSaveDialog() {\n      return Promise.resolve({ canceled: true, filePath: undefined })\n    },\n  },\n  ipcMain: {\n    emit() {},\n    on() {},\n    handle() {},\n    removeHandler() {},\n    removeAllListeners() {},\n  },\n  ipcRenderer: {\n    invoke() {\n      return Promise.resolve(undefined)\n    },\n    on() {},\n    send() {},\n    removeAllListeners() {},\n  },\n  contextBridge: {\n    exposeInMainWorld() {},\n  },\n  nativeTheme: {\n    shouldUseDarkColors: false,\n  },\n}\n\nvi.mock('electron', () => electronMock)\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"module\": \"commonjs\",\n    \"moduleResolution\": \"node\",\n    \"experimentalDecorators\": true,\n    \"importHelpers\": true,\n    \"jsx\": \"react-jsx\",\n    \"esModuleInterop\": true,\n    \"sourceMap\": true,\n    \"baseUrl\": \"./\",\n    \"strict\": true,\n    \"paths\": {\n      \"@root/*\": [\n        \"./*\"\n      ],\n      \"@common/*\": [\n        \"src/common/*\"\n      ],\n      \"@main/*\": [\n        \"src/main/*\"\n      ],\n      \"@renderer/*\": [\n        \"src/renderer/*\"\n      ],\n      \"@src/*\": [\n        \"src/*\"\n      ],\n      \"@/*\": [\n        \"src/*\"\n      ],\n      \"@styles/*\": [\n        \"src/renderer/styles/*\"\n      ],\n      \"@assets/*\": [\n        \"assets/*\"\n      ]\n    },\n    \"allowSyntheticDefaultImports\": true,\n    \"declaration\": true,\n    \"resolveJsonModule\": true\n  },\n  \"include\": [\n    \"mock/**/*\",\n    \"src/**/*\",\n    \"config/**/*\",\n    \".umirc.ts\",\n    \"typings.d.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"es\",\n    \"dist\",\n    \"typings\",\n    \"**/__test__\",\n    \"test\",\n    \"docs\",\n    \"tests\"\n  ]\n}\n"
  },
  {
    "path": "typings.d.ts",
    "content": "declare module '*.css'\ndeclare module '*.scss'\ndeclare module '*.png'\ndeclare module '*.svg' {\n  const url: string\n  export default url\n}\n\ndeclare namespace React {\n  interface ReactSVG {\n    [elementName: string]: SVGProps<SVGElement>\n  }\n}\n\ndeclare module '*.json' {\n  const value: any\n  export default value\n}\n"
  },
  {
    "path": "vite.main.config.mts",
    "content": "import * as fs from 'fs/promises'\nimport * as path from 'path'\nimport { defineConfig } from 'vite'\nimport tsconfigPaths from 'vite-tsconfig-paths'\n\nconst copyMainAssetsPlugin = () => ({\n  name: 'copy-main-assets',\n  apply: 'build' as const,\n  async closeBundle() {\n    const outAssetsDir = path.resolve(__dirname, 'build', 'assets')\n    const srcDirs = [path.resolve(__dirname, 'assets'), path.resolve(__dirname, 'src', 'assets')]\n\n    await fs.mkdir(outAssetsDir, { recursive: true })\n\n    for (const srcDir of srcDirs) {\n      let entries: string[] = []\n      try {\n        entries = await fs.readdir(srcDir)\n      } catch {\n        continue\n      }\n\n      for (const entry of entries) {\n        if (!entry.endsWith('.png')) {\n          continue\n        }\n        await fs.copyFile(path.join(srcDir, entry), path.join(outAssetsDir, entry))\n      }\n    }\n  },\n})\n\nexport default defineConfig({\n  plugins: [\n    tsconfigPaths(),\n    copyMainAssetsPlugin(),\n  ],\n  // root: path.join(__dirname, 'src', 'main'),\n  base: './',\n  build: {\n    rollupOptions: {\n      input: {\n        main: path.join(__dirname, 'src', 'main', 'main.ts'),\n        preload: path.join(__dirname, 'src', 'main', 'preload.ts'),\n        // renderer: path.join(__dirname, 'src', 'renderer', 'index.html'),\n      },\n    },\n    lib: {\n      entry: path.join(__dirname, 'src', 'main', 'main.ts'),\n      name: 'main',\n      formats: ['cjs'],\n      // fileName: (format) => `main.${format}.js`,\n      fileName: (format) => `main.js`,\n    },\n    outDir: path.join(__dirname, 'build'),\n    minify: true,\n    ssr: true,\n    emptyOutDir: false,\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, 'src'),\n      '@root': path.resolve(__dirname),\n      '@assets': path.resolve(__dirname, 'assets'),\n      '@src': path.resolve(__dirname, 'src'),\n      '@common': path.resolve(__dirname, 'src', 'common'),\n      '@main': path.resolve(__dirname, 'src', 'main'),\n      '@renderer': path.resolve(__dirname, 'src', 'renderer'),\n    },\n  },\n})\n"
  },
  {
    "path": "vite.render.config.mts",
    "content": "import react from '@vitejs/plugin-react'\nimport * as path from 'path'\nimport { defineConfig, normalizePath } from 'vite'\nimport { viteStaticCopy } from 'vite-plugin-static-copy'\nimport tsconfigPaths from 'vite-tsconfig-paths'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    tsconfigPaths(),\n    react(),\n    viteStaticCopy({\n      targets: [\n        {\n          src: normalizePath(path.resolve(__dirname, 'src', 'assets', 'logoTemplate*.png')),\n          dest: 'assets',\n        },\n      ],\n    }),\n  ],\n  base: './',\n  root: path.join(__dirname, 'src', 'renderer'),\n  build: {\n    rollupOptions: {\n      input: {\n        renderer: path.join(__dirname, 'src', 'renderer', 'index.html'),\n      },\n    },\n    outDir: path.join(__dirname, 'build'),\n    minify: true,\n    ssr: false,\n    emptyOutDir: false,\n  },\n  css: {\n    modules: {\n      generateScopedName: '[name]__[local]___[hash:base64:5]',\n    },\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, 'src'),\n      '@root': path.resolve(__dirname),\n      '@assets': path.resolve(__dirname, 'assets'),\n      '@src': path.resolve(__dirname, 'src'),\n      '@common': path.resolve(__dirname, 'src', 'common'),\n      '@main': path.resolve(__dirname, 'src', 'main'),\n      '@renderer': path.resolve(__dirname, 'src', 'renderer'),\n      '@styles': path.resolve(__dirname, 'src', 'renderer', 'styles'),\n    },\n  },\n  server: {\n    host: '127.0.0.1',\n    port: 8220,\n  },\n})\n"
  },
  {
    "path": "vitest.config.mts",
    "content": "import { defineConfig } from 'vitest/config'\nimport tsconfigPaths from 'vite-tsconfig-paths'\n\nexport default defineConfig({\n  plugins: [ tsconfigPaths() ],\n  test: {\n    environment: 'node',\n    fileParallelism: false,\n    include: [ 'test/**/*.test.ts', 'src/**/*.test.ts' ],\n    setupFiles: [ './test/setup.ts' ],\n  },\n})\n"
  }
]