Repository: oldj/SwitchHosts Branch: master Commit: 8f9941a0e0c3 Files: 248 Total size: 468.2 KB Directory structure: gitextract_xbk83j9u/ ├── .gitattributes ├── .github/ │ └── issue_template.md ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── README.pl.md ├── README.zh_hans.md ├── README.zh_hant.md ├── alfred/ │ ├── Readme.txt │ └── info.plist ├── app/ │ └── package.json ├── assets/ │ └── app.icns ├── package.json ├── scripts/ │ ├── entitlements.mac.plist │ ├── hooks/ │ │ ├── artifactBuildCompleted.mjs │ │ └── notarize-options.mjs │ ├── libs/ │ │ ├── build-env.mjs │ │ ├── build-log.mjs │ │ ├── build-plan.mjs │ │ ├── build-state.mjs │ │ └── my-exec.mjs │ ├── make.mjs │ ├── release-config.mjs │ ├── upload-diagnostics.mjs │ ├── upload-progress.mjs │ ├── upload-release.mjs │ ├── vars.mjs │ └── version-up.mjs ├── src/ │ ├── common/ │ │ ├── acknowledgements.ts │ │ ├── constants.ts │ │ ├── data.d.ts │ │ ├── default_configs.ts │ │ ├── events.ts │ │ ├── hostsFn.ts │ │ ├── i18n/ │ │ │ ├── index.ts │ │ │ └── languages/ │ │ │ ├── de.ts │ │ │ ├── en.ts │ │ │ ├── fr.ts │ │ │ ├── ja.ts │ │ │ ├── ko.ts │ │ │ ├── pl.ts │ │ │ ├── tr.ts │ │ │ ├── zh-hant.ts │ │ │ └── zh.ts │ │ ├── newlines.ts │ │ ├── normalize.ts │ │ ├── tree.ts │ │ ├── types.d.ts │ │ ├── update.ts │ │ └── utils/ │ │ └── wait.ts │ ├── main/ │ │ ├── actions/ │ │ │ ├── checkUpdate.ts │ │ │ ├── closeMainWindow.ts │ │ │ ├── cmd/ │ │ │ │ ├── changeDataDir.ts │ │ │ │ ├── clearHistory.ts │ │ │ │ ├── deleteHistory.ts │ │ │ │ ├── focusMainWindow.ts │ │ │ │ ├── getHistoryList.ts │ │ │ │ ├── toggleDevTools.ts │ │ │ │ └── tryToRun.ts │ │ │ ├── config/ │ │ │ │ ├── all.ts │ │ │ │ ├── get.ts │ │ │ │ ├── set.ts │ │ │ │ └── update.ts │ │ │ ├── downloadUpdate.ts │ │ │ ├── find/ │ │ │ │ ├── addHistory.ts │ │ │ │ ├── addReplaceHistory.ts │ │ │ │ ├── findBy.ts │ │ │ │ ├── findPositionsInContent.ts │ │ │ │ ├── getHistory.ts │ │ │ │ ├── getReplaceHistory.ts │ │ │ │ ├── setHistory.ts │ │ │ │ ├── setReplaceHistory.ts │ │ │ │ ├── show.ts │ │ │ │ └── splitContent.ts │ │ │ ├── getBasicData.ts │ │ │ ├── getDataDir.ts │ │ │ ├── getDefaultDataDir.ts │ │ │ ├── hosts/ │ │ │ │ ├── deleteHistory.ts │ │ │ │ ├── getContent.ts │ │ │ │ ├── getHistoryList.ts │ │ │ │ ├── getPathOfSystemHostsPath.ts │ │ │ │ ├── getSystemHosts.ts │ │ │ │ ├── refresh.ts │ │ │ │ ├── setContent.ts │ │ │ │ └── setSystemHosts.ts │ │ │ ├── index.ts │ │ │ ├── installUpdate.ts │ │ │ ├── list/ │ │ │ │ ├── getContentOfList.ts │ │ │ │ ├── getItem.ts │ │ │ │ ├── getList.ts │ │ │ │ ├── moveItemToTrashcan.ts │ │ │ │ ├── moveManyToTrashcan.ts │ │ │ │ └── setList.ts │ │ │ ├── migrate/ │ │ │ │ ├── checkIfMigration.ts │ │ │ │ ├── export.ts │ │ │ │ ├── import.ts │ │ │ │ ├── importFromUrl.ts │ │ │ │ ├── importV3Data.ts │ │ │ │ └── migrateData.ts │ │ │ ├── openUrl.ts │ │ │ ├── ping.ts │ │ │ ├── quit.ts │ │ │ ├── showItemInFolder.ts │ │ │ ├── trashcan/ │ │ │ │ ├── clear.ts │ │ │ │ ├── deleteItem.ts │ │ │ │ ├── getList.ts │ │ │ │ └── restoreItem.ts │ │ │ └── updateTrayTitle.ts │ │ ├── core/ │ │ │ ├── agent.ts │ │ │ ├── getI18N.ts │ │ │ ├── message.ts │ │ │ ├── popupMenu.ts │ │ │ └── updater.ts │ │ ├── data/ │ │ │ └── index.ts │ │ ├── http/ │ │ │ ├── api/ │ │ │ │ ├── index.ts │ │ │ │ ├── list.ts │ │ │ │ └── toggle.ts │ │ │ └── index.ts │ │ ├── libs/ │ │ │ ├── cron.ts │ │ │ ├── getConfigDir.ts │ │ │ ├── getDataDir.ts │ │ │ ├── getIndex.ts │ │ │ ├── isDev.ts │ │ │ ├── request.ts │ │ │ ├── safePSWD.ts │ │ │ └── tracer.ts │ │ ├── main.ts │ │ ├── preload.ts │ │ ├── types.d.ts │ │ ├── ui/ │ │ │ ├── checkSystemLocale.ts │ │ │ ├── find.ts │ │ │ ├── menu.ts │ │ │ └── tray/ │ │ │ ├── index.ts │ │ │ └── window.ts │ │ └── utils/ │ │ └── fs2.ts │ ├── renderer/ │ │ ├── common/ │ │ │ └── PageWrapper.tsx │ │ ├── components/ │ │ │ ├── About/ │ │ │ │ ├── AboutContent.module.scss │ │ │ │ ├── AboutContent.tsx │ │ │ │ ├── index.module.scss │ │ │ │ └── index.tsx │ │ │ ├── BrowserLink.tsx │ │ │ ├── EditHostsInfo.module.scss │ │ │ ├── EditHostsInfo.tsx │ │ │ ├── Editor/ │ │ │ │ ├── HostsEditor.module.scss │ │ │ │ ├── HostsEditor.tsx │ │ │ │ ├── hosts_highlight.test.ts │ │ │ │ └── hosts_highlight.ts │ │ │ ├── History.module.scss │ │ │ ├── History.tsx │ │ │ ├── HostsViewer.module.scss │ │ │ ├── HostsViewer.tsx │ │ │ ├── ItemIcon.tsx │ │ │ ├── Lang.tsx │ │ │ ├── LeftPanel/ │ │ │ │ ├── SystemHostsItem.module.scss │ │ │ │ ├── SystemHostsItem.tsx │ │ │ │ ├── Trashcan.module.scss │ │ │ │ ├── Trashcan.tsx │ │ │ │ ├── TrashcanItem.module.scss │ │ │ │ ├── TrashcanItem.tsx │ │ │ │ ├── index.module.scss │ │ │ │ └── index.tsx │ │ │ ├── List/ │ │ │ │ ├── ListItem.module.scss │ │ │ │ ├── ListItem.tsx │ │ │ │ ├── index.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Loading.module.scss │ │ │ ├── Loading.tsx │ │ │ ├── MainPanel/ │ │ │ │ ├── index.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Pref/ │ │ │ │ ├── Advanced.tsx │ │ │ │ ├── Commands.tsx │ │ │ │ ├── CommandsHistory.tsx │ │ │ │ ├── General.tsx │ │ │ │ ├── Proxy.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.scss │ │ │ ├── SetWriteMode.module.scss │ │ │ ├── SetWriteMode.tsx │ │ │ ├── SideDrawer.tsx │ │ │ ├── StatusBar.module.scss │ │ │ ├── StatusBar.tsx │ │ │ ├── SudoPasswordInput.module.scss │ │ │ ├── SudoPasswordInput.tsx │ │ │ ├── SwitchButton.module.scss │ │ │ ├── SwitchButton.tsx │ │ │ ├── TopBar/ │ │ │ │ ├── ConfigMenu.module.scss │ │ │ │ ├── ConfigMenu.tsx │ │ │ │ ├── ImportFromUrl.module.scss │ │ │ │ ├── ImportFromUrl.tsx │ │ │ │ ├── index.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Transfer.module.scss │ │ │ ├── Transfer.tsx │ │ │ ├── Tree/ │ │ │ │ ├── Node.tsx │ │ │ │ ├── Tree.tsx │ │ │ │ ├── fn.ts │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ └── UpdateDialog.tsx │ │ ├── core/ │ │ │ ├── PopupMenu.ts │ │ │ ├── agent.ts │ │ │ └── useOnBroadcast.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── models/ │ │ │ ├── useConfigs.ts │ │ │ ├── useHostsData.ts │ │ │ └── useI18n.ts │ │ ├── pages/ │ │ │ ├── find.module.scss │ │ │ ├── find.tsx │ │ │ ├── index.module.scss │ │ │ ├── index.tsx │ │ │ ├── tray.module.scss │ │ │ └── tray.tsx │ │ ├── stores/ │ │ │ ├── configs.ts │ │ │ ├── hosts_data.ts │ │ │ └── i18n.ts │ │ ├── styles/ │ │ │ ├── common.scss │ │ │ ├── fn.scss │ │ │ ├── global.scss │ │ │ ├── scrollbar.scss │ │ │ ├── themes/ │ │ │ │ ├── dark.scss │ │ │ │ └── light.scss │ │ │ └── var.scss │ │ └── utils/ │ │ └── css-var.ts │ └── version.json ├── test/ │ ├── _base.ts │ ├── common/ │ │ ├── hostsFn.test.ts │ │ ├── mock/ │ │ │ ├── normalize.001.input.hosts │ │ │ └── normalize.001.output.hosts │ │ ├── newlines.test.ts │ │ └── normalize.test.ts │ ├── main/ │ │ ├── basic.test.ts │ │ ├── findInContent.test.ts │ │ ├── http.test.ts │ │ ├── setSystemHosts.test.ts │ │ ├── splitContent.test.ts │ │ └── trashcan.test.ts │ ├── scripts/ │ │ ├── upload-diagnostics.test.ts │ │ └── upload-progress.test.ts │ └── setup.ts ├── tsconfig.json ├── typings.d.ts ├── vite.main.config.mts ├── vite.render.config.mts └── vitest.config.mts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ legacy/* linguist-vendored scripts/alfred/workflow/* linguist-vendored *.css linguist-language=javascript ================================================ FILE: .github/issue_template.md ================================================ ### System (Mac, Windows 10/11, Linux) / 操作系统 ### SwitchHosts Version / SwitchHosts 版本 ### Description / 描述 ### How to reproduce / 重现步骤 ================================================ FILE: .gitignore ================================================ .idea # dependencies node_modules npm-debug.log* yarn-error.log yarn.lock package-lock.json # production build dist # misc .DS_Store # umi src/.umi src/.umi-production src/.umi-test src/renderer/.umi src/renderer/.umi-production src/renderer/.umi-test src/renderer/dist .env.local tmp test/tmp .env *.provisionprofile ================================================ FILE: .prettierignore ================================================ src/renderer/.umi src/renderer/.umi-production ================================================ FILE: .prettierrc.json ================================================ { "arrowParens": "always", "bracketSpacing": true, "embeddedLanguageFormatting": "auto", "endOfLine": "lf", "htmlWhitespaceSensitivity": "css", "insertPragma": false, "jsxBracketSameLine": false, "jsxSingleQuote": false, "printWidth": 100, "proseWrap": "preserve", "quoteProps": "as-needed", "requirePragma": false, "semi": false, "singleQuote": true, "tabWidth": 2, "trailingComma": "all", "useTabs": false } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { "source.organizeImports": "explicit" }, "files.exclude": { "**/.idea": true, "**/build": true, "**/dist": true, "**/node_modules": true }, "search.exclude": { "**/.idea": true, "**/build": true, "**/dist": true, "**/node_modules": true }, "files.watcherExclude": { "**/.idea/**": true, "**/build/**": true, "**/dist/**": true, "**/node_modules/**": true } } ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2011-2025 oldj Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
Special thanks to:
Warp sponsorship ### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts) [Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)
--- # SwitchHosts - [Polski](README.pl.md) - [简体中文](README.zh_hans.md) - [繁體中文](README.zh_hant.md) Homepage: [https://switchhosts.vercel.app](https://switchhosts.vercel.app) SwitchHosts 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. ## Screenshot Capture ## Features - Switch hosts quickly - Syntax highlight - Remote hosts - Switch from system tray ## Install ### Download You can download the source code and build it yourself, or download the built version from following links: - [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases) You can also install the built version using the [package manager Chocolatey](https://community.chocolatey.org/packages/switchhosts): ```powershell choco install switchhosts ``` ## Backup SwitchHosts stores data at `~/.SwitchHosts` (Or folder `.SwitchHosts` under the current user's home path on Windows), the `~/.SwitchHosts/data` folder contains data, while the `~/.SwitchHosts/config` folder contains various configuration information. ## Develop and build ### Development - Install [Node.js](https://nodejs.org/) - Change to the folder `./`, run `npm install` to install dependented libraries - Run `npm run dev` to start the development server - Then run `npm run start` to start the app for developing or debuging ### Build and package - It is recommended to use [electron-builder](https://github.com/electron-userland/electron-builder) for packaging - Go to the `./` folder - Run `npm run build` - Run `npm run make`, if everything goes well, the packaged files will be in the `./dist` folder. - This command may take several minutes to finish when you run it the first time, as it needs time to download dependent files. You can download the dependencies manually [here](https://github.com/electron/electron/releases), or [Taobao mirror](https://npmmirror.com/mirrors/electron/), then save the files to `~/.electron` . You can check the [Electron Docs](http://electron.atom.io/docs/) for more infomation. ```bash # build npm run build # make npm run make # the packed files will be in ./dist ``` ## Copyright SwitchHosts is a free and open source software, it is released under the [Apache License](./LICENSE). ================================================ FILE: README.pl.md ================================================
Special thanks to:
Warp sponsorship ### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts) [Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)
--- # SwitchHosts - [English](README.md) - [简体中文](README.zh_hans.md) - [繁體中文](README.zh_hant.md) Strona główna: [https://switchhosts.vercel.app](https://switchhosts.vercel.app) SwitchHosts 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. ## Zrzut ekranu Zrzut aplikacji ## Funkcje - Szybkie przełączanie hostów - Podświetlanie składni - Hosty zdalne - Przełączanie z paska systemowego ## Instalacja ### Pobieranie Możesz pobrać kod źródłowy i zbudować go samodzielnie, lub pobrać wbudowaną wersję z poniższych linków: - [Pobierz najnowszą wersję SwitchHosts (GitHub release)](https://github.com/oldj/SwitchHosts/releases) Możesz także zainstalować build używając [menedżera pakietów Chocolatey](https://community.chocolatey.org/packages/switchhosts): ```powershell choco install switchhosts ``` ## Kopia zapasowa SwitchHosts 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. ## Tworzenie i budowanie ### Tworzenie - Zainstaluj [Node.js](https://nodejs.org/) - Przejdź do folderu `./`, uruchom `npm install` aby zainstalować biblioteki zależności - Uruchom `npm run dev` aby uruchomić serwer deweloperski - Następnie uruchom `npm run start` aby uruchomić aplikację do tworzenia lub debugowania ### Budowanie i pakowanie - Zaleca się użycie [electron-builder](https://github.com/electron-userland/electron-builder) do budowania - Przejdź do folderu `./` - Uruchom `npm run build` - Uruchom `npm run make`, jeśli wszystko pójdzie dobrze, spakowane pliki będą w folderze `./dist`. - 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. ```bash # budowanie npm run build # pakowanie npm run make # spakowane pliki będą w ./dist ``` ## Prawa autorskie SwitchHosts to wolne i otwarte oprogramowanie, wydane na licencji [Apache License](./LICENSE). ================================================ FILE: README.zh_hans.md ================================================
Special thanks to:
Warp sponsorship ### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts) [Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)
--- # SwitchHosts - [English](README.md) - [Polski](README.pl.md) - [繁體中文](README.zh_hant.md) 项目主页:[https://switchhosts.vercel.app](https://switchhosts.vercel.app) SwitchHosts 是一个管理 hosts 文件的应用,基于 [Electron](http://electron.atom.io/)、[React](https://facebook.github.io/react/)、[Jotai](https://jotai.org/)、[Mantine](https://mantine.dev/) 等技术开发。 ## 截图 Capture ## 功能特性 - 快速切换 hosts 方案 - hosts 语法高亮 - 支持从网络加载远程 hosts 配置 - 可从系统菜单栏图标快速切换 hosts ## 安装 ### 下载 你可以下载源码并自行构建,也可以从以下地址下载已构建好的版本: - [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases) 你也可以通过 [Chocolatey 包管理器](https://community.chocolatey.org/packages/switchhosts)安装已构建好的版本: ```powershell choco install switchhosts ``` ## 数据备份 SwitchHosts 的数据文件存储于 `~/.SwitchHosts` (Windows 下存储于用户个人文件夹下的 `.SwitchHosts` 文件夹), 其中 `~/.SwitchHosts/data` 文件夹包含数据,`~/.SwitchHosts/config` 文件夹包含各项配置信息。 ## 开发以及构建 ### 开发 - 安装 [Node.js](https://nodejs.org/) - 在项目根目录 `./` 下,运行 `npm install` 命令安装依赖 - 运行 `npm run dev` 命令启动开发服务 - 运行 `npm run start` 启动 App,即可开始开发及调试 ### 构建及打包 - 推荐使用 [electron-builder](https://github.com/electron-userland/electron-builder) 进行打包 - 转到项目根目录 './' - 运行 `npm run build` - 运行 `npm run make`,如果一切顺利,可在 `./dist` 目录下找到打包后的文件 - 首次运行可能需要花费一些时间,因为需要下载相关依赖文件。你也可以从 [这儿](https://github.com/electron/electron/releases) 或者 [淘宝镜像](https://npmmirror.com/mirrors/electron/) 手动下载,并保存到 `~/.electron` 目录下。更多信息可访问 [Electron 文档](http://electron.atom.io/docs/)。 ```bash # build npm run build # make npm run make # the packed files will be in ./dist ``` ## 版权 SwitchHosts 是一个免费开源软件,基于 Apache-2.0 协议发布。 ================================================ FILE: README.zh_hant.md ================================================
Special thanks to:
Warp sponsorship ### [Warp, the intelligent terminal for developers](https://go.warp.dev/SwitchHosts) [Available for MacOS, Linux, & Windows](https://go.warp.dev/SwitchHosts)
--- # SwitchHosts - [English](README.md) - [Polski](README.pl.md) - [简体中文](README.zh_hans.md) 項目主頁:[https://switchhosts.vercel.app](https://switchhosts.vercel.app) SwitchHosts 是一個管理 hosts 檔案的應用程式,基於 [Electron](http://electron.atom.io/)、[React](https://facebook.github.io/react/)、[Jotai](https://jotai.org/)、[Mantine](https://mantine.dev/) 等技術開發。 ## 螢幕截圖 Capture ## 功能特性 - 快速切換 hosts 方案 - hosts 語法高亮顯示 - 支援從網路載入遠程 hosts 設定 - 可從系統菜單欄圖是快速切換 hosts ## 安裝 ### 下載 你可以下載原始碼並自行建置,也可以從以下網址下載已經建置好的版本: - [SwitchHosts Download Page (GitHub release)](https://github.com/oldj/SwitchHosts/releases) 你也可以通過 [Chocolatey 包管理器](https://community.chocolatey.org/packages/switchhosts)安裝已經建置好的版本: ```powershell choco install switchhosts ``` ## 數據備份 SwitchHosts 的數據文件儲存於 `~/.SwitchHosts` (Windows 下儲存使用者個人文件裡的 `.SwitchHosts` 資料夾), 其中 `~/.SwitchHosts/data` 資料夾包含數據,`~/.SwitchHosts/config` 資料夾包含各種設定。 ## 開發及建置 ### 開發 - 安裝 [Node.js](https://nodejs.org/) - 在項目根目錄 `./` 下,執行 `npm install` 指令安裝前置 - 執行 `npm run dev` 指令啟動開發服務 - 執行 `npm run start` 啟動應用程式,即可開始開發及測試 ### 打包 - 推薦使用 [electron-builder](https://github.com/electron-userland/electron-builder) 進行打包 - 轉到項目根目錄 './' - 執行 `npm run build` - 執行 `npm run make`,如果一切順利,可在 `./dist` 目錄下找到打包後的檔案 - 首次執行可能需要花費一點時間,因為需要下載相關的前置檔案。你也可以從 [這裡](https://github.com/electron/electron/releases) 手動下載,並儲存到 `~/.electron`目錄下。更多資訊可以參考 [Electron 文檔](http://electron.atom.io/docs/)。 ```bash # build npm run build # make npm run make # the packed files will be in ./dist ``` ## 版權聲明 SwitchHosts 是一個免費開源軟體,基於 Apache-2.0 開源協議發佈。 ================================================ FILE: alfred/Readme.txt ================================================ SwitchHosts! is an App for switching hosts quickly. Homepage: https://oldj.github.io/SwitchHosts/ ================================================ FILE: alfred/info.plist ================================================ bundleid switchhosts.oldj.net category Tools connections E4D66445-FD72-47A2-9EE6-7232A2BADE29 destinationuid 78D17FD5-9628-4901-A01A-511528D5FC14 modifiers 0 modifiersubtext vitoclose createdby oldj description Switch hosts quickly! disabled name SwitchHosts objects config concurrently escaping 102 script curl 'http://127.0.0.1:50761/api/toggle?id={query}' scriptargtype 0 scriptfile type 0 type alfred.workflow.action.script uid 78D17FD5-9628-4901-A01A-511528D5FC14 version 2 config alfredfiltersresults alfredfiltersresultsmatchmode 0 argumenttreatemptyqueryasnil argumenttrimmode 0 argumenttype 1 escaping 68 keyword swh queuedelaycustom 3 queuedelayimmediatelyinitially queuedelaymode 0 queuemode 1 runningsubtext loading... script function makeItems(items) { return items.map(item => { return { uid: item.id, title: item.title, arg: item.id, icon: {path: item.on ? 'on.png' : 'off.png'}, subtitle: (item.content || '').split('\n')[0], } }) } function run(argv) { const server = 'http://127.0.0.1:50761' // console.log(argv) const queryURL = $.NSURL.URLWithString(`${server}/api/list`) const requestData = $.NSData.dataWithContentsOfURL(queryURL) const requestString = $.NSString.alloc.initWithDataEncoding(requestData, $.NSUTF8StringEncoding).js let result try { result = JSON.parse(requestString) result.data = result.data.filter((item)=>item.title.includes("{query}")) } catch (e) { console.log(e) return JSON.stringify({ items: [{ uid: '0', title: `API Error: ${server}`, subtitle: 'Make sure SwitchHosts is running and the HTTP API interface is enabled.', valid: false, }] }) } if (result.success) { return JSON.stringify({ items: makeItems(result.data) }) } return JSON.stringify({ items: [{ uid: '0', title: `Error: ${result.message || result.code || 'Unknown'}`, valid: false, }] }) } scriptargtype 0 scriptfile subtext Switch hosts quickly! title Show hosts.. type 7 withspace type alfred.workflow.input.scriptfilter uid E4D66445-FD72-47A2-9EE6-7232A2BADE29 version 3 readme This workflow is for the SwitchHosts App. https://switchhosts.vercel.app uidata 78D17FD5-9628-4901-A01A-511528D5FC14 xpos 340 ypos 30 E4D66445-FD72-47A2-9EE6-7232A2BADE29 xpos 120 ypos 30 variablesdontexport version 1.3.0 webaddress https://switchhosts.vercel.app ================================================ FILE: app/package.json ================================================ { "name": "switchhosts", "productName": "SwitchHosts", "version": "4.3.0.6137", "description": "Switch hosts quickly!", "main": "./main.js", "author": { "name": "oldj", "email": "oldj.wu@gmail.com", "url": "https://github.com/oldj/SwitchHosts" }, "homepage": "https://switchhosts.vercel.app", "scripts": {}, "license": "Apache-2.0", "dependencies": {} } ================================================ FILE: package.json ================================================ { "private": true, "scripts": { "start": "cross-env NODE_ENV=development electron ./build/main.js", "pretest": "rimraf ./test/tmp", "test": "vitest --config ./vitest.config.mts --watch=false", "typecheck": "tsc --noEmit", "clean:dist": "rimraf ./dist/*", "clean:build": "rimraf ./build/*", "dev": "npm run clean:build && concurrently --kill-others-on-fail --prefix-colors auto --names main,renderer \"npm run dev:main\" \"npm run dev:renderer\"", "dev:main": "vite build --watch --config ./vite.main.config.mts", "dev:renderer": "vite --config ./vite.render.config.mts", "version:up": "node scripts/version-up.mjs", "_build": "npm run version:up && npm run _build:release", "_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\"", "build:main": "cross-env NODE_ENV=production vite build --config ./vite.main.config.mts", "build:renderer": "cross-env NODE_ENV=production vite build --config ./vite.render.config.mts", "build": "npm run _build", "build:release": "npm run _build:release", "make": "node scripts/make.mjs", "make:dev": "cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=dev npm run make", "make:linux": "cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=linux npm run make", "make:win": "cross-env SKIP_NOTARIZATION=1 cross-env MAKE_FOR=win npm run make", "release:upload": "node ./scripts/upload-release.mjs", "release:upload:dry-run": "cross-env DRY_RUN=1 node ./scripts/upload-release.mjs", "publish": "npm run release:upload" }, "dependencies": { "@hono/node-server": "^1.19.11", "axios": "1.13.6", "compare-versions": "6.1.1", "dayjs": "1.11.19", "electron-updater": "6.8.3", "electron-window-state": "5.0.3", "hono": "^4.12.7", "lodash": "4.17.23", "md5": "2.3.0", "md5-file": "5.0.0", "mkdirp": "3.0.1", "potdb": "2.6.6", "tslib": "2.8.1", "uuid": "13.0.0" }, "devDependencies": { "@electron/notarize": "^3.1.1", "@mantine/core": "^8.3.16", "@mantine/hooks": "^8.3.16", "@tabler/icons-react": "3.38.0", "@types/assert": "1.5.11", "@types/lodash": "4.17.24", "@types/md5": "2.3.6", "@types/mkdirp": "2.0.0", "@types/node": "25.3.3", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", "@types/uuid": "11.0.0", "@vitejs/plugin-react": "^5.1.4", "ahooks": "3.9.6", "chalk": "^5.6.2", "clsx": "2.1.1", "codejar": "^4.3.0", "codejar-linenumbers": "^1.0.1", "concurrently": "9.2.1", "cross-env": "10.1.0", "dotenv": "17.3.1", "electron": "39.5.1", "electron-builder": "26.8.1", "execa": "9.6.1", "fs-extra": "11.3.3", "jotai": "2.18.0", "prettier": "3.8.1", "pretty-bytes": "7.1.0", "progress": "^2.0.3", "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "5.6.0", "react-router": "7.13.1", "rimraf": "^6.1.3", "sass": "1.97.3", "smooth-scroll-into-view-if-needed": "2.0.2", "typescript": "5.9.3", "vite": "7.3.1", "vite-plugin-static-copy": "3.2.0", "vite-tsconfig-paths": "6.1.1", "vitest": "^3.2.4" } } ================================================ FILE: scripts/entitlements.mac.plist ================================================ com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-jit ================================================ FILE: scripts/hooks/artifactBuildCompleted.mjs ================================================ import { notarize } from '@electron/notarize' import path from 'node:path' import { isEnvFlagEnabled } from '../libs/build-env.mjs' import { getNotarizeOptions } from './notarize-options.mjs' export default async function artifactBuildCompleted(context) { const { file, packager } = context if (!file || path.extname(file) !== '.dmg') { return } if (packager?.platform?.name !== 'mac') { return } if (process.env.MAKE_FOR === 'dev' || isEnvFlagEnabled(process.env.SKIP_NOTARIZATION)) { console.log(`skip notarization for ${path.basename(file)}.`) return } const options = await getNotarizeOptions(file) if (!options) { throw new Error(`Notarization credentials are missing for ${path.basename(file)}.`) } console.log('in artifactBuildCompleted, notarize dmg...') console.log(`dmgPath: ${file}`) await notarize(options) console.log(`Notarize done for ${path.basename(file)}.`) } ================================================ FILE: scripts/hooks/notarize-options.mjs ================================================ import { execFile } from 'node:child_process' import { isEnvFlagEnabled } from '../libs/build-env.mjs' function getPasswordFromKeychain(account, service) { return new Promise((resolve, reject) => { execFile( 'security', ['find-generic-password', '-a', account, '-s', service, '-w'], (error, stdout, stderr) => { if (error) { reject(new Error(stderr || error.message)) return } resolve(stdout.trim()) }, ) }) } export async function prepareNotarizeEnv(env = process.env) { if (isEnvFlagEnabled(env.SKIP_NOTARIZATION) || env.MAKE_FOR === 'dev') { return env } if (!env.APPLE_TEAM_ID && env.TEAM_ID) { env.APPLE_TEAM_ID = env.TEAM_ID } const hasCompleteCredentials = !!env.APPLE_KEYCHAIN_PROFILE || (!!env.APPLE_API_KEY && !!env.APPLE_API_KEY_ID && !!env.APPLE_API_ISSUER) || (!!env.APPLE_ID && !!env.APPLE_APP_SPECIFIC_PASSWORD && !!env.APPLE_TEAM_ID) if (hasCompleteCredentials || !env.APPLE_ID) { return env } try { env.APPLE_APP_SPECIFIC_PASSWORD = await getPasswordFromKeychain( env.APPLE_ID, `Apple Notarize: ${env.APPLE_ID}`, ) } catch (error) { console.log(`Legacy notarization keychain lookup skipped: ${error.message}`) } return env } export function hasNotarizeCredentials(env = process.env) { return Boolean( env.APPLE_KEYCHAIN_PROFILE || (env.APPLE_API_KEY && env.APPLE_API_KEY_ID && env.APPLE_API_ISSUER) || (env.APPLE_ID && env.APPLE_APP_SPECIFIC_PASSWORD && env.APPLE_TEAM_ID), ) } export async function getNotarizeOptions(appPath, env = process.env) { await prepareNotarizeEnv(env) const { APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: appleApiKeyId, APPLE_API_ISSUER: appleApiIssuer, APPLE_ID: appleId, APPLE_APP_SPECIFIC_PASSWORD: appleIdPassword, APPLE_KEYCHAIN: keychain, APPLE_KEYCHAIN_PROFILE: keychainProfile, APPLE_TEAM_ID: teamId, } = env const tool = 'notarytool' if (appleId || appleIdPassword) { if (!appleId) { throw new Error('APPLE_ID env var needs to be set') } if (!appleIdPassword) { throw new Error('APPLE_APP_SPECIFIC_PASSWORD env var needs to be set') } if (!teamId) { throw new Error('APPLE_TEAM_ID env var needs to be set') } return { tool, appPath, appleId, appleIdPassword, teamId } } if (appleApiKey || appleApiKeyId || appleApiIssuer) { if (!appleApiKey || !appleApiKeyId || !appleApiIssuer) { throw new Error('Env vars APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER need to be set') } return { tool, appPath, appleApiKey, appleApiKeyId, appleApiIssuer } } if (keychainProfile) { return { tool, appPath, keychainProfile, ...(keychain ? { keychain } : {}), } } return null } ================================================ FILE: scripts/libs/build-env.mjs ================================================ export function hasValue(value) { return typeof value === 'string' ? value.trim() !== '' : Boolean(value) } export function getFirstConfiguredEnv(env, names) { for (const name of names) { if (hasValue(env[name])) { return env[name].trim() } } return null } export function isEnvFlagEnabled(value) { if (!hasValue(value)) { return false } if (typeof value === 'string') { const normalized = value.trim().toLowerCase() if (['0', 'false', 'no', 'off', ''].includes(normalized)) { return false } if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true } } return Boolean(value) } ================================================ FILE: scripts/libs/build-log.mjs ================================================ import chalk from 'chalk' import dayjs from 'dayjs' export const PLATFORM_LABELS = { mac: 'macOS', win: 'Windows', linux: 'Linux', } const PLATFORM_COLORS = { mac: chalk.magenta, win: chalk.cyan, linux: chalk.green, } export function formatTimestamp(date = new Date()) { return dayjs(date).format('YYYY-MM-DD HH:mm:ss') } function formatLogLine(message) { return `${formatTimestamp()} ${message}` } export function formatDuration(ms) { const totalSeconds = Math.floor(ms / 1000) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 if (hours > 0) { return `${hours}h ${minutes}m ${seconds}s` } if (minutes > 0) { return `${minutes}m ${seconds}s` } return `${seconds}s` } export function logBanner(message) { console.log(chalk.bold.blue(`\n${formatLogLine(`=== ${message} ===`)}`)) } export function logStep(message) { console.log(chalk.blue(formatLogLine(`-> ${message}`))) } export function logSuccess(message) { console.log(chalk.green(formatLogLine(`✓ ${message}`))) } export function logWarning(message) { console.log(chalk.yellow(formatLogLine(`! ${message}`))) } export function logPlatform(platform, message) { const color = PLATFORM_COLORS[platform] || chalk.white const label = PLATFORM_LABELS[platform] || platform console.log(color(formatLogLine(`[${label}] ${message}`))) } ================================================ FILE: scripts/libs/build-plan.mjs ================================================ import { Arch } from 'builder-util' import path from 'node:path' import { PLATFORM_LABELS, formatDuration, logBanner, logPlatform } from './build-log.mjs' export const PLATFORM_ORDER = ['mac', 'win', 'linux'] function resolvePlatformName(name) { const map = { darwin: 'mac', linux: 'linux', mac: 'mac', win: 'win', win32: 'win', windows: 'win', } return map[name] || null } function formatArch(arch) { if (arch == null) { return 'unknown' } return Arch[arch] || String(arch) } export function getBuildPlan(makeFor, targetPlatformsConfigs) { if (makeFor === 'dev') { return [{ platform: 'mac', targets: targetPlatformsConfigs.mac.mac }] } if (makeFor === 'mac') { return [{ platform: 'mac', targets: targetPlatformsConfigs.mac.mac }] } if (makeFor === 'win') { return [{ platform: 'win', targets: targetPlatformsConfigs.win.win }] } if (makeFor === 'linux') { return [{ platform: 'linux', targets: targetPlatformsConfigs.linux.linux }] } return PLATFORM_ORDER.map((platform) => ({ platform, targets: targetPlatformsConfigs.all[platform], })) } export function createBuildTracker({ plan, compression, macBuildState, winBuildState, artifactBuildCompletedHook }) { // Track platform timing through electron-builder hooks while the outer loop // runs one platform build at a time for cleaner, non-interleaved logging. const stats = new Map( plan.map(({ platform, targets }) => [ platform, { targets, startedAt: 0, finishedAt: 0, }, ]), ) function getStat(platform) { if (!stats.has(platform)) { stats.set(platform, { targets: [], startedAt: 0, finishedAt: 0, }) } return stats.get(platform) } function markStarted(platform) { const stat = getStat(platform) if (!stat.startedAt) { stat.startedAt = Date.now() logBanner(`Build ${PLATFORM_LABELS[platform]}`) logPlatform(platform, `targets: ${stat.targets.join(', ')}`) logPlatform(platform, `compression: ${compression}`) if (platform === 'mac') { logPlatform(platform, `code signing: ${macBuildState.sign ? 'enabled' : 'disabled'}`) logPlatform(platform, `notarization: ${macBuildState.notarize ? 'enabled' : 'disabled'}`) } else if (platform === 'win') { logPlatform(platform, `code signing: ${winBuildState.sign ? 'enabled' : 'disabled'}`) } else { logPlatform(platform, 'notarization: disabled') } } return stat } function markFinished(platform) { const stat = getStat(platform) stat.finishedAt = Date.now() return stat } return { hooks: { beforePack(context) { const platform = resolvePlatformName(context.electronPlatformName) if (!platform) { return } markStarted(platform) // beforePack fires for each arch-specific app bundle preparation. logPlatform(platform, `packaging app bundle for ${formatArch(context.arch)}...`) }, afterPack(context) { const platform = resolvePlatformName(context.electronPlatformName) if (!platform) { return } markFinished(platform) logPlatform(platform, `app bundle ready for ${formatArch(context.arch)}`) }, async artifactBuildCompleted(context) { const platform = resolvePlatformName(context.packager?.platform?.name) if (platform) { markStarted(platform) } // Reuse the DMG notarization hook from the packaging config so logging and // timing stay in one place while the notarization logic remains isolated. const artifactFile = context.file || '' const isMacDmg = platform === 'mac' && path.extname(artifactFile) === '.dmg' if (isMacDmg && !macBuildState.notarize) { logPlatform(platform, `skipping dmg notarization: ${path.basename(artifactFile)}`) } else { await artifactBuildCompletedHook(context) } if (!platform) { return } markFinished(platform) const targetName = context.target?.name || path.extname(artifactFile).slice(1) logPlatform(platform, `artifact ready (${targetName}): ${path.basename(artifactFile)}`) }, }, printSummary() { logBanner('Build Summary') for (const { platform } of plan) { const stat = getStat(platform) const elapsed = stat.startedAt && stat.finishedAt ? stat.finishedAt - stat.startedAt : 0 logPlatform(platform, `elapsed: ${elapsed > 0 ? formatDuration(elapsed) : 'n/a'}`) } }, } } ================================================ FILE: scripts/libs/build-state.mjs ================================================ import { hasNotarizeCredentials, prepareNotarizeEnv } from '../hooks/notarize-options.mjs' import { getFirstConfiguredEnv, hasValue, isEnvFlagEnabled } from './build-env.mjs' function hasSigningIdentityEnv(env = process.env) { return hasValue(env.IDENTITY) } function describeNotarizationSetup(env = process.env) { if (hasValue(env.APPLE_KEYCHAIN_PROFILE)) { return 'APPLE_KEYCHAIN_PROFILE' } if (hasValue(env.APPLE_API_KEY) || hasValue(env.APPLE_API_KEY_ID) || hasValue(env.APPLE_API_ISSUER)) { return 'APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER' } if ( hasValue(env.APPLE_ID) || hasValue(env.APPLE_APP_SPECIFIC_PASSWORD) || hasValue(env.APPLE_TEAM_ID) || hasValue(env.TEAM_ID) ) { return 'APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID' } return null } export async function resolveMacBuildState(plan, env = process.env) { const includesMac = plan.some(({ platform }) => platform === 'mac') const notarizationForcedOff = env.MAKE_FOR === 'dev' || isEnvFlagEnabled(env.SKIP_NOTARIZATION) const state = { includesMac, sign: false, notarize: false, logLevel: 'step', message: 'macOS signing configuration check skipped', } if (!includesMac) { state.message = 'skipping macOS signing configuration check' return state } await prepareNotarizeEnv(env) const hasIdentity = hasSigningIdentityEnv(env) const hasNotary = hasNotarizeCredentials(env) const configuredNotarySetup = describeNotarizationSetup(env) if (notarizationForcedOff) { if (hasIdentity) { state.sign = true state.logLevel = 'success' state.message = `macOS code signing enabled via IDENTITY; notarization disabled by ${ env.MAKE_FOR === 'dev' ? 'MAKE_FOR=dev' : 'SKIP_NOTARIZATION' }` } else { state.logLevel = 'warning' state.message = 'IDENTITY is not configured; falling back to unsigned macOS artifacts because notarization is disabled.' } return state } if (hasIdentity && hasNotary) { state.sign = true state.notarize = true state.logLevel = 'success' state.message = `macOS signing and notarization enabled via IDENTITY + ${configuredNotarySetup}` return state } const missing = [] if (!hasIdentity) { missing.push('IDENTITY') } if (!hasNotary) { missing.push( 'APPLE_KEYCHAIN_PROFILE or APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER or APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID', ) } state.logLevel = 'warning' state.message = `macOS signing/notarization config is missing or incomplete (${missing.join(', ')}). ` + 'Falling back to unsigned and unnotarized macOS artifacts.' return state } export function resolveWindowsBuildState(plan, env = process.env) { const includesWin = plan.some(({ platform }) => platform === 'win') const certificateSubjectName = getFirstConfiguredEnv(env, [ 'WIN_CERTIFICATE_SUBJECT_NAME', 'WINDOWS_CERTIFICATE_SUBJECT_NAME', 'WIN_CERT_SUBJECT_NAME', ]) const configuredPublisherName = getFirstConfiguredEnv(env, ['WIN_PUBLISHER_NAME', 'WINDOWS_PUBLISHER_NAME']) const publisherName = configuredPublisherName || certificateSubjectName const state = { includesWin, sign: false, logLevel: 'step', message: 'skipping Windows signing configuration check', publisherName, certificateSubjectName, } if (!includesWin) { state.message = 'skipping Windows signing configuration check' return state } if (certificateSubjectName) { state.sign = true state.logLevel = 'success' state.message = configuredPublisherName ? 'Windows code signing enabled via WIN_CERTIFICATE_SUBJECT_NAME and WIN_PUBLISHER_NAME.' : 'Windows code signing enabled via WIN_CERTIFICATE_SUBJECT_NAME; publisherName defaults to the certificate subject name.' return state } if (configuredPublisherName) { state.logLevel = 'warning' state.message = 'Windows signing config is incomplete (missing WIN_CERTIFICATE_SUBJECT_NAME or WINDOWS_CERTIFICATE_SUBJECT_NAME or WIN_CERT_SUBJECT_NAME). ' + 'Skipping Windows code signing for this build.' return state } state.message = 'Windows code signing disabled by default. Set WIN_CERTIFICATE_SUBJECT_NAME to enable it; WIN_PUBLISHER_NAME is optional.' return state } ================================================ FILE: scripts/libs/my-exec.mjs ================================================ import { spawn } from 'node:child_process' export default function myExec(cmd, ...args) { return new Promise((resolve, reject) => { const run = spawn(cmd, args) let out = '' run.stdout.on('data', (data) => { console.log(`[stdout]: ${data.toString().trimEnd()}`) out += data.toString() }) run.stderr.on('data', (data) => { console.log(`[stderr]: ${data.toString().trimEnd()}`) }) run.on('exit', function (code) { console.log('child process exited with code ' + code.toString()) if (code === 0) { resolve(out) } else { reject(code) } }) }) } ================================================ FILE: scripts/make.mjs ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import chalk from 'chalk' import { config as loadEnv } from 'dotenv' import fse from 'fs-extra' import { createRequire } from 'node:module' import { homedir } from 'node:os' import path from 'node:path' import artifactBuildCompletedHook from './hooks/artifactBuildCompleted.mjs' import { PLATFORM_LABELS, formatDuration, logBanner, logPlatform, logStep, logSuccess, logWarning } from './libs/build-log.mjs' import { createBuildTracker, getBuildPlan } from './libs/build-plan.mjs' import { resolveMacBuildState, resolveWindowsBuildState } from './libs/build-state.mjs' import { resolveGithubRepository } from './release-config.mjs' import { APP_NAME, distDir, electronLanguages, rootDir } from './vars.mjs' loadEnv() // Use CommonJS require for local JSON/package reads so the script stays portable // across Node runtimes without relying on JSON import assertions. const require = createRequire(import.meta.url) const version = require('../src/version.json') const TARGET_PLATFORMS_CONFIGS = { mac: { mac: ['dmg:x64', 'dmg:arm64'], }, win: { win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64'], }, linux: { linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'], }, all: { mac: ['dmg:x64', 'dmg:arm64', 'zip:universal'], win: ['nsis:ia32', 'nsis:x64', 'nsis:arm64', 'portable:x64', 'zip:x64' /* , 'appx:x64'*/], linux: ['AppImage:x64', 'AppImage:arm64', 'deb:x64', 'deb:arm64'], }, } const { APP_BUNDLE_ID, IDENTITY, MAKE_FOR } = process.env const appId = APP_BUNDLE_ID || 'SwitchHosts' const fullVersion = `${version[0]}.${version[1]}.${version[2]}.${version[3]}` const publishMode = process.env.PUBLISH_POLICY || 'never' const githubRepository = resolveGithubRepository(process.env) const WINDOWS_TIMESTAMP_SERVER = 'http://rfc3161timestamp.globalsign.com/advanced' function createBuilderConfig(hooks, macBuildState, winBuildState) { // Build the full electron-builder config in one place so every entrypoint // (`make`, `make:*`) stays on the same packaging pipeline. return { ...cfgCommon, appId, productName: APP_NAME, mac: { type: 'distribution', category: 'public.app-category.productivity', icon: 'assets/app.icns', gatekeeperAssess: false, electronLanguages, identity: macBuildState.sign ? IDENTITY : null, hardenedRuntime: true, entitlements: 'scripts/entitlements.mac.plist', entitlementsInherit: 'scripts/entitlements.mac.plist', extendInfo: { ITSAppUsesNonExemptEncryption: false, CFBundleLocalizations: electronLanguages, CFBundleDevelopmentRegion: 'en', }, artifactName: '${productName}-v' + fullVersion + '-${arch}-mac.${ext}', ...(macBuildState.notarize ? {} : { notarize: false }), }, dmg: { background: 'assets/dmg-bg.png', iconSize: 160, window: { width: 600, height: 420, }, contents: [ { x: 150, y: 200, }, { x: 450, y: 200, type: 'link', path: '/Applications', }, ], sign: macBuildState.sign, artifactName: '${productName}-v' + fullVersion + '-mac-${arch}.${ext}', }, win: { icon: 'assets/icon.ico', verifyUpdateCodeSignature: winBuildState.sign, signAndEditExecutable: winBuildState.sign, // NSIS/portable targets still try to sign final `.exe` artifacts unless // we explicitly exclude them when Windows signing is disabled. ...(winBuildState.sign ? {} : { signExts: ['!.exe'] }), ...(winBuildState.sign ? { signtoolOptions: { signingHashAlgorithms: ['sha256'], publisherName: winBuildState.publisherName, certificateSubjectName: winBuildState.certificateSubjectName, timeStampServer: WINDOWS_TIMESTAMP_SERVER, rfc3161TimeStampServer: WINDOWS_TIMESTAMP_SERVER, }, } : {}), artifactName: '${productName}-v' + fullVersion + '-win-${arch}.${ext}', }, nsis: { installerIcon: 'assets/installer-icon.ico', oneClick: false, allowToChangeInstallationDirectory: true, deleteAppDataOnUninstall: false, shortcutName: 'SwitchHosts', artifactName: '${productName}-v' + fullVersion + '-win-${arch}-installer.${ext}', }, portable: { artifactName: '${productName}-v' + fullVersion + '-win-${arch}-portable.${ext}', }, linux: { icon: 'assets/app.icns', artifactName: '${productName}-v' + fullVersion + '-linux-${arch}.${ext}', category: 'Utility', synopsis: 'An App for hosts management and switching.', desktop: { entry: { Name: 'SwitchHosts', Type: 'Application', GenericName: 'An App for hosts management and switching.', }, }, }, publish: { // Keep the GitHub provider configured so electron-builder emits update metadata // for GitHub Releases, while the actual asset upload stays in scripts/upload-release.mjs. provider: 'github', owner: githubRepository.owner, repo: githubRepository.repo, releaseType: 'draft', vPrefixedTagName: true, }, beforePack: hooks.beforePack, afterPack: hooks.afterPack, artifactBuildCompleted: hooks.artifactBuildCompleted, } } if (!APP_BUNDLE_ID) { logWarning('APP_BUNDLE_ID is not set, falling back to appId "SwitchHosts".') } logStep(`APP_BUNDLE_ID: ${APP_BUNDLE_ID || '(fallback: SwitchHosts)'}`) const cfgCommon = { copyright: `Copyright © ${new Date().getFullYear()}`, buildVersion: version[3].toString(), directories: { buildResources: 'build', app: 'build', output: 'dist', }, electronDownload: { cache: path.join(homedir(), '.electron'), mirror: 'https://registry.npmmirror.com/-/binary/electron/', }, asar: true, compression: 'maximum', } const beforeMake = async () => { const t0 = Date.now() logBanner('Prepare Build Directory') // Start every package run from a clean dist directory to avoid mixing artifacts // from different target sets or previous versions. fse.removeSync(distDir) fse.ensureDirSync(distDir) logStep(`dist cleaned: ${distDir}`) const toCopy = [[path.join(rootDir, 'assets', 'app.png'), path.join(rootDir, 'build', 'assets', 'app.png')]] toCopy.map(([src, target]) => { fse.copySync(src, target) }) logStep(`copied build assets: ${toCopy.map(([src]) => path.basename(src)).join(', ')}`) let pkgBase = require(path.join(rootDir, 'package.json')) let pkgApp = require(path.join(rootDir, 'app', 'package.json')) // Refresh the app package manifest inside build/ so electron-builder always // packages the current dependency set and release version. pkgApp.name = APP_NAME pkgApp.version = version.slice(0, 3).join('.') pkgApp.dependencies = pkgBase.dependencies fse.writeFileSync( path.join(rootDir, 'build', 'package.json'), JSON.stringify(pkgApp, null, 2), 'utf-8', ) logSuccess(`build/package.json refreshed in ${formatDuration(Date.now() - t0)}`) } const afterMake = async () => { const t0 = Date.now() logBanner('Finalize Packaging') // Reserved for post-build cleanup or metadata fixes if packaging needs them later. logSuccess(`post-build steps finished in ${formatDuration(Date.now() - t0)}`) } const doMake = async () => { // Resolve the requested platform set first so every later step can log against // the same plan and timing model. const compression = MAKE_FOR === 'dev' ? 'store' : 'maximum' cfgCommon.compression = compression const plan = getBuildPlan(MAKE_FOR, TARGET_PLATFORMS_CONFIGS) const macBuildState = await resolveMacBuildState(plan) const winBuildState = resolveWindowsBuildState(plan) const tracker = createBuildTracker({ plan, compression, macBuildState, winBuildState, artifactBuildCompletedHook, }) logBanner('Build Plan') logStep(`MAKE_FOR: ${MAKE_FOR || 'all'}`) logStep(`version: ${fullVersion}`) logStep(`appId: ${appId}`) logStep(`compression: ${cfgCommon.compression}`) logStep(`publish: ${publishMode}`) logStep(`platforms: ${plan.map(({ platform }) => PLATFORM_LABELS[platform]).join(', ')}`) if (macBuildState.includesMac) { if (macBuildState.logLevel === 'warning') { logWarning(macBuildState.message) } else if (macBuildState.logLevel === 'success') { logSuccess(macBuildState.message) } else { logStep(macBuildState.message) } } if (winBuildState.includesWin) { if (winBuildState.logLevel === 'warning') { logWarning(winBuildState.message) } else if (winBuildState.logLevel === 'success') { logSuccess(winBuildState.message) } else { logStep(winBuildState.message) } } if (macBuildState.notarize) { logStep('notarization environment prepared') } else if (macBuildState.includesMac) { logStep('running macOS packaging without notarization') } else { logStep('skipping macOS notarization preparation') } logStep('loading electron-builder...') const eb = await import('electron-builder') const builder = eb.default || eb logSuccess('electron-builder loaded') // Build one platform per invocation so electron-builder's own logs stay grouped // and easy to read even when each platform expands to multiple arch/target jobs. for (const { platform, targets } of plan) { logPlatform(platform, 'starting electron-builder run...') await builder.build({ [platform]: targets, publish: publishMode, config: createBuilderConfig(tracker.hooks, macBuildState, winBuildState), }) logPlatform(platform, 'electron-builder run finished.') } tracker.printSummary() } async function main() { const t0 = Date.now() try { // The top-level flow is intentionally linear: prepare inputs, run packaging, // then finish with summary output and any future cleanup. await beforeMake() await doMake() await afterMake() logBanner('Done') logSuccess(`total elapsed: ${formatDuration(Date.now() - t0)}`) } catch (e) { logBanner('Build Failed') console.error(chalk.red(e?.stack || String(e))) console.log(chalk.red(`total elapsed before failure: ${formatDuration(Date.now() - t0)}`)) process.exit(1) } } await main() ================================================ FILE: scripts/release-config.mjs ================================================ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const version = require('../src/version.json') export const DEFAULT_GITHUB_REPOSITORY = 'oldj/SwitchHosts' export function getReleaseVersion() { return version.slice(0, 3).join('.') } export function getFullVersion() { return `${version[0]}.${version[1]}.${version[2]}.${version[3]}` } export function getReleaseTag(env = process.env) { const expectedTag = `v${getReleaseVersion()}` const tag = env.RELEASE_TAG || expectedTag // Keep GitHub Release tags aligned with the app's public semver so // the uploader cannot silently publish assets under a mismatched tag. if (tag !== expectedTag) { throw new Error(`RELEASE_TAG must be "${expectedTag}", got "${tag}".`) } return tag } export function resolveGithubRepository(env = process.env) { const rawRepository = env.GH_RELEASE_REPOSITORY || env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY const match = /^([^/\s]+)\/([^/\s]+)$/.exec(rawRepository || '') if (!match) { throw new Error( `Invalid GitHub repository "${rawRepository}". Expected the format "owner/repo".`, ) } const [, owner, repo] = match return { owner, repo, fullName: `${owner}/${repo}`, } } export function isReleaseArtifactFile(fileName, fullVersion = getFullVersion()) { if (!fileName || fileName.startsWith('.')) { return false } // builder-debug.yml is useful locally, but publishing it would only clutter the release page. if (fileName === 'builder-debug.yml') { return false } // latest*.yml files are required by electron-updater to discover GitHub-hosted updates. if (/^latest.*\.ya?ml$/i.test(fileName)) { return true } return fileName.includes(`v${fullVersion}`) } ================================================ FILE: scripts/upload-diagnostics.mjs ================================================ function getCauseField(cause, field) { if (!cause || !(field in cause)) return null const value = cause[field] return value === null || value === undefined ? null : String(value) } function normalizeTarget(target) { if (!target) { return null } if (typeof target === 'string') { return target } if (typeof target === 'object' && target !== null) { if ('pathname' in target && typeof target.pathname === 'string') { const search = 'search' in target && typeof target.search === 'string' ? target.search : '' return `${target.pathname}${search}` } if ('href' in target && typeof target.href === 'string') { return target.href } } return String(target) } function getCause(error) { if (!(error instanceof Error)) { return null } if (typeof error.cause === 'object' && error.cause !== null) { return error.cause } return null } function pickEnumerableFields(value) { if (!value || typeof value !== 'object') { return null } const entries = Object.entries(value) .filter(([, entryValue]) => { return entryValue === null || [ 'string', 'number', 'boolean' ].includes(typeof entryValue) }) return entries.length > 0 ? Object.fromEntries(entries) : null } export function extractErrorDetails(error) { const normalizedError = error instanceof Error ? error : new Error(String(error)) const cause = getCause(normalizedError) return { causeCode: getCauseField(cause, 'code'), causeErrno: getCauseField(cause, 'errno'), causeHostname: getCauseField(cause, 'hostname'), causeMessage: getCauseField(cause, 'message'), causeSyscall: getCauseField(cause, 'syscall'), errorMessage: normalizedError.message, errorName: normalizedError.name || 'Error', rawCause: pickEnumerableFields(cause), stack: normalizedError.stack || null, } } export function buildDiagnostic({ attempt, error, fileIndex = null, fileName = null, httpStatus = null, maxAttempts, method, progressSnapshot = null, retryable, stage, target = null, }) { const errorDetails = extractErrorDetails(error) return { attempt, causeCode: errorDetails.causeCode, causeErrno: errorDetails.causeErrno, causeHostname: errorDetails.causeHostname, causeMessage: errorDetails.causeMessage, causeSyscall: errorDetails.causeSyscall, currentFileBytes: progressSnapshot?.currentFileBytes ?? null, errorMessage: errorDetails.errorMessage, errorName: errorDetails.errorName, fileIndex, fileName, httpStatus, maxAttempts, method, retryable: Boolean(retryable), stage, target: normalizeTarget(target), totalFiles: progressSnapshot?.totalFiles ?? null, totalUploadedBytes: progressSnapshot?.totalUploadedBytes ?? null, } } export function formatDiagnosticSummary(diagnostic) { const subject = diagnostic.fileName || diagnostic.target || diagnostic.stage const details = [ `attempt ${diagnostic.attempt}/${diagnostic.maxAttempts}` ] if (diagnostic.fileIndex != null && diagnostic.totalFiles) { details.push(`file ${diagnostic.fileIndex}/${diagnostic.totalFiles}`) } if (diagnostic.httpStatus) { details.push(`status=${diagnostic.httpStatus}`) } if (diagnostic.causeCode) { details.push(`cause=${diagnostic.causeCode}`) } if (diagnostic.causeMessage) { details.push(`message=${diagnostic.causeMessage}`) } else if (diagnostic.errorMessage) { details.push(`message=${diagnostic.errorMessage}`) } return `${diagnostic.stage} failed for ${subject} (${details.join(', ')})` } export function formatRetrySummary(diagnostic, delayLabel) { const subject = diagnostic.fileName || diagnostic.target || diagnostic.stage const details = [ `attempt ${Math.min(diagnostic.attempt + 1, diagnostic.maxAttempts)}/${diagnostic.maxAttempts}` ] if (diagnostic.fileIndex != null && diagnostic.totalFiles) { details.unshift(`file ${diagnostic.fileIndex}/${diagnostic.totalFiles}`) } if (diagnostic.httpStatus) { details.push(`status=${diagnostic.httpStatus}`) } if (diagnostic.causeCode) { details.push(`cause=${diagnostic.causeCode}`) } details.push(`in ${delayLabel}`) return `retrying ${diagnostic.stage} ${subject} (${details.join(', ')})` } export function buildDebugPayload(diagnostic, error) { const errorDetails = extractErrorDetails(error) return { diagnostic, error: { cause: errorDetails.rawCause, errorMessage: errorDetails.errorMessage, errorName: errorDetails.errorName, stack: errorDetails.stack, }, } } export function attachDiagnostic(error, diagnostic) { const normalizedError = error instanceof Error ? error : new Error(String(error)) normalizedError.diagnostic = diagnostic return normalizedError } ================================================ FILE: scripts/upload-progress.mjs ================================================ import prettyBytes from 'pretty-bytes' import ProgressBar from 'progress' const PROGRESS_BAR_FORMAT = '[:bar]' const PROGRESS_BAR_WIDTH = 24 function clamp(value, min, max) { return Math.min(Math.max(value, min), max) } export function formatPercent(value) { return `${clamp(value, 0, 100).toFixed(1)}%` } export function formatEta(seconds) { if (!Number.isFinite(seconds) || seconds < 0) { return '--:--' } const roundedSeconds = Math.ceil(seconds) const hours = Math.floor(roundedSeconds / 3600) const minutes = Math.floor((roundedSeconds % 3600) / 60) const secs = roundedSeconds % 60 if (hours > 0) { return [ hours, minutes, secs ].map((value) => String(value).padStart(2, '0')).join(':') } return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` } export function truncateFileName(fileName, maxLength = 36) { if (fileName.length <= maxLength) { return fileName } if (maxLength <= 3) { return fileName.slice(0, maxLength) } const extensionIndex = fileName.lastIndexOf('.') const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : '' const suffixLength = clamp(extension.length + 10, 8, maxLength - 3) const prefixLength = Math.max(maxLength - suffixLength - 3, 1) return `${fileName.slice(0, prefixLength)}...${fileName.slice(-suffixLength)}` } export function formatProgressMessage(snapshot) { return ( `progress ${formatPercent(snapshot.totalPercent)} ` + `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` + `current ${formatPercent(snapshot.currentFilePercent)} ` + `speed ${snapshot.speedLabel} ` + `eta ${snapshot.etaLabel} ` + `${snapshot.transferredLabel}/${snapshot.totalLabel} ` + `${snapshot.displayFileName}` ) } export function fitFileNameToWidth(fileName, availableWidth, fallbackMaxLength = 36) { if (!Number.isFinite(availableWidth)) { return fileName } if (availableWidth <= 0) { return truncateFileName(fileName, Math.max(fallbackMaxLength, 8)) } if (fileName.length <= availableWidth) { return fileName } return truncateFileName(fileName, Math.max(Math.floor(availableWidth), 8)) } export function formatTtyProgressLines(snapshot, barText, columns) { const firstLine = `upload ${barText} ${formatPercent(snapshot.totalPercent)} ${snapshot.transferredLabel}/${snapshot.totalLabel}` const secondLinePrefix = `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` + `current ${formatPercent(snapshot.currentFilePercent)} ` + `speed ${snapshot.speedLabel} ` + `eta ${snapshot.etaLabel} ` const displayFileName = fitFileNameToWidth( snapshot.currentFileName || snapshot.displayFileName, typeof columns === 'number' ? columns - secondLinePrefix.length : undefined, ) return [ firstLine, `${secondLinePrefix}${displayFileName}`, ] } function buildSnapshot(state, now) { const elapsedSeconds = state.startedAt === null ? 0 : Math.max((now() - state.startedAt) / 1000, 0) const speedBytesPerSecond = elapsedSeconds > 0 ? state.totalUploadedBytes / elapsedSeconds : 0 const remainingBytes = Math.max(state.totalBytes - state.totalUploadedBytes, 0) const etaSeconds = remainingBytes === 0 ? 0 : speedBytesPerSecond > 0 ? remainingBytes / speedBytesPerSecond : null const totalPercent = state.totalBytes === 0 ? (state.finished ? 100 : 0) : (state.totalUploadedBytes / state.totalBytes) * 100 const currentFilePercent = state.currentFileSize === 0 ? state.currentFileComplete ? 100 : 0 : (state.currentFileBytes / state.currentFileSize) * 100 return { currentFileBytes: state.currentFileBytes, currentFileIndex: state.currentFileIndex, currentFileName: state.currentFileName, currentFilePercent: clamp(currentFilePercent, 0, 100), currentFileSize: state.currentFileSize, displayFileName: state.currentFileName || '-', etaLabel: formatEta(etaSeconds), etaSeconds, speedBytesPerSecond, speedLabel: `${prettyBytes(speedBytesPerSecond)}/s`, totalBytes: state.totalBytes, totalFiles: state.totalFiles, totalPercent: clamp(totalPercent, 0, 100), totalUploadedBytes: state.totalUploadedBytes, totalLabel: prettyBytes(state.totalBytes), transferredLabel: prettyBytes(state.totalUploadedBytes), } } function createCaptureStream(columns = 120) { let buffer = '' return { clearBuffer() { buffer = '' }, clearLine() {}, columns, cursorTo() { buffer = '' }, isTTY: true, moveCursor() {}, write(chunk) { buffer += chunk return true }, get value() { return buffer }, } } export function createUploadProgressTracker({ totalBytes, totalFiles, isTTY = Boolean(process.stdout.isTTY), log = console.log, now = () => Date.now(), percentStep = 5, ProgressBarClass = ProgressBar, stream = process.stdout, throttleMs = 1000, } = {}) { const state = { currentFileBytes: 0, currentFileComplete: false, currentFileIndex: 0, currentFileName: '', currentFileSize: 0, finished: false, startedAt: null, totalBytes, totalFiles, totalUploadedBytes: 0, } let lastLoggedAt = -Infinity let lastLoggedBucket = -1 let hasRendered = false const progressTotal = Math.max(totalBytes, 1) const barCaptureStream = createCaptureStream() const bar = isTTY && totalFiles > 0 ? new ProgressBarClass(PROGRESS_BAR_FORMAT, { clear: false, complete: '=', incomplete: ' ', renderThrottle: 100, stream: barCaptureStream, total: progressTotal, width: PROGRESS_BAR_WIDTH, }) : null function safeClearLine(direction = 0) { stream.clearLine?.(direction) } function safeCursorTo(column = 0) { stream.cursorTo?.(column) } function safeMoveCursor(dx = 0, dy = 0) { stream.moveCursor?.(dx, dy) } function getBarText(snapshot, force = false) { if (!bar) { return '' } const ratio = progressTotal > 0 ? clamp(snapshot.totalUploadedBytes / progressTotal, 0, 1) : 0 barCaptureStream.clearBuffer() if (force) { bar.update(ratio) bar.render(undefined, true) } else { bar.update(ratio) } return barCaptureStream.value || bar.lastDraw || '[]' } function clearTTYRender() { if (!hasRendered || !stream.isTTY) { return } safeClearLine(0) safeCursorTo(0) safeMoveCursor(0, -1) safeClearLine(0) safeCursorTo(0) } function renderTTY(force = false) { const snapshot = getSnapshot() const barText = getBarText(snapshot, force) const [ firstLine, secondLine ] = formatTtyProgressLines(snapshot, barText, stream.columns) if (hasRendered) { clearTTYRender() } stream.write(firstLine) safeClearLine(1) stream.write('\n') stream.write(secondLine) safeClearLine(1) hasRendered = true return snapshot } function terminateTTYRender() { if (!hasRendered || !stream.isTTY) { return } stream.write('\n') } function ensureStarted() { if (state.startedAt === null) { state.startedAt = now() } } function getSnapshot() { return buildSnapshot(state, now) } function logSnapshot(force = false) { const snapshot = getSnapshot() const currentBucket = percentStep > 0 ? Math.floor(snapshot.totalPercent / percentStep) : Number.POSITIVE_INFINITY if ( !force && now() - lastLoggedAt < throttleMs && currentBucket <= lastLoggedBucket ) { return snapshot } lastLoggedAt = now() lastLoggedBucket = currentBucket log(formatProgressMessage(snapshot)) return snapshot } function render(force = false) { if (bar) { return renderTTY(force) } return logSnapshot(force) } function advance(deltaBytes) { if (deltaBytes <= 0) { return getSnapshot() } ensureStarted() const remainingFile = Math.max(state.currentFileSize - state.currentFileBytes, 0) const remainingTotal = Math.max(state.totalBytes - state.totalUploadedBytes, 0) const safeDelta = Math.min(deltaBytes, remainingFile, remainingTotal) if (safeDelta <= 0) { return getSnapshot() } state.currentFileBytes += safeDelta state.totalUploadedBytes += safeDelta return render() } function startFile(file, fileIndex) { ensureStarted() state.currentFileBytes = 0 state.currentFileComplete = false state.currentFileIndex = fileIndex state.currentFileName = file.name state.currentFileSize = file.size return render(true) } function completeFile() { const remainingBytes = Math.max(state.currentFileSize - state.currentFileBytes, 0) if (remainingBytes > 0) { advance(remainingBytes) } state.currentFileComplete = true return render(true) } function resetCurrentFile() { state.totalUploadedBytes = clamp( state.totalUploadedBytes - state.currentFileBytes, 0, Math.max(state.totalBytes, 0), ) state.currentFileBytes = 0 state.currentFileComplete = false return render(true) } function finish() { state.finished = true if (bar) { const snapshot = renderTTY(true) terminateTTYRender() return snapshot } return logSnapshot(true) } function fail(fileName = state.currentFileName) { const snapshot = getSnapshot() if (bar) { renderTTY(true) terminateTTYRender() } log( `upload failed at file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` + `${truncateFileName(fileName || snapshot.currentFileName || '-')} ` + `(${formatPercent(snapshot.currentFilePercent)} current, ` + `${formatPercent(snapshot.totalPercent)} total, ` + `${snapshot.speedLabel}, eta ${snapshot.etaLabel}, ` + `${snapshot.transferredLabel}/${snapshot.totalLabel})`, ) } function interrupt(message) { if (bar && hasRendered) { clearTTYRender() stream.write(message) stream.write('\n') renderTTY(true) return } log(message) } return { advance, completeFile, fail, finish, getSnapshot, interrupt, resetCurrentFile, startFile, } } ================================================ FILE: scripts/upload-release.mjs ================================================ import chalk from 'chalk' import { config as loadEnv } from 'dotenv' import { createReadStream, promises as fs } from 'node:fs' import path from 'node:path' import { Transform } from 'node:stream' import { fileURLToPath } from 'node:url' import prettyBytes from 'pretty-bytes' import { getFullVersion, getReleaseTag, getReleaseVersion, isReleaseArtifactFile, resolveGithubRepository, } from './release-config.mjs' import { attachDiagnostic, buildDebugPayload, buildDiagnostic, formatDiagnosticSummary, formatRetrySummary, } from './upload-diagnostics.mjs' import { createUploadProgressTracker } from './upload-progress.mjs' loadEnv() const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const rootDir = path.normalize(path.join(__dirname, '..')) const distDir = path.join(rootDir, 'dist') const dryRun = process.env.DRY_RUN === '1' || process.argv.includes('--dry-run') const token = process.env.GH_TOKEN const repository = resolveGithubRepository(process.env) const releaseTag = getReleaseTag(process.env) const releaseVersion = getReleaseVersion() const fullVersion = getFullVersion() const retryAttempts = Math.max( 1, Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_ATTEMPTS, 10) || 3, ) const retryBaseDelayMs = Math.max( 250, Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_BASE_DELAY_MS, 10) || 1500, ) const retryMaxDelayMs = Math.max( retryBaseDelayMs, Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_MAX_DELAY_MS, 10) || 10000, ) const retryableStatusCodes = new Set([ 408, 409, 425, 429, 500, 502, 503, 504 ]) const debugDiagnostics = process.env.RELEASE_UPLOAD_DEBUG === '1' function log(message) { console.log(`[release:upload] ${message}`) } function logFileList(files) { log('files:') files.forEach((file) => { console.log(` - ${file.name} (${prettyBytes(file.size)})`) }) } function getArtifactVersion(fileName) { const match = /-v(\d+\.\d+\.\d+\.\d+)-/.exec(fileName) return match ? match[1] : null } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms) }) } function getRetryDelayMs(attempt) { return Math.min(retryBaseDelayMs * 2 ** Math.max(attempt - 1, 0), retryMaxDelayMs) } function formatRetryDelay(ms) { return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` } function isRetryableStatus(status) { return retryableStatusCodes.has(status) } function isRetryableFetchError(error) { if (!(error instanceof Error)) { return false } const code = typeof error.cause === 'object' && error.cause !== null && 'code' in error.cause ? String(error.cause.code || '') : '' const message = `${error.message} ${code}`.toLowerCase() return ( message.includes('fetch failed') || message.includes('network') || message.includes('timeout') || message.includes('econnreset') || message.includes('eai_again') || message.includes('enotfound') || message.includes('econnrefused') || message.includes('socket') ) } function getProgressSnapshot(progressTracker) { return progressTracker?.getSnapshot() ?? null } function logDiagnosticDebug(error) { if (!debugDiagnostics) { return } const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null const payload = buildDebugPayload(diagnostic, error) console.error(chalk.gray('[release:upload] debug diagnostic:')) console.error(chalk.gray(JSON.stringify(payload, null, 2))) } async function readReleaseFiles() { const entries = await fs.readdir(distDir, { withFileTypes: true }) const files = entries.filter((entry) => entry.isFile()) const mismatchedVersionedFiles = files .map((entry) => entry.name) .filter((fileName) => { const artifactVersion = getArtifactVersion(fileName) return artifactVersion && artifactVersion !== fullVersion }) if (mismatchedVersionedFiles.length > 0) { throw new Error( `Cannot prepare GitHub Release assets for version ${fullVersion}.\n` + `Found old build artifacts in dist/: ${mismatchedVersionedFiles.join(', ')}\n` + `This usually means src/version.json was updated after the last package build, so only latest*.yml still matches.\n` + `Please rebuild the app for version ${fullVersion}, or clean dist/ before uploading.`, ) } // Keep the asset picker strict so repeated uploads remain deterministic across machines. const selectedFiles = files .filter((entry) => isReleaseArtifactFile(entry.name, fullVersion)) .map((entry) => ({ name: entry.name, filePath: path.join(distDir, entry.name), })) .sort((a, b) => a.name.localeCompare(b.name)) return Promise.all( selectedFiles.map(async (file) => ({ ...file, size: (await fs.stat(file.filePath)).size, })), ) } async function githubRequest( pathname, { method = 'GET', body, headers = {}, stage = 'github-request', fileName = null } = {}, ) { const requestUrl = `https://api.github.com${pathname}` for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { let response try { response = await fetch(requestUrl, { method, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, 'User-Agent': 'SwitchHosts-release-uploader', 'X-GitHub-Api-Version': '2022-11-28', ...headers, }, body, }) } catch (error) { const diagnostic = buildDiagnostic({ attempt, error, fileName, maxAttempts: retryAttempts, method, retryable: isRetryableFetchError(error), stage, target: pathname, }) if (attempt >= retryAttempts || !isRetryableFetchError(error)) { throw attachDiagnostic(error, diagnostic) } const delayMs = getRetryDelayMs(attempt) log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs))) await sleep(delayMs) continue } if (!response.ok) { const text = await response.text() const error = new Error(`${method} ${pathname} failed: ${response.status} ${text}`) const diagnostic = buildDiagnostic({ attempt, error, fileName, httpStatus: response.status, maxAttempts: retryAttempts, method, retryable: isRetryableStatus(response.status), stage, target: pathname, }) if (attempt < retryAttempts && isRetryableStatus(response.status)) { const delayMs = getRetryDelayMs(attempt) log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs))) await sleep(delayMs) continue } throw attachDiagnostic(error, diagnostic) } if (response.status === 204) { return null } return response.json() } throw new Error(`${method} ${pathname} failed after ${retryAttempts} attempts.`) } async function findReleaseByTag() { let page = 1 const maxPages = 20 while (page <= maxPages) { // The list API is used here because draft releases are not reliably addressable // through the single-release-by-tag endpoint. const releases = await githubRequest( `/repos/${repository.owner}/${repository.repo}/releases?per_page=100&page=${page}`, { stage: 'find-release', }, ) const found = releases.find((release) => release.tag_name === releaseTag) if (found) { return found } if (releases.length < 100) { return null } page += 1 } } async function createDraftRelease() { return githubRequest(`/repos/${repository.owner}/${repository.repo}/releases`, { method: 'POST', stage: 'create-release', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ tag_name: releaseTag, name: releaseTag, draft: true, prerelease: false, generate_release_notes: false, }), }) } function getUploadUrl(release) { return release.upload_url.replace(/\{.*$/, '') } async function deleteAsset(assetId, assetName) { await githubRequest(`/repos/${repository.owner}/${repository.repo}/releases/assets/${assetId}`, { method: 'DELETE', stage: 'delete-asset', fileName: assetName, }) } async function tryDeleteAssetByName(releaseId, assetName) { try { const assets = await githubRequest( `/repos/${repository.owner}/${repository.repo}/releases/${releaseId}/assets?per_page=100`, { stage: 'list-assets', fileName: assetName }, ) const match = assets?.find((asset) => asset.name === assetName) if (match) { await deleteAsset(match.id, assetName) } } catch (_) { // Best-effort cleanup — don't block the retry if this fails. } } async function uploadAsset(uploadUrl, file, { fileIndex, releaseId, progressTracker } = {}) { const url = new URL(uploadUrl) url.searchParams.set('name', file.name) progressTracker?.startFile(file, fileIndex) for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { const fileStream = createReadStream(file.filePath) const trackedStream = fileStream.pipe( new Transform({ transform(chunk, encoding, callback) { progressTracker?.advance(chunk.byteLength) callback(null, chunk) }, }), ) let response try { response = await fetch(url, { method: 'POST', headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, 'Content-Length': String(file.size), 'Content-Type': 'application/octet-stream', 'User-Agent': 'SwitchHosts-release-uploader', 'X-GitHub-Api-Version': '2022-11-28', }, body: trackedStream, duplex: 'half', }) } catch (error) { fileStream.destroy() trackedStream.destroy() const diagnostic = buildDiagnostic({ attempt, error, fileIndex, fileName: file.name, maxAttempts: retryAttempts, method: 'POST', progressSnapshot: getProgressSnapshot(progressTracker), retryable: isRetryableFetchError(error), stage: 'upload-asset', target: url, }) if (attempt < retryAttempts && isRetryableFetchError(error)) { const delayMs = getRetryDelayMs(attempt) await tryDeleteAssetByName(releaseId, file.name) progressTracker?.resetCurrentFile() progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`) await sleep(delayMs) continue } progressTracker?.fail(file.name) throw attachDiagnostic(error, diagnostic) } if (!response.ok) { fileStream.destroy() trackedStream.destroy() const text = await response.text() const error = new Error(`Upload failed for ${file.name}: ${response.status} ${text}`) const diagnostic = buildDiagnostic({ attempt, error, fileIndex, fileName: file.name, httpStatus: response.status, maxAttempts: retryAttempts, method: 'POST', progressSnapshot: getProgressSnapshot(progressTracker), retryable: isRetryableStatus(response.status), stage: 'upload-asset', target: url, }) if (attempt < retryAttempts && isRetryableStatus(response.status)) { const delayMs = getRetryDelayMs(attempt) await tryDeleteAssetByName(releaseId, file.name) progressTracker?.resetCurrentFile() progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`) await sleep(delayMs) continue } progressTracker?.fail(file.name) throw attachDiagnostic(error, diagnostic) } progressTracker?.completeFile() return response.json() } const exhaustedError = new Error(`Upload failed for ${file.name} after ${retryAttempts} attempts.`) progressTracker?.fail(file.name) throw attachDiagnostic( exhaustedError, buildDiagnostic({ attempt: retryAttempts, error: exhaustedError, fileIndex, fileName: file.name, maxAttempts: retryAttempts, method: 'POST', progressSnapshot: getProgressSnapshot(progressTracker), retryable: false, stage: 'upload-asset', target: url, }), ) } async function main() { const files = await readReleaseFiles() const totalFiles = files.length const totalBytes = files.reduce((sum, file) => sum + file.size, 0) if (files.length === 0) { throw new Error(`No release artifacts found in ${distDir} for version ${fullVersion}.`) } log(`repository: ${repository.fullName}`) log(`release version: ${releaseVersion}`) log(`release tag: ${releaseTag}`) log(`artifacts: ${totalFiles} files, ${prettyBytes(totalBytes)}`) logFileList(files) if (dryRun) { log('dry run enabled, skipping GitHub API calls.') return } if (!token) { throw new Error('GH_TOKEN is required unless DRY_RUN=1 is set.') } let release = await findReleaseByTag() if (!release) { log(`release ${releaseTag} not found, creating draft release...`) release = await createDraftRelease() } else { log(`using existing release ${releaseTag} (draft=${release.draft}, prerelease=${release.prerelease})`) } const uploadUrl = getUploadUrl(release) const existingAssets = new Map(release.assets.map((asset) => [asset.name, asset])) const progressTracker = createUploadProgressTracker({ totalBytes, totalFiles, log, }) const logUploadStatus = (message) => progressTracker.interrupt(`[release:upload] ${message}`) for (const [ index, file ] of files.entries()) { const existingAsset = existingAssets.get(file.name) if (existingAsset) { // Replace same-name assets so different machines can safely append // or refresh artifacts for the same draft release. logUploadStatus(`replacing existing asset ${file.name}`) await deleteAsset(existingAsset.id, file.name) } else { logUploadStatus(`uploading new asset ${file.name}`) } await uploadAsset(uploadUrl, file, { fileIndex: index + 1, releaseId: release.id, progressTracker, }) } progressTracker.finish() log(`done: ${release.html_url}`) } try { await main() } catch (error) { const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null const message = diagnostic ? formatDiagnosticSummary(diagnostic) : error instanceof Error ? error.message : String(error) console.error(chalk.red(`[release:upload] ${message}`)) logDiagnosticDebug(error) process.exit(1) } ================================================ FILE: scripts/vars.mjs ================================================ import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const rootDir = path.normalize(path.join(__dirname, '..')) const distDir = path.normalize(path.join(__dirname, '..', 'dist')) const APP_NAME = 'SwitchHosts' const electronLanguages = ['en', 'fr', 'zh_CN', 'de', 'ja', 'tr', 'ko'] export { APP_NAME, distDir, electronLanguages, rootDir } ================================================ FILE: scripts/version-up.mjs ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const rootDir = path.dirname(__dirname) const versionFile = path.join(rootDir, 'src', 'version.json') const appPackageFile = path.join(rootDir, 'app', 'package.json') const version = JSON.parse(fs.readFileSync(versionFile, 'utf8')) const appPackage = JSON.parse(fs.readFileSync(appPackageFile, 'utf8')) const versionInc = (v) => { return ++v } version[3] = versionInc(version[3]) console.log(`version -> ${version.slice(0, 3).join('.')}(${version[3]})`) fs.writeFileSync(versionFile, `[${version.join(', ')}]`) appPackage.version = version.slice(0, 3).join('.') + '.' + version[3] fs.writeFileSync( appPackageFile, JSON.stringify(appPackage, null, 2), 'utf8', ) ================================================ FILE: src/common/acknowledgements.ts ================================================ /** * acknowledgements * @author: oldj * @homepage: https://oldj.net */ export default [ { name: 'oldj', link: 'https://github.com/oldj' }, { name: 'Allen.M', link: 'https://github.com/allenm' }, { name: 'Charles Tang', link: 'https://github.com/charlestang' }, { name: 'WuJianjun', link: 'https://github.com/stotem' }, { name: 'Elf Sundae', link: 'https://github.com/ElfSundae' }, { name: 'zhu yu', link: 'https://github.com/codeyu' }, { name: '胖梁', link: 'https://github.com/pangliang' }, { name: 'CaffreySun', link: 'https://github.com/CaffreySun' }, { name: 'Xmader', link: 'https://github.com/Xmader' }, { name: 'Dean Zhang', link: 'https://github.com/zhanggang807' }, { name: 'CloverNet', link: 'https://github.com/CloverNet' }, { name: 'ReAlign', link: 'https://github.com/ReAlign' }, { name: 'Kangyi Cui', link: 'https://github.com/cuikangyi' }, { name: 'AKIRA', link: 'https://github.com/akrha' }, { name: 'Constaline', link: 'https://github.com/Constaline' }, { name: 'TooBug', link: 'https://github.com/TooBug' }, { name: 'Lussac', link: 'https://github.com/LussacZheng' }, { name: 'Aktilor', link: 'https://github.com/Aktilor' }, { name: 'LiangLong', link: 'https://github.com/xxccll' }, { name: 'ClDaniel1', link: 'https://github.com/ClDaniel1' }, { name: 'Aaron Xie', link: 'https://github.com/Aaron00101010' }, { name: 'Stefan Berger', link: 'https://github.com/bergo' }, { name: 'EmeryWan', link: 'https://github.com/EmeryWan' }, { name: 'ClDaniel1', link: 'https://github.com/ClDaniel1' }, { name: 'moonheart', link: 'https://github.com/moonheart' }, { name: 'Wang Weitao', link: 'https://github.com/watonyweng' }, { name: 'kamatte', link: 'https://github.com/kamatte-me' }, { name: 'Yuyao Nie', link: 'https://github.com/nieyuyao' }, { name: 'Xav83', link: 'https://github.com/Xav83' }, { name: 'Mango Jelly Pudding', link: 'https://github.com/EvanHsieh0415' }, { name: 'Alex Zappa', link: 'https://github.com/reatlat' }, { name: 'shenshen', link: 'https://github.com/imshenshen' }, { name: 'ChunRen Zhang', link: 'https://github.com/rayatn1011' }, { name: 'Barış Uzun', link: 'https://github.com/barisuzunn' }, { name: 'Hwang In-wook', link: 'https://github.com/wooklab' }, ] ================================================ FILE: src/common/constants.ts ================================================ /** * constants * @author: oldj * @homepage: https://oldj.net */ export const server_url = 'https://switchhosts.vercel.app' export const homepage_url = `${server_url}/home/` export const download_url = `${server_url}/download/` export const source_url = 'https://github.com/oldj/SwitchHosts' export const feedback_url = 'https://github.com/oldj/SwitchHosts/issues' export const http_api_port = 50761 ================================================ FILE: src/common/data.d.ts ================================================ import { ITreeNodeData } from './tree' export type HostsType = 'local' | 'remote' | 'group' | 'folder' export type FolderModeType = 0 | 1 | 2 // 0: 默认; 1: 单选; 2: 多选 export interface IHostsListObject { id: string title?: string on?: boolean type?: HostsType // remote url?: string last_refresh?: string last_refresh_ms?: number refresh_interval?: number // 单位:秒 // group include?: string[] // folder folder_mode?: FolderModeType folder_open?: boolean children?: IHostsListObject[] is_sys?: boolean [key: string]: any } export interface IHostsContentObject { id: string content: string [key: string]: any } export interface ITrashcanObject { data: IHostsListObject add_time_ms: number parent_id: string | null } export interface ITrashcanListObject extends ITrashcanObject, ITreeNodeData { id: string children?: ITrashcanListObject[] is_root?: boolean type?: HostsType | 'trashcan' [key: string]: any } export interface IHostsHistoryObject { id: string content: string add_time_ms: number label?: string } export type VersionType = [number, number, number, number] export interface IHostsBasicData { list: IHostsListObject[] trashcan: ITrashcanObject[] version: VersionType } export interface IOperationResult { success: boolean message?: string data?: any code?: string | number } export interface ICommandRunResult { _id?: string success: boolean stdout: string stderr: string add_time_ms: number } ================================================ FILE: src/common/default_configs.ts ================================================ import { LocaleName } from '@common/i18n' import { FolderModeType } from './data.d' export type WriteModeType = null | 'overwrite' | 'append' export type ThemeType = 'light' | 'dark' | 'system' export type ProtocolType = 'http' | 'https' export type DefaultLocaleType = LocaleName | undefined const configs = { // UI left_panel_show: true, left_panel_width: 270, use_system_window_frame: false, // preferences write_mode: 'append' as WriteModeType, history_limit: 50, locale: undefined as DefaultLocaleType, theme: 'light' as ThemeType, choice_mode: 2 as FolderModeType, show_title_on_tray: false, hide_at_launch: false, send_usage_data: false, cmd_after_hosts_apply: '', remove_duplicate_records: false, hide_dock_icon: false, use_proxy: false, proxy_protocol: 'http' as ProtocolType, proxy_host: '', proxy_port: 0, http_api_on: false, http_api_only_local: true, tray_mini_window: true, multi_chose_folder_switch_all: false, // Legacy key: it now controls background update checks, while the actual // download remains a manual action in the UI. auto_download_update: true, // other env: 'PROD' as 'PROD' | 'DEV', } export type ConfigsType = typeof configs export default configs ================================================ FILE: src/common/events.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default { active_main_window: 'active_main_window', add_new: 'add_new', browser_link: 'browser_link', close_find: 'close_find', cmd_run_result: 'cmd_run_result', config_updated: 'config_updated', edit_hosts_info: 'edit_hosts_info', hosts_content_changed: 'hosts_content_changed', hosts_refreshed: 'hosts_refreshed', hosts_refreshed_by_id: 'hosts_refreshed_by_id', move_to_trashcan: 'move_to_trashcan', new_version: 'new_version', reload_list: 'reload_list', select_hosts: 'select_hosts', set_hosts_on_status: 'set_hosts_on_status', show_about: 'show_about', show_history: 'show_history', show_preferences: 'show_preferences', show_set_write_mode: 'show_set_write_mode', show_source: 'show_source', show_sudo_password_input: 'show_sudo_password_input', system_hosts_updated: 'system_hosts_updated', toggle_comment: 'toggle_comment', toggle_developer_tools: 'toggle_developer_tools', toggle_item: 'toggle_item', toggle_left_panel: 'toggle_left_panel', tray_list_updated: 'tray:list_updated', update_download_progress: 'update_download_progress', update_downloaded: 'update_downloaded', write_hosts_to_system: 'write_hosts_to_system', } ================================================ FILE: src/common/hostsFn.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { FolderModeType, IHostsBasicData, IHostsListObject } from '@common/data' import lodash from 'lodash' type PartHostsObjectType = Partial & { id: string } type Predicate = (obj: IHostsListObject) => boolean export const flatten = (list: IHostsListObject[]): IHostsListObject[] => { let new_list: IHostsListObject[] = [] list.map((item) => { new_list.push(item) if (item.children) { new_list = [...new_list, ...flatten(item.children)] } }) return new_list } export const cleanHostsList = (data: IHostsBasicData): IHostsBasicData => { let list = flatten(data.list) list.map((item) => { if (item.type === 'folder' && !Array.isArray(item.children)) { item.children = [] as IHostsListObject[] } if (item.type === 'group' && !Array.isArray(item.include)) { item.include = [] as string[] } if (item.type === 'folder' || item.type === 'group') { item.content = '' } }) return data } export const findItemById = ( list: IHostsListObject[], id: string, ): IHostsListObject | undefined => { return flatten(list).find((item) => item.id === id) } export const updateOneItem = ( list: IHostsListObject[], item: PartHostsObjectType, ): IHostsListObject[] => { let new_list: IHostsListObject[] = lodash.cloneDeep(list) let i = findItemById(new_list, item.id) if (i) { Object.assign(i, item) } return new_list } const isInTopLevel = (list: IHostsListObject[], id: string): boolean => { return list.findIndex((i) => i.id === id) > -1 } export const setOnStateOfItem = ( list: IHostsListObject[], id: string, on: boolean, default_choice_mode: FolderModeType = 0, multi_chose_folder_switch_all: boolean = false, ): IHostsListObject[] => { let new_list: IHostsListObject[] = lodash.cloneDeep(list) let item = findItemById(new_list, id) if (!item) return new_list item.on = on let itemIsInTopLevel = isInTopLevel(list, id) if (multi_chose_folder_switch_all) { item = switchFolderChild(item, on) !itemIsInTopLevel && switchItemParentIsON(new_list, item, on) } if (!on) { return new_list } if (itemIsInTopLevel) { if (default_choice_mode === 1) { new_list.map((item) => { if (item.id !== id) { item.on = false if (multi_chose_folder_switch_all) { item = switchFolderChild(item, false) } } }) } } else { let parent = getParentOfItem(new_list, id) if (parent) { let folder_mode = parent.folder_mode || default_choice_mode if (folder_mode === 1 && parent.children) { // 单选模式 parent.children.map((item) => { if (item.id !== id) { item.on = false if (multi_chose_folder_switch_all) { item = switchFolderChild(item, false) } } }) } } } return new_list } export const switchItemParentIsON = ( list: IHostsListObject[], item: IHostsListObject, on: boolean, ) => { let parent = getParentOfItem(list, item.id) if (parent) { if (parent.folder_mode === 1) { return } if (!on) { parent.on = on } else if (parent.children) { let parentOn = true parent.children.forEach((item) => { if (!item.on) { parentOn = false } }) parent.on = parentOn } let itemIsInTopLevel = isInTopLevel(list, parent.id) if (!itemIsInTopLevel) { switchItemParentIsON(list, parent, on) } } } export const switchFolderChild = (item: IHostsListObject, on: boolean): IHostsListObject => { if (item.type != 'folder') { return item } let folder_mode = item.folder_mode if (folder_mode === 1) { return item } if (item.children) { item.children.forEach((item) => { item.on = on if (item.type == 'folder') { item = switchFolderChild(item, on) } }) } return item } export const deleteItemById = (list: IHostsListObject[], id: string) => { let idx = list.findIndex((item) => item.id === id) if (idx >= 0) { list.splice(idx, 1) return } list.map((item) => deleteItemById(item.children || [], id)) } // export const getNextSelectedItem = (list: IHostsListObject[], id: string): IHostsListObject | undefined => { // let flat = flatten(list) // let idx = flat.findIndex(item => item.id === id) // // return flat[idx + 1] || flat[idx - 1] // } export const getNextSelectedItem = ( tree: IHostsListObject[], predicate: Predicate, ): IHostsListObject | undefined => { let flat = flatten(tree) let idx_1 = -1 let idx_2 = -1 flat.map((i, idx) => { if (predicate(i)) { if (idx_1 === -1) { idx_1 = idx } idx_2 = idx } }) return flat[idx_2 + 1] || flat[idx_1 - 1] } export const getParentOfItem = ( list: IHostsListObject[], item_id: string, ): IHostsListObject | undefined => { if (list.find((i) => i.id === item_id)) { // is in the top level return } let flat = flatten(list) for (let p of flat) { if (p.children && p.children.find((i) => i.id === item_id)) { return p } } } ================================================ FILE: src/common/i18n/index.ts ================================================ /** * index * @author: oldj * @homepage: https://oldj.net */ import en from './languages/en' import zh from './languages/zh' import zh_hant from './languages/zh-hant' import fr from './languages/fr' import de from './languages/de' import ja from './languages/ja' import tr from './languages/tr' import ko from './languages/ko' import pl from './languages/pl' import { LanguageDict, LanguageKey } from '@common/types' export const languages = { en, zh, cn: zh, 'zh-CN': zh, zh_hant: zh_hant, 'zh-TW': zh_hant, fr, de, ja, tr, ko, pl, } export type LocaleName = keyof typeof languages export class I18N { locale: LocaleName lang: LanguageDict constructor(locale: LocaleName = 'en') { this.locale = locale const _this = this this.lang = new Proxy( {}, { get(obj, key: LanguageKey) { return _this.trans(key) }, }, ) as LanguageDict } trans(key: LanguageKey, words?: string[]) { let lang = languages[this.locale] let s: string = '' if (key in lang) { s = lang[key].toString() } if (words) { words.map((w, idx) => { let reg = new RegExp(`\{\s*${idx}\s*}`) s = s.replace(reg, w) }) } return s } } ================================================ FILE: src/common/i18n/languages/de.ts ================================================ /** * @author: bergo * @homepage: https://bergo.dev */ import { LanguageDict } from '@common/types' const lang: LanguageDict = { _app_name: 'SwitchHosts', _key: 'de', _name: 'Deutsch', about: 'Über', acknowledgement: 'Danksagung', advanced: 'Erweitert', all: 'Alle', append: 'Anhängen', auto_refresh: 'Automatisch aktualisieren', btn_cancel: 'Abbrechen', btn_ok: 'OK', change: 'Ändern', check_update: 'Aktualisierung prüfen', choice_mode: 'Auswahlmodus', choice_mode_default: 'Standard', choice_mode_desc: 'Gilt nur für das oberste Element, jeder Ordner kann seinen eigenen Auswahlmodus festlegen.', choice_mode_multiple: 'Mehrfach', choice_mode_single: 'Einfach', choices: 'Auswahlen', chosen: 'Ausgewählt', clear_history: 'Verlauf löschen', click_to_open: 'Klicken zum Öffnen', close: 'Schließen', colon: ': ', commands: 'Befehle', commands_help: 'Die folgenden Systembefehle werden ausgeführt, wenn Hosts angewendet werden:', commands_title: 'Befehl nach dem Anlegen eines Hosts', comment_current_line: 'Aktuelle Zeile kommentieren', content: 'Inhalt', copy: 'Kopieren', cut: 'Ausschneiden', day: 'Tag', days: 'Tage', delete: 'Löschen', download: 'Herunterladen', edit: 'Bearbeiten', export: 'Exportieren', export_done: 'Der Export ist abgeschlossen.', fail: 'Fehlgeschlagen!', feedback: 'Rückmeldung', file: 'Datei', find: 'Suchen', find_all: 'Alles suchen', find_and_replace: 'Suchen und ersetzen', find_history: 'Verlauf suchen', folder: 'Ordner', front: 'Vorderseite', general: 'Allgemein', group: 'Gruppe', help: 'Hilfe', hide: 'Ausblenden', hide_at_launch: 'Beim Start ausblenden', hide_dock_icon: 'Dock-Symbol ausblenden', hide_history: 'Verlauf ausblenden', hide_others: 'Andere ausblenden', homepage: 'Startseite', host: 'Gastgeber', hosts_add: 'Neue Hosts hinzufügen', hosts_delete: 'Diesen Host löschen', hosts_delete_confirm: 'Sind Sie sicher, dass Sie die aktuellen Hosts löschen wollen?', hosts_edit: 'Hosts bearbeiten', hosts_title: 'Titel des Hosts', hosts_type: 'Hosts Typ', hosts_updated: 'Die Hosts-Datei wurde aktualisiert.', hour: 'Stunde', hours: 'Stunden', http_api_on: 'HTTP-API eingeschaltet', http_api_on_desc: 'Läuft auf Port {0}, kann von Software von Drittanbietern wie Alfred verwendet werden, um den Host zu wechseln.', http_api_only_local: 'HTTP-API hört nur auf 127.0.0.1', ignore_case: 'Groß- und Kleinschreibung ignorieren', import: 'Importieren', import_done: 'Der Import ist abgeschlossen.', import_fail: 'Der Import ist fehlgeschlagen!', import_from_url: 'Importieren von URL', is_latest_version_inform: 'Super, Sie haben die neueste Version!', check_update_failed: 'Suche nach Updates fehlgeschlagen!', update_download_now: 'Update herunterladen', update_install_now: 'Installieren und neu starten', update_downloading_desc: 'Version {0} wird heruntergeladen: {1}', update_ready_desc: 'Version {0} wurde heruntergeladen und kann jetzt installiert werden.', item_found: '{0} Einträge gefunden.', items: 'items', items_found: '{0} Einträge gefunden.', language: 'Sprache', last_refresh: 'Letzte Aktualisierung: ', latest_version_desc: 'Die neueste Version ist: {0}', line: 'Zeile', lines: 'Zeilen', loading: 'Loading...', local: 'Lokal', match: 'Übereinstimmung', migrate_confirm: 'SwitchHosts v4.0 verwendet ein neues Datenspeicherformat, möchten Sie alte Daten in das neue Format migrieren?', migrate_data: 'Daten migrieren', minimize: 'Minimieren', minute: 'Minute', minutes: 'Minuten', move_items_to_trashcan: 'Verschiebe {0} Objekte in den Mülleimer', move_to_trashcan: 'In die Mülltonne verschieben', multi_chose_folder_switch_all: 'Mehrfachauswahl-Ordnerschalter zur Steuerung von Unterelementen', need_to_relaunch: 'Muss neu gestartet werden', need_to_relaunch_after_setting_changed: 'Die Einstellungen wurden geändert und werden erst nach einem Neustart der App wirksam.', never: 'Niemals', new: 'Neu', new_version_found: 'Neue Version gefunden', next: 'Nächste', no_access_to_hosts: 'Keine Berechtigung zum Schreiben in die Hosts-Datei.', no_record: 'Kein Datensatz', overwrite: 'Überschreiben', password: 'Passwort', paste: 'Einfügen', port: 'Anschluss', preferences: 'Präferenzen', previous: 'Vorhergehend', protocol: 'Protokoll', proxy: 'Proxy', quit: 'Beenden', read_only: 'Nur Lesen', redo: 'Wiederherstellen', refresh: 'Auffrischen', regexp: 'Regulärer Ausdruck', reload: 'Neu laden', remote: 'Entfernt', remove_duplicate_records: 'Doppelte Datensätze entfernen', remove_duplicate_records_desc: 'Wenn eine Domain auf mehrere IPs verweist, wird nur die erste wirksam, die folgenden werden in Kommentare umgewandelt.', replace: 'Ersetzen', replace_all: 'Ersetze alle', replace_history: 'Historie ersetzen', reset: 'Zurücksetzen', reset_data_dir_confirm: 'Sind Sie sicher, dass Sie den Datenordner an der Standardadresse ({0}) wiederherstellen wollen?', reset_zoom: 'Zoom zurücksetzen', search: 'Suchen', select_all: 'Alles auswählen', selected: 'Ausgewählt', show_dock_icon: 'Dock-Symbol anzeigen', show_history: 'Historie anzeigen', show_main_window: 'Hauptfenster anzeigen', show_title_on_tray: 'Titel auf dem Tablett anzeigen', source_code: 'Quellcode', success: 'Erfolg!', sudo_prompt_title: 'Geben Sie Ihr sudo-Passwort ein', system_hosts: 'System-Hosts', system_hosts_history: 'Historische Versionen der System-Hosts', system_hosts_history_delete_confirm: 'Sind Sie sicher, dass Sie dieses Element löschen wollen?', system_hosts_history_help: 'Wenn die Gesamtzahl der historischen Einträge diese Grenze überschreitet, wird der älteste Eintrag gelöscht.', system_hosts_history_limit: 'Maximale Anzahl von Datensätzen: ', test: 'Test', theme: 'Thema', theme_dark: 'Dunkel', theme_light: 'Hell', title: 'Titel', to_show_source: 'Durch Doppelklick wird der Quellcode angezeigt.', toggle_developer_tools: 'Entwicklerwerkzeuge einschalten', toggle_dock_icon: 'Das Dock-Symbol einschalten', toggle_full_screen: 'Vollbildmodus einschalten', trashcan: 'Mülleimer', trashcan_clear: 'Den Mülleimer leeren', trashcan_clear_confirm: 'Sind Sie sicher, dass Sie den Mülleimer leeren wollen?', trashcan_delete_confirm: 'Möchten Sie dieses Objekt vollständig löschen?', trashcan_restore: 'Wiederherstellen', tray_mini_window: 'Taskleistensymbol-Verknüpfung', undo: 'Rückgängig machen', unhide: 'Einblenden', untitled: 'Ohne Titel', url_placeholder: 'http:// oder https:// oder file://', usage_data_agree: 'Ja, übermitteln Sie anonymisierte Nutzungsdaten', usage_data_help: 'Möchten Sie uns helfen, SwitchHosts zu verbessern, indem Sie regelmäßig anonyme Nutzungsdaten übermitteln?', usage_data_title: 'Machen Sie SwitchHosts besser!', use_proxy: 'Proxy verwenden', use_system_window_frame: 'Verwenden Sie den Systemfensterrahmen, ein Neustart der Anwendung ist erforderlich', view: 'Ansicht', where_is_my_data: 'Wo sind meine Daten gespeichert?', where_is_my_hosts: 'Wo ist meine Hosts-Datei?', window: 'Fenster', write_mode: 'Schreibmodus', write_mode_append_help: 'Hängen Sie die neuen Datensätze an das Ende der Hosts-Datei des Systems an.', write_mode_overwrite_help: 'Überschreibt die Systemhosts-Datei mit den neuen Datensätzen.', write_mode_set: 'Schreibmodus einstellen', your_data_is: 'Ihre Datendateien sind gespeichert in:', your_hosts_file_is: 'Ihre Hosts-Datei befindet sich in:', zoom: 'Vergrößern', zoom_in: 'Vergrößern', zoom_out: 'Herauszoomen', } export default lang ================================================ FILE: src/common/i18n/languages/en.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default { _app_name: 'SwitchHosts', _key: 'en', _name: 'English', about: 'About', acknowledgement: 'Acknowledgement', advanced: 'Advanced', all: 'All', append: 'Append', auto_refresh: 'Auto refresh', btn_cancel: 'Cancel', btn_ok: 'OK', change: 'Change', check_update: 'Check update', choice_mode: 'Choice mode', choice_mode_default: 'Default', choice_mode_desc: 'Only valid for the topmost item, each folder can set its own choice mode.', choice_mode_multiple: 'Multiple', choice_mode_single: 'Single', choices: 'Choices', chosen: 'Chosen', clear_history: 'Clear history', click_to_open: 'Click to open', close: 'Close', colon: ': ', commands: 'Commands', commands_help: 'The following system commands will be executed when Hosts applied:', commands_title: 'Command after hosts are applied', comment_current_line: 'Comment current line', content: 'Content', copy: 'Copy', cut: 'Cut', day: 'day', days: 'days', delete: 'Delete', download: 'Download', edit: 'Edit', export: 'Export', export_done: 'The export is complete.', fail: 'Fail!', feedback: 'Feedback', file: 'File', find: 'Find', find_all: 'Find all', find_and_replace: 'Find and replace', find_history: 'Find history', folder: 'Folder', front: 'Front', general: 'General', group: 'Group', help: 'Help', hide: 'Hide', hide_at_launch: 'Hide at launch', hide_dock_icon: 'Hide the dock icon', hide_history: 'Hide history', hide_others: 'Hide others', homepage: 'Homepage', host: 'Host', hosts_add: 'Add new hosts', hosts_delete: 'Delete this hosts', hosts_delete_confirm: 'Are you sure you want to delete the current hosts?', hosts_edit: 'Edit hosts', hosts_title: 'Hosts title', hosts_type: 'Hosts type', hosts_updated: 'The Hosts file has been updated.', hour: 'hour', hours: 'hours', http_api_on: 'HTTP API on', http_api_on_desc: 'Runs on port {0}, can be used by third-party software such as Alfred to switch hosts.', http_api_only_local: 'HTTP API only listen 127.0.0.1', ignore_case: 'Ignore case', import: 'Import', import_done: 'The import is complete.', import_fail: 'Import failed!', import_from_url: 'Import from URL', is_latest_version_inform: 'Great, you are running the latest version!', check_update_failed: 'Check for updates failed!', update_download_now: 'Download update', update_install_now: 'Install and restart', update_downloading_desc: 'Downloading version {0}: {1}', update_ready_desc: 'Version {0} has been downloaded and is ready to install.', item_found: '{0} item found.', items: 'items', items_found: '{0} items found.', language: 'Language', last_refresh: 'Last refresh: ', latest_version_desc: 'The latest version is: {0}', line: 'line', lines: 'lines', loading: 'Loading...', local: 'Local', match: 'Match', migrate_confirm: 'SwitchHosts v4.0 uses a new data storage format, do you want to migrate old data to the new format?', migrate_data: 'Migrate data', minimize: 'Minimize', minute: 'minute', minutes: 'minutes', move_items_to_trashcan: 'Move {0} items to trashcan', move_to_trashcan: 'Move to trashcan', multi_chose_folder_switch_all: 'multi-select folder switch to control sub-items', need_to_relaunch: 'Need to relaunch', need_to_relaunch_after_setting_changed: 'The setting has been changed and will take effect after the app is restarted.', never: 'Never', new: 'New', new_version_found: 'New version found', next: 'Next', no_access_to_hosts: 'No permission to write to the Hosts file.', no_record: 'No record', overwrite: 'Overwrite', password: 'Password', paste: 'Paste', port: 'Port', preferences: 'Preferences', previous: 'Previous', protocol: 'Protocol', proxy: 'Proxy', quit: 'Quit', read_only: 'Read only', redo: 'Redo', refresh: 'Refresh', regexp: 'Regular expression', reload: 'Reload', remote: 'Remote', remove_duplicate_records: 'Remove duplicate records', remove_duplicate_records_desc: 'If a domain points to multiple IPs, only the first one will take effect, and the following ones will be converted into comments.', replace: 'Replace', replace_all: 'Replace all', replace_history: 'Replace history', reset: 'Reset', reset_data_dir_confirm: 'Are you sure you want to restore the data folder to the default address ({0})?', reset_zoom: 'Reset zoom', search: 'Search', select_all: 'Select all', selected: 'Selected', show_dock_icon: 'Show the dock icon', show_history: 'Show history', show_main_window: 'Show main window', show_title_on_tray: 'Show title on tray', source_code: 'Souce code', success: 'Success!', sudo_prompt_title: 'Input your sudo password', system_hosts: 'System Hosts', system_hosts_history: 'History versions of the System Hosts', system_hosts_history_delete_confirm: 'Are you sure you want to delete this item?', system_hosts_history_help: 'If the total number of historical records exceeds this limit, the oldest record will be deleted.', system_hosts_history_limit: 'Maximum number of records: ', test: 'Test', theme: 'Theme', theme_dark: 'Dark', theme_light: 'Light', title: 'Title', to_show_source: 'Double-click to show the source code.', toggle_developer_tools: 'Toggle Developer Tools', toggle_dock_icon: 'Toggle the dock icon', toggle_full_screen: 'Toggle full screen', trashcan: 'Trashcan', trashcan_clear: 'Empty the trashcan', trashcan_clear_confirm: 'Are you sure you want to empty the trashcan?', trashcan_delete_confirm: 'Do you want to delete this item completely?', trashcan_restore: 'Restore', tray_mini_window: 'taskbar icon shortcut', undo: 'Undo', unhide: 'Unhide', untitled: 'Untitled', url_placeholder: 'http:// or https:// or file://', usage_data_agree: 'Yes, submit anonymized usage data', usage_data_help: 'Would you like to help us improve SwitchHosts by periodically submitting anonymous usage data?', usage_data_title: 'Make SwitchHosts better!', use_proxy: 'Use proxy', use_system_window_frame: 'Use system window frame, application restart is required', view: 'View', where_is_my_data: 'Where is my data stored?', where_is_my_hosts: 'Where is my hosts file?', window: 'Window', write_mode: 'Write mode', write_mode_append_help: 'Append the new records to the end of the system hosts file.', write_mode_overwrite_help: 'Overwrite the system hosts file with the new records.', write_mode_set: 'Set the write mode', your_data_is: 'Your data files are stored in:', your_hosts_file_is: 'Your hosts file is located at:', zoom: 'Zoom', zoom_in: 'Zoom in', zoom_out: 'Zoom out', } ================================================ FILE: src/common/i18n/languages/fr.ts ================================================ /** * @author: Aktilor * @homepage: https://github.com/Aktilor */ import { LanguageDict } from '@common/types' const lang: LanguageDict = { _app_name: 'SwitchHosts', _key: 'fr', _name: 'Français', about: 'À propos', acknowledgement: 'Remerciements', advanced: 'Avancé', all: 'Tout', append: 'Ajouter', auto_refresh: 'Rafraîchissement automatique', btn_cancel: 'Annuler', btn_ok: 'OK', change: 'Changer', check_update: 'Vérifier les mises à jour', choice_mode: 'Choice mode', choice_mode_default: 'Défaut', choice_mode_desc: "Uniquement valable pour l'élément le plus haut, chaque dossier peut définir son propre mode.", choice_mode_multiple: 'Multiple', choice_mode_single: 'Seul', choices: 'Choix', chosen: 'Choisi', clear_history: "Effacer l'historique", click_to_open: 'Cliquer pour ouvrir', close: 'Fermer', colon: ' : ', commands: 'Commandes', commands_help: "Les commandes systèmes suivantes seront exécutées quand l'hosts sera activé :", commands_title: "Commandes une fois qu'un hosts est activé", comment_current_line: 'Commenter cette ligne', content: 'Contenu', copy: 'Copier', cut: 'Couper', day: 'jour', days: 'jours', delete: 'Supprimer', download: 'Télécharger', edit: 'Éditer', export: 'Exporter', export_done: "L'export est terminé.", fail: 'Échec !', feedback: 'Laisser un commentaire', file: 'Fichier', find: 'Rechercher', find_all: 'Rechercher tout', find_and_replace: 'Rechercher et remplacer', find_history: 'Historique des recherches', folder: 'Dossier', front: 'Front', general: 'Général', group: 'Groupe', help: 'Aide', hide: 'Cacher', hide_at_launch: 'Cacher au lancement', hide_dock_icon: "Cacher l'icone dans le Dock", hide_history: "Cacher l'historique", hide_others: 'Cacher les autres', homepage: "Page d'accueil", host: 'Host', hosts_add: 'Ajouter un nouvel hosts', hosts_delete: 'Supprimer cet hosts', hosts_delete_confirm: 'Êtes-vous sûr de vouloir supprimer cet hosts?', hosts_edit: "Éditer l'hosts", hosts_title: "Titre de l'hosts", hosts_type: "Type d'hosts", hosts_updated: 'Le fichier hosts a été mis à jour', hour: 'heure', hours: 'heures', http_api_on: 'Activer HTTP API', http_api_on_desc: "Actif sur le port {0}, peut être utilisé par un logiciel tier comme Alfred pour changer d'hosts", http_api_only_local: "L'API HTTP n'écoute que sur 127.0.0.1", ignore_case: 'Ignorer la casse', import: 'Importer', import_done: "L'importation est terminée", import_fail: "Échec de l'importation !", import_from_url: "Importer à partir d'une URL", is_latest_version_inform: 'Super, vous avez la dernière version !', check_update_failed: 'La vérification des mises à jour a échoué !', update_download_now: 'Télécharger la mise à jour', update_install_now: 'Installer et redémarrer', update_downloading_desc: 'Téléchargement de la version {0} : {1}', update_ready_desc: 'La version {0} a été téléchargée et est prête à être installée.', item_found: '{0} élément trouvé.', items: 'éléments', items_found: '{0} éléments trouvés.', language: 'Langage', last_refresh: 'Dernier rafraîchissement : ', latest_version_desc: 'La dernière version est : {0}', line: 'ligne', lines: 'lignes', loading: 'Chargement...', local: 'Local', match: 'Correspondance', migrate_confirm: 'SwitchHosts v4.0 utilise un nouveau format de stockage des données, voulez-vous migrer les anciennes données dans ce nouveau format ?', migrate_data: 'Migrer les données', minimize: 'Réduire', minute: 'minute', minutes: 'minutes', move_items_to_trashcan: 'Déplacer {0} éléments dans la corbeille', move_to_trashcan: 'Déplacer dans la corbeille', multi_chose_folder_switch_all: 'Commutateur de dossier à sélection multiple pour contrôler les sous-éléments', need_to_relaunch: 'Besoin de redémarrer', need_to_relaunch_after_setting_changed: "Le paramètre a été modifié et prendra effet après le redémarrage de l'application.", never: 'Jamais', new: 'Nouveau', new_version_found: 'Nouvelle version trouvée', next: 'Suivant', no_access_to_hosts: 'Aucune autorisation pour écrire dans le fichier hosts.', no_record: 'Aucun enregistrement', overwrite: 'Écraser', password: 'Mot de passe', paste: 'Coller', port: 'Port', preferences: 'Préférences', previous: 'Précédent', protocol: 'Protocol', proxy: 'Proxy', quit: 'Quitter', read_only: 'Lecture seule', redo: 'Rétablir', refresh: 'Rafraîchir', regexp: 'Expression régulière', reload: 'Recharger', remote: 'Distant', remove_duplicate_records: 'Supprimer les enregistrements doublons', remove_duplicate_records_desc: 'Si un domaine pointe sur plusieurs IPs, seulement la première sera prise en compte, et les autres seront converties en commentaires.', replace: 'Remplacer', replace_all: 'Tout remplacer', replace_history: "Remplacer l'historique", reset: 'Réinitialiser', reset_data_dir_confirm: "Êtes-vous sûr de vouloir réinitialiser le dossier de données à l'adresse par défaut?({0})?", reset_zoom: 'Réinitialiser le zoom', search: 'Rechercher', select_all: 'Tout sélectionner', selected: 'Sélectionné', show_dock_icon: "Afficher l'icone dans le Dock", show_history: "Afficher l'historique", show_main_window: 'Afficher la fenêtre principale', show_title_on_tray: 'Afficher le titre dans la barre des menus', source_code: 'Code source', success: 'Succès !', sudo_prompt_title: 'Entrez votre mot de passe sudo', system_hosts: 'Hosts du système', system_hosts_history: 'Historique des versions hosts du système', system_hosts_history_delete_confirm: 'Êtes-vous sûr de vouloir supprimer cet élément ?', system_hosts_history_help: "Si le nombre total d'enregistrements dépasse cette limite, l'enregistrement le plus ancien sera supprimé.", system_hosts_history_limit: "Nombre max. d'enregistrements : ", test: 'Test', theme: 'Thème', theme_dark: 'Sombre', theme_light: 'Clair', title: 'Titre', to_show_source: 'Double-cliquez pour afficher le code source', toggle_developer_tools: 'Afficher/Cacher le Developer Tools', toggle_dock_icon: "Afficher/Cacher l'icone dans le Dock", toggle_full_screen: 'Activer/Désactiver le plein écran', trashcan: 'Corbeille', trashcan_clear: 'Vider la corbeille', trashcan_clear_confirm: 'Êtes-vous sûr de vouloir vider la corbeille ?', trashcan_delete_confirm: 'Voulez-vous supprimer définitivement cet élément ?', trashcan_restore: 'Restaurer', tray_mini_window: "raccourci de l'icône de la barre des tâches", undo: 'Annuler', unhide: 'Démasquer', untitled: 'Sans titre', url_placeholder: 'http:// ou https:// ou file://', usage_data_agree: "Oui, soumettre de manière anonyme mes données d'utilisation", usage_data_help: "Voulez-vous nous aider à améliorer SwitchHosts en soumettant périodiquement vos données d'utilisation de manière anonyme ?", usage_data_title: 'Rendez SwitchHosts meilleur !', use_proxy: 'Utiliser un proxy', use_system_window_frame: "Utiliser le cadre de la fenêtre système, le redémarrage de l'application est requis", view: 'Vue', where_is_my_data: 'Où sont stockées mes données ?', where_is_my_hosts: 'Où est mon fichier hosts ?', window: 'Fenêtre', write_mode: "Mode d'écriture", write_mode_append_help: "Ajoutez les nouveaux enregistrements à la fin du fichier d'hôtes système.", write_mode_overwrite_help: "Écrasez le fichier d'hôtes système avec les nouveaux enregistrements.", write_mode_set: "Définir le mode d'écriture", your_data_is: 'Les fichiers contenant vos données sont stockés ici :', your_hosts_file_is: 'Votre fichier hosts est situé ici :', zoom: 'Zoom', zoom_in: 'Zoommer', zoom_out: 'Dézoommer', } export default lang ================================================ FILE: src/common/i18n/languages/ja.ts ================================================ /** * @author: kamatte * @homepage: https://kamatte.me */ import { LanguageDict } from '@common/types' const lang: LanguageDict = { _app_name: 'SwitchHosts', _key: 'ja', _name: '日本語', about: 'SwitchHosts について', acknowledgement: '謝辞', advanced: '詳細設定', all: 'すべて', append: '追記', auto_refresh: '自動更新', btn_cancel: 'キャンセル', btn_ok: 'OK', change: '変更', check_update: 'アップデートを確認', choice_mode: '選択モード', choice_mode_default: 'デフォルト', choice_mode_desc: '最上位階層のhostsにのみ有効で、各フォルダーでは独自に選択モードを設定できます。', choice_mode_multiple: '複数', choice_mode_single: '単一', choices: '選択', chosen: '選択済み', clear_history: '履歴をクリア', click_to_open: 'クリックして開く', close: '閉じる', colon: ': ', commands: 'コマンド', commands_help: 'hostsが適用されたとき、以下のシステムコマンドを実行します:', commands_title: 'hosts適用後のコマンド', comment_current_line: '現在の行をコメントアウト', content: '内容', copy: 'コピー', cut: '切り取り', day: '日', days: '日', delete: '削除', download: 'ダウンロード', edit: '編集', export: 'エクスポート', export_done: 'エクスポートが完了しました。', fail: '失敗', feedback: 'フィードバック', file: 'ファイル', find: '検索', find_all: 'すべて検索', find_and_replace: '検索と置換', find_history: '検索履歴', folder: 'フォルダー', front: '前面', general: '一般', group: 'グループ', help: 'ヘルプ', hide: '非表示', hide_at_launch: '起動時に非表示', hide_dock_icon: 'Dockアイコンを非表示', hide_history: '履歴を非表示', hide_others: 'その他を非表示にする', homepage: 'ホームページ', host: 'ホスト', hosts_add: 'hostsを追加', hosts_delete: 'hostsを削除', hosts_delete_confirm: 'このhostsを削除してもよろしいですか?', hosts_edit: 'hostsを編集', hosts_title: 'hostsタイトル', hosts_type: 'hostsタイプ', hosts_updated: 'hostsを更新しました。', hour: '時間', hours: '時間', http_api_on: 'HTTP APIを有効化', http_api_on_desc: '{0}番ポートで実行され、Alfredなどのサードパーティソフトウェアでhostsを切り替えるために使用できます。', http_api_only_local: 'HTTP APIを 127.0.0.1 のみでリッスンする', ignore_case: '大文字と小文字を区別しない', import: 'インポート', import_done: 'インポートが完了しました。', import_fail: 'インポートに失敗しました!', import_from_url: 'URLからインポート', is_latest_version_inform: 'ご利用のバージョンは最新です!', check_update_failed: 'アップデートの確認に失敗しました!', update_download_now: '更新をダウンロード', update_install_now: 'インストールして再起動', update_downloading_desc: 'バージョン {0} をダウンロード中: {1}', update_ready_desc: 'バージョン {0} のダウンロードが完了し、インストールできます。', item_found: '{0}件見つかりました。', items: '件', items_found: '{0}件見つかりました。', language: '言語', last_refresh: '最終更新: ', latest_version_desc: '最新バージョン: {0}', line: '行', lines: '行', loading: '読み込み中...', local: 'ローカル', match: '一致', migrate_confirm: 'SwitchHosts v4.0は新しいデータ保存形式を使用します。古いデータを新しい形式に移行しますか?', migrate_data: 'データ移行', minimize: '最小化', minute: '分', minutes: '分', move_items_to_trashcan: '{0}件をごみ箱に入れる', move_to_trashcan: 'ゴミ箱に入れる', multi_chose_folder_switch_all: 'フォルダーの切り替えで配下のアイテムを一括操作', need_to_relaunch: '再起動が必要です', need_to_relaunch_after_setting_changed: '変更された設定はアプリケーションの再起動後に有効になります。', never: 'なし', new: '新規', new_version_found: '新しいバージョンが見つかりました', next: '次へ', no_access_to_hosts: 'hostsファイルの書き込み権限がありません。', no_record: 'なし', overwrite: '上書き', password: 'パスワード', paste: '貼り付け', port: 'ポート', preferences: '設定', previous: '前へ', protocol: 'プロトコル', proxy: 'プロキシ', quit: '終了', read_only: '読み取り専用', redo: 'やり直し', refresh: '更新', regexp: '正規表現', reload: '再読み込み', remote: 'リモート', remove_duplicate_records: '重複レコードを削除', remove_duplicate_records_desc: '1つのドメインに複数のIPアドレスを指定している場合、先頭のIPアドレスのみが有効になり、以降のIPアドレスはコメントに変換されます。', replace: '置換', replace_all: 'すべて置換', replace_history: '置換履歴', reset: 'リセット', reset_data_dir_confirm: 'データフォルダーの場所をデフォルト ({0}) に戻してもよろしいですか?', reset_zoom: 'ズームをリセット', search: '検索', select_all: 'すべて選択', selected: '選択済み', show_dock_icon: 'Dockアイコンを表示', show_history: '履歴を表示', show_main_window: 'メインウィンドウを表示', show_title_on_tray: 'トレイにタイトルを表示', source_code: 'ソースコード', success: '成功', sudo_prompt_title: '管理者パスワードを入力してください', system_hosts: 'システムhosts', system_hosts_history: 'システムhostsのバージョン履歴', system_hosts_history_delete_confirm: 'この履歴を削除してもよろしいですか?', system_hosts_history_help: '履歴件数がこれを超えると、最も古い履歴が削除されます。', system_hosts_history_limit: '履歴の最大件数: ', test: 'テスト', theme: 'テーマ', theme_dark: 'ダーク', theme_light: 'ライト', title: 'タイトル', to_show_source: 'ダブルクリックでソースコードを表示する。', toggle_developer_tools: '開発者ツールの表示/非表示', toggle_dock_icon: 'Dockアイコンの表示/非表示', toggle_full_screen: 'フルスクリーン', trashcan: 'ゴミ箱', trashcan_clear: 'ゴミ箱を空にする', trashcan_clear_confirm: 'ゴミ箱を空にしてもよろしいですか?', trashcan_delete_confirm: 'この項目を完全に削除しますか?', trashcan_restore: '戻す', tray_mini_window: 'タスクバーアイコンショートカット', undo: '元に戻す', unhide: 'すべて表示', untitled: '無題', url_placeholder: 'http:// または https:// または file://', usage_data_agree: 'はい、匿名の利用データを送信します。', usage_data_help: '匿名の利用データを定期的に送信し、SwitchHostsの改善にご協力いただけませんか?', usage_data_title: 'SwitchHostsの改善に協力する', use_proxy: 'プロキシを使用', use_system_window_frame: 'システムのウィンドウフレームを使用。アプリケーションの再起動が必要です。', view: '表示', where_is_my_data: 'データはどこに保存されますか?', where_is_my_hosts: 'hostsファイルはどこにありますか?', window: 'ウィンドウ', write_mode: '書き込みモード', write_mode_append_help: '新しいレコードをシステムhostsの末尾に追記します。', write_mode_overwrite_help: '新しいレコードでシステムhostsを上書きします。', write_mode_set: '書き込みモードを設定', your_data_is: 'あなたのデータファイルはこちらに保存されています:', your_hosts_file_is: 'あなたのhostsファイルはこちらにあります:', zoom: 'ズーム', zoom_in: '拡大', zoom_out: '縮小', } export default lang ================================================ FILE: src/common/i18n/languages/ko.ts ================================================ /** * @author: wooklab */ export default { _app_name: 'SwitchHosts', _key: 'ko', _name: '한국어', about: '정보', acknowledgement: '승인', advanced: '고급', all: '전체', append: '추가', auto_refresh: '자동 새로고침', btn_cancel: '취소', btn_ok: '확인', change: '수정', check_update: '업데이트 확인', choice_mode: '선택 모드', choice_mode_default: '기본값', choice_mode_desc: '최상위 항목에만 유효하며, 각 폴더는 고유의 선택 모드를 설정할 수 있습니다.', choice_mode_multiple: '다중모드', choice_mode_single: '단일모드', choices: '선택', chosen: '선택됨', clear_history: '이력 삭제', click_to_open: '클릭하여 열기', close: '닫기', colon: ': ', commands: '명령어', commands_help: '호스트를 적용하면 시스템에 명령어가 실행됩니다:', commands_title: '호스트가 적용된 후 명령어', comment_current_line: '주석 현재 줄', content: '내용', copy: '복사', cut: '자르기', day: '일', days: '일', delete: '삭제', download: '다운로드', edit: '수정', export: '내보내기', export_done: '내보내기가 완료되었습니다.', fail: '실패!', feedback: '피드백', file: '파일', find: '찾기', find_all: '전체 찾기', find_and_replace: '찾기 및 바꾸기', find_history: '이력 찾기', folder: '폴더', front: '앞쪽', general: '일반', group: '그룹', help: '도움말', hide: '숨기기', hide_at_launch: '시작 시 숨기기', hide_dock_icon: '독(Dock) 아이콘 숨기기', hide_history: '이력 숨기기', hide_others: '다른 것 숨기기', homepage: '홈페이지', host: '호스트', hosts_add: '새로운 호스트 추가', hosts_delete: '이 호스트 삭제', hosts_delete_confirm: '현재 호스트를 삭제하시겠습니까?', hosts_edit: '호스트 수정', hosts_title: '호스트 제목', hosts_type: '호스트 유형', hosts_updated: '호스트 파일이 갱신되었습니다.', hour: '시간', hours: '시간', http_api_on: 'HTTP API 사용', http_api_on_desc: '포트 {0}에서 실행되며, Alfred와 같은 서드파티 소프트웨어를 통해 호스트를 전환하는데 사용할 수 있습니다.', http_api_only_local: 'HTTP API 127.0.0.1만 수신', ignore_case: '대소문자 무시', import: '가져오기', import_done: '가져오기가 완료되었습니다.', import_fail: '가져오기 실패!', import_from_url: 'URL에서 가져오기', is_latest_version_inform: '좋아요, 최신 버전을 실행 중입니다.!', check_update_failed: '업데이트 확인에 실패했습니다!', update_download_now: '업데이트 다운로드', update_install_now: '설치 후 다시 시작', update_downloading_desc: '버전 {0} 다운로드 중: {1}', update_ready_desc: '버전 {0} 다운로드가 완료되었으며 설치할 수 있습니다.', item_found: '{0}개의 항목을 찾았습니다.', items: '항목', items_found: '{0}개의 항목들을 찾았습니다.', language: '언어', last_refresh: '마지막 새로고침: ', latest_version_desc: '최신버전: {0}', line: '줄', lines: '줄들', loading: '로딩중...', local: '로컬', match: '일치', migrate_confirm: 'SwitchHosts v4.0는 새로운 데이터 저장 형식을 사용합니다. 이전 데이터를 새 형식으로 마이그레이션(이전)하시겠습니까?', migrate_data: '데이터 이전', minimize: '최소화', minute: '분', minutes: '분', move_items_to_trashcan: '{0}개의 항목이 휴지통으로 이동', move_to_trashcan: '휴지통으로 이동', multi_chose_folder_switch_all: '하위 항목을 제어할 다중 선택 폴더 스위치', need_to_relaunch: '다시 시작해야 함', need_to_relaunch_after_setting_changed: '설정이 변경되었으며 앱을 다시 시작한 후에 적용됩니다.', never: '절대', new: '신규', new_version_found: '새 버전을 찾았습니다', next: '다음', no_access_to_hosts: '호스트 파일 쓰기 권한이 없습니다.', no_record: '레코드 없음', overwrite: '덮어쓰기', password: '패스워드', paste: '붙여넣기', port: '포트', preferences: '설정', previous: '이전', protocol: '프로토콜', proxy: '프록시', quit: '종료', read_only: '읽기 전용', redo: '재실행', refresh: '새로고침', regexp: '정규식', reload: '새로고침', remote: '리모트', remove_duplicate_records: '중복 레코드 삭제', remove_duplicate_records_desc: '만약 도메인이 여러 IP를 가리키는 경우, 첫 번째 IP만 적용되며, 나머지는 주석처리됩니다.', replace: '대체하기', replace_all: '전체 대체하기', replace_history: '이력 교체', reset: '재설정', reset_data_dir_confirm: '이 데이터 폴더를 기본 경로({0})로 복원하시겠습니까?', reset_zoom: '확대 재설정', search: '찾기', select_all: '전체 선택', selected: '선택', show_dock_icon: '독(Dock) 아이콘 보기', show_history: '이력 보기', show_main_window: '메인 창 보기', show_title_on_tray: '트레이에 제목 표시', source_code: '소스코드', success: '성공!', sudo_prompt_title: 'sudo password 입력', system_hosts: '시스템 호스트', system_hosts_history: '시스템 호스트의 이력버전', system_hosts_history_delete_confirm: '이 항목을 삭제하시겠습니까?', system_hosts_history_help: '이력 개수가 이 제한을 초과하면 가장 오래된 이력부터 삭제됩니다.', system_hosts_history_limit: '이력 최대 개수: ', test: '테스트', theme: '테마', theme_dark: '다크', theme_light: '라이트', title: '제목', to_show_source: '더블 클릭하여 소스코드를 표시합니다.', toggle_developer_tools: '개발자 도구 전환', toggle_dock_icon: '독(DocK) 아이콘 전환', toggle_full_screen: '전체화면 전환', trashcan: '휴지통', trashcan_clear: '휴지통 비우기', trashcan_clear_confirm: '휴지통을 비우시겠습니까?', trashcan_delete_confirm: '이 항목을 완전히 삭제하시겠습니까?', trashcan_restore: '복구', tray_mini_window: '작업 표시줄 아이콘 바로가기', undo: '실행취소', unhide: '숨김해제', untitled: '제목없음', url_placeholder: 'http:// or https:// or file://', usage_data_agree: '익명화된 사용 데이터 제출에 동의합니다', usage_data_help: '주기적으로 익명의 사용 데이터를 제출하여 SwitchHost를 개선하는 데 도움을 주시겠습니까?', usage_data_title: 'SwitchHosts개선!', use_proxy: '프록시 사용', use_system_window_frame: '시스템 창을 사용하려면, 프로그램 재시작이 필요합니다', view: '뷰', where_is_my_data: '내 데이터는 어디에 저장되나요?', where_is_my_hosts: '내 호스트 파일은 어디에 있나요?', window: '창', write_mode: '쓰기 모드', write_mode_append_help: '시스템 호스트 파일 마지막 줄에 새 레코드를 추가합니다.', write_mode_overwrite_help: '시스템 호스트 파일에 새 레코드로 덮어씁니다.', write_mode_set: '쓰기 모드 설정', your_data_is: '데이터 저장 위치:', your_hosts_file_is: '호스트 파일 위치:', zoom: '확대', zoom_in: '확대', zoom_out: '축소', } ================================================ FILE: src/common/i18n/languages/pl.ts ================================================ /** * @author: piteriuz * @homepage: https://piotr.pienkowski.pl/ */ export default { _app_name: 'SwitchHosts', _key: 'pl', _name: 'Polski', about: 'O aplikacji', acknowledgement: 'Podziękowania', advanced: 'Zaawansowane', all: 'Wszystko', append: 'Dołącz', auto_refresh: 'Automatyczne odświeżanie', btn_cancel: 'Anuluj', btn_ok: 'OK', change: 'Zmień', check_update: 'Sprawdź aktualizacje', choice_mode: 'Tryb wyboru', choice_mode_default: 'Domyślny', choice_mode_desc: 'Obowiązuje tylko dla elementu na górze, każdy folder może mieć własny tryb wyboru.', choice_mode_multiple: 'Wiele', choice_mode_single: 'Jeden', choices: 'Wybory', chosen: 'Wybrane', clear_history: 'Wyczyść historię', click_to_open: 'Kliknij, aby otworzyć', close: 'Zamknij', colon: ': ', commands: 'Polecenia', commands_help: 'Poniższe polecenia systemowe będą wykonane po zastosowaniu Hosts:', commands_title: 'Polecenie po zastosowaniu hosts', comment_current_line: 'Skomentuj bieżącą linię', content: 'Zawartość', copy: 'Kopiuj', cut: 'Wytnij', day: 'dzień', days: 'dni', delete: 'Usuń', download: 'Pobierz', edit: 'Edytuj', export: 'Eksportuj', export_done: 'Eksport został ukończony.', fail: 'Błąd!', feedback: 'Opinia', file: 'Plik', find: 'Znajdź', find_all: 'Znajdź wszystkie', find_and_replace: 'Znajdź i zamień', find_history: 'Historia wyszukiwania', folder: 'Folder', front: 'Przód', general: 'Ogólne', group: 'Grupa', help: 'Pomoc', hide: 'Ukryj', hide_at_launch: 'Ukryj przy uruchomieniu', hide_dock_icon: 'Ukryj ikonę docka', hide_history: 'Ukryj historię', hide_others: 'Ukryj inne', homepage: 'Strona główna', host: 'Host', hosts_add: 'Dodaj nowe hosty', hosts_delete: 'Usuń ten hosts', hosts_delete_confirm: 'Czy na pewno chcesz usunąć bieżące hosty?', hosts_edit: 'Edytuj hosty', hosts_title: 'Nazwa hosts', hosts_type: 'Typ hosts', hosts_updated: 'Plik Hosts został zaktualizowany.', hour: 'godzina', hours: 'godziny', http_api_on: 'HTTP API włączone', 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.', http_api_only_local: 'HTTP API nasłuchuje tylko na 127.0.0.1', ignore_case: 'Ignoruj wielkość liter', import: 'Importuj', import_done: 'Import został ukończony.', import_fail: 'Import nie powiódł się!', import_from_url: 'Importuj z adresu URL', is_latest_version_inform: 'Świetnie, masz najnowszą wersję!', check_update_failed: 'Sprawdzanie aktualizacji nie powiodło się!', update_download_now: 'Pobierz aktualizację', update_install_now: 'Zainstaluj i uruchom ponownie', update_downloading_desc: 'Pobieranie wersji {0}: {1}', update_ready_desc: 'Wersja {0} została pobrana i jest gotowa do instalacji.', item_found: 'Znaleziono {0} element.', items: 'elementy', items_found: 'Znaleziono {0} elementów.', language: 'Język', last_refresh: 'Ostatnie odświeżenie: ', latest_version_desc: 'Najnowsza wersja to: {0}', line: 'linia', lines: 'linie', loading: 'Ładowanie...', local: 'Lokalny', match: 'Dopasuj', migrate_confirm: 'SwitchHosts v4.0 używa nowego formatu przechowywania danych, czy chcesz migrować stare dane do nowego formatu?', migrate_data: 'Migruj dane', minimize: 'Minimalizuj', minute: 'minuta', minutes: 'minuty', move_items_to_trashcan: 'Przenieś {0} elementy do kosza', move_to_trashcan: 'Przenieś do kosza', multi_chose_folder_switch_all: 'wielokrotny wybór folderu do kontroli podelementów', need_to_relaunch: 'Wymagane ponowne uruchomienie', need_to_relaunch_after_setting_changed: 'Ustawienie zostało zmienione i wejdzie w życie po ponownym uruchomieniu aplikacji.', never: 'Nigdy', new: 'Nowy', new_version_found: 'Znaleziono nową wersję', next: 'Dalej', no_access_to_hosts: 'Brak uprawnień do zapisu w pliku Hosts.', no_record: 'Brak rekordu', overwrite: 'Nadpisz', password: 'Hasło', paste: 'Wklej', port: 'Port', preferences: 'Preferencje', previous: 'Wstecz', protocol: 'Protokół', proxy: 'Proxy', quit: 'Zamknij', read_only: 'Tylko do odczytu', redo: 'Powtórz', refresh: 'Odśwież', regexp: 'Wyrażenie regularne', reload: 'Załaduj ponownie', remote: 'Zdalny', remove_duplicate_records: 'Usuń zduplikowane rekordy', 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.', replace: 'Zamień', replace_all: 'Zamień wszystko', replace_history: 'Historia zamiany', reset: 'Resetuj', reset_data_dir_confirm: 'Czy na pewno chcesz przywrócić folder danych do adresu domyślnego ({0})?', reset_zoom: 'Resetuj powiększenie', search: 'Szukaj', select_all: 'Zaznacz wszystko', selected: 'Wybrane', show_dock_icon: 'Pokaż ikonę docka', show_history: 'Pokaż historię', show_main_window: 'Pokaż główne okno', show_title_on_tray: 'Pokaż tytuł na pasku zadań', source_code: 'Kod źródłowy', success: 'Sukces!', sudo_prompt_title: 'Wpisz swoje hasło sudo', system_hosts: 'System Hosts', system_hosts_history: 'Historyczne wersje System Hosts', system_hosts_history_delete_confirm: 'Czy na pewno chcesz usunąć ten element?', system_hosts_history_help: 'Jeśli całkowita liczba rekordów historycznych przekroczy ten limit, najstarszy rekord zostanie usunięty.', system_hosts_history_limit: 'Maksymalna liczba rekordów: ', test: 'Test', theme: 'Motyw', theme_dark: 'Ciemny', theme_light: 'Jasny', title: 'Tytuł', to_show_source: 'Kliknij dwukrotnie, aby wyświetlić kod źródłowy.', toggle_developer_tools: 'Przełącz narzędzia deweloperskie', toggle_dock_icon: 'Przełącz ikonę docka', toggle_full_screen: 'Przełącz pełny ekran', trashcan: 'Kosz', trashcan_clear: 'Opróżnij kosz', trashcan_clear_confirm: 'Czy na pewno chcesz opróżnić kosz?', trashcan_delete_confirm: 'Czy chcesz całkowicie usunąć ten element?', trashcan_restore: 'Przywróć', tray_mini_window: 'skrót ikony paska zadań', undo: 'Cofnij', unhide: 'Pokaż', untitled: 'Bez tytułu', url_placeholder: 'http:// lub https:// lub file://', usage_data_agree: 'Tak, prześlij anonimowe dane użytkowania', usage_data_help: 'Czy chcesz nam pomóc ulepszyć SwitchHosts, okresowo przesyłając anonimowe dane użytkowania?', usage_data_title: 'Uczynić SwitchHosts lepszym!', use_proxy: 'Użyj proxy', use_system_window_frame: 'Używaj systemowych ramek okna, wymagane ponowne uruchomienie aplikacji', view: 'Widok', where_is_my_data: 'Gdzie są przechowywane moje dane?', where_is_my_hosts: 'Gdzie znajduje się mój plik hosts?', window: 'Okno', write_mode: 'Tryb zapisu', write_mode_append_help: 'Dołącz nowe rekordy na koniec systemowego pliku hosts.', write_mode_overwrite_help: 'Nadpisz plik hosts systemu nowymi rekordami.', write_mode_set: 'Ustaw tryb zapisu', your_data_is: 'Twoje pliki danych są przechowywane w:', your_hosts_file_is: 'Twój plik hosts znajduje się w:', zoom: 'Powiększenie', zoom_in: 'Powiększ', zoom_out: 'Pomniejsz', } ================================================ FILE: src/common/i18n/languages/tr.ts ================================================ /** * @author: baris * @homepage: https://barisuzun.com.tr */ export default { _app_name: 'SwitchHosts', _key: 'tr', _name: 'Türkçe', about: 'Hakkında', acknowledgement: 'Teşekkür', advanced: 'Gelişmiş', all: 'Tümü', append: 'Ekle', auto_refresh: 'Otomatik Yenile', btn_cancel: 'İptal', btn_ok: 'Tamam', change: 'Değiştir', check_update: 'Güncellemeleri Kontrol Et', choice_mode: 'Seçim Modu', choice_mode_default: 'Varsayılan', choice_mode_desc: 'Sadece en üstteki öğe için geçerlidir, her klasör kendi seçim modunu ayarlayabilir.', choice_mode_multiple: 'Çoklu', choice_mode_single: 'Tekli', choices: 'Seçenekler', chosen: 'Seçildi', clear_history: 'Geçmişi Temizle', click_to_open: 'Açmak için tıkla', close: 'Kapat', colon: ': ', commands: 'Komutlar', commands_help: 'Hosts uygulandığında aşağıdaki sistem komutları çalıştırılacaktır:', commands_title: 'Hosts uygulandıktan sonra komut', comment_current_line: 'Mevcut satırı yorumla', content: 'İçerik', copy: 'Kopyala', cut: 'Kes', day: 'gün', days: 'günler', delete: 'Sil', download: 'İndir', edit: 'Düzenle', export: 'Dışa Aktar', export_done: 'Dışa aktarma tamamlandı.', fail: 'Başarısız!', feedback: 'Geri Bildirim', file: 'Dosya', find: 'Bul', find_all: 'Hepsini Bul', find_and_replace: 'Bul ve Değiştir', find_history: 'Arama Geçmişi', folder: 'Klasör', front: 'Ön', general: 'Genel', group: 'Grup', help: 'Yardım', hide: 'Gizle', hide_at_launch: 'Başlangıçta Gizle', hide_dock_icon: 'Dock simgesini gizle', hide_history: 'Geçmişi Gizle', hide_others: 'Diğerlerini Gizle', homepage: 'Anasayfa', host: 'Host', hosts_add: 'Yeni host ekle', hosts_delete: 'Bu hostu sil', hosts_delete_confirm: 'Mevcut hostu silmek istediğinizden emin misiniz?', hosts_edit: 'Hostları düzenle', hosts_title: 'Host Başlığı', hosts_type: 'Host Türü', hosts_updated: 'Host dosyası güncellendi.', hour: 'saat', hours: 'saatler', http_api_on: 'HTTP API açık', 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.', http_api_only_local: 'HTTP API sadece 127.0.0.1’i dinler', ignore_case: 'Büyük/Küçük Harf Duyarsız', import: 'İçe Aktar', import_done: 'İçe aktarma tamamlandı.', import_fail: 'İçe aktarma başarısız!', import_from_url: 'URL’den İçe Aktar', is_latest_version_inform: 'Harika, en güncel sürümü kullanıyorsunuz!', check_update_failed: 'Güncellemeleri kontrol etme başarısız!', update_download_now: 'Güncellemeyi indir', update_install_now: 'Yükle ve yeniden başlat', update_downloading_desc: '{0} sürümü indiriliyor: {1}', update_ready_desc: '{0} sürümü indirildi ve kuruluma hazır.', item_found: '{0} öğe bulundu.', items: 'öğeler', items_found: '{0} öğe bulundu.', language: 'Dil', last_refresh: 'Son yenileme: ', latest_version_desc: 'En son sürüm: {0}', line: 'satır', lines: 'satırlar', loading: 'Yükleniyor...', local: 'Yerel', match: 'Eşleşme', migrate_confirm: 'SwitchHosts v4.0 yeni bir veri depolama formatı kullanıyor, eski verileri yeni formata taşımak ister misiniz?', migrate_data: 'Veri Taşı', minimize: 'Küçült', minute: 'dakika', minutes: 'dakikalar', move_items_to_trashcan: 'Çöp kutusuna {0} öğe taşı', move_to_trashcan: 'Çöp Kutusuna Taşı', multi_chose_folder_switch_all: 'çoklu seçim klasörü, alt öğelerin kontrolünü sağlar', need_to_relaunch: 'Yeniden başlatılması gerekiyor', need_to_relaunch_after_setting_changed: 'Ayar değiştirildi ve uygulama yeniden başlatıldıktan sonra etkili olacak.', never: 'Asla', new: 'Yeni', new_version_found: 'Yeni sürüm bulundu', next: 'Sonraki', no_access_to_hosts: 'Hosts dosyasına yazma izni yok.', no_record: 'Kayıt yok', overwrite: 'Üzerine Yaz', password: 'Parola', paste: 'Yapıştır', port: 'Port', preferences: 'Tercihler', previous: 'Önceki', protocol: 'Protokol', proxy: 'Proxy', quit: 'Çıkış', read_only: 'Salt Okunur', redo: 'Yinele', refresh: 'Yenile', regexp: 'Düzenli İfade', reload: 'Yeniden Yükle', remote: 'Uzak', remove_duplicate_records: 'Yinelenen kayıtları kaldır', remove_duplicate_records_desc: 'Bir alan birden fazla IP\'ye işaret ediyorsa, sadece ilk olanı etkili olacak ve sonrakiler yorum olarak dönüştürülecek.', replace: 'Değiştir', replace_all: 'Hepsini Değiştir', replace_history: 'Geçmişi Değiştir', reset: 'Sıfırla', reset_data_dir_confirm: 'Veri klasörünü varsayılan adrese ({0}) geri yüklemek istediğinizden emin misiniz?', reset_zoom: 'Yakınlaştırmayı Sıfırla', search: 'Ara', select_all: 'Hepsini Seç', selected: 'Seçildi', show_dock_icon: 'Dock simgesini göster', show_history: 'Geçmişi Göster', show_main_window: 'Ana pencereyi göster', show_title_on_tray: 'Görev çubuğunda başlığı göster', source_code: 'Kaynak Kod', success: 'Başarılı!', sudo_prompt_title: 'Sudo parolanızı girin', system_hosts: 'Sistem Hostları', system_hosts_history: 'Sistem Hostlarının geçmiş sürümleri', system_hosts_history_delete_confirm: 'Bu öğeyi silmek istediğinizden emin misiniz?', system_hosts_history_help: 'Toplam kayıt sayısı bu sınırları aşarsa, en eski kayıt silinecektir.', system_hosts_history_limit: 'Maksimum kayıt sayısı: ', test: 'Test', theme: 'Tema', theme_dark: 'Karanlık', theme_light: 'Aydınlık', title: 'Başlık', to_show_source: 'Kaynak kodunu göstermek için çift tıklayın.', toggle_developer_tools: 'Geliştirici Araçlarını Aç/Kapat', toggle_dock_icon: 'Dock simgesini aç/kapat', toggle_full_screen: 'Tam ekranı aç/kapat', trashcan: 'Çöp Kutusu', trashcan_clear: 'Çöp kutusunu boşalt', trashcan_clear_confirm: 'Çöp kutusunu boşaltmak istediğinizden emin misiniz?', trashcan_delete_confirm: 'Bu öğeyi tamamen silmek istiyor musunuz?', trashcan_restore: 'Geri Yükle', tray_mini_window: 'Görev çubuğu simgesi kısayolu', undo: 'Geri Al', unhide: 'Gizlemeyi Kaldır', untitled: 'Başlıksız', url_placeholder: 'http:// veya https:// veya file://', usage_data_agree: 'Evet, anonimleştirilmiş kullanım verilerini gönder', usage_data_help: 'Anonim kullanım verilerini periyodik olarak göndererek SwitchHosts\'u iyileştirmemize yardımcı olmak ister misiniz?', usage_data_title: 'SwitchHosts\'u Daha İyi Yapın!', use_proxy: 'Proxy Kullan', use_system_window_frame: 'Sistem pencere çerçevesini kullanın, uygulamanın yeniden başlatılması gereklidir', view: 'Görüntüle', where_is_my_data: 'Verilerim nerede saklanıyor?', where_is_my_hosts: 'Hosts dosyam nerede?', window: 'Pencere', write_mode: 'Yazma modu', write_mode_append_help: 'Yeni kayıtları sistem hosts dosyasının sonuna ekleyin.', write_mode_overwrite_help: 'Yeni kayıtlarla sistem hosts dosyasını üzerine yazın.', write_mode_set: 'Yazma modunu ayarla', your_data_is: 'Veri dosyalarınız şurada saklanıyor:', your_hosts_file_is: 'Hosts dosyanız şu konumda bulunuyor:', zoom: 'Yakınlaştır', zoom_in: 'Yakınlaştır', zoom_out: 'Uzaklaştır', } ================================================ FILE: src/common/i18n/languages/zh-hant.ts ================================================ /** * @author: rayatn1011 * @homepage: https://github.com/rayatn1011 */ import { LanguageDict } from '@common/types' const lang: LanguageDict = { _app_name: 'SwitchHosts', _key: 'zh-hant', _name: '中文', about: '關於', acknowledgement: '特別感謝', advanced: '進階', all: '全部', append: '附加', auto_refresh: '自動更新', btn_cancel: '取消', btn_ok: '確定', change: '修改', check_update: '檢查更新', choice_mode: '選擇模式', choice_mode_default: '預設', choice_mode_desc: '只對頂層項目有效,每個資料夾可設定自己的選擇模式。', choice_mode_multiple: '多選', choice_mode_single: '單選', choices: '選項', chosen: '已選', clear_history: '清除歷史紀錄', click_to_open: '點擊開啟', close: '關閉', colon: ':', commands: '指令', commands_help: '每次 Hosts 應用後將執行下面的系統指令:', commands_title: 'Hosts 應用後指令', comment_current_line: '註解當前行', content: '內容', copy: '複製', cut: '剪下', day: '天', days: '天', delete: '刪除', download: '下載', edit: '編輯', export: '匯出', export_done: '匯出已完成。', fail: '操作失敗!', feedback: '意見回饋', file: '檔案', find: '尋找', find_all: '尋找所有', find_and_replace: '尋找並替換', find_history: '尋找歷史', folder: '資料夾', front: '前置', general: '一般', group: '群組', help: 'Help', hide: '隱藏', hide_at_launch: '啟動時隱藏', hide_dock_icon: '隱藏工作列圖示', hide_history: '隱藏歷史紀錄', hide_others: '隱藏其他', homepage: '首頁', host: '主機', hosts_add: '新增 hosts', hosts_delete: '刪除當前方案', hosts_delete_confirm: '確定要刪除當前方案嗎?', hosts_edit: '編輯 hosts', hosts_title: 'Hosts 標題', hosts_type: 'Hosts 類型', hosts_updated: 'Hosts 檔案已更新。', hour: '小時', hours: '小時', http_api_on: '開啟 HTTP API', http_api_on_desc: '運行於 {0} 通訊埠,可用於 Alfred 等第三方應用切換 hosts。', http_api_only_local: 'HTTP API 僅監聽 127.0.0.1', ignore_case: '忽略大小寫', import: '匯入', import_done: '匯入已完成。', import_fail: '匯入失敗!', import_from_url: '從 URL 匯入', is_latest_version_inform: '太棒了,你正在執行的是最新版本!', check_update_failed: '檢查更新失敗!', update_download_now: '下載更新', update_install_now: '安裝並重新啟動', update_downloading_desc: '正在下載版本 {0}:{1}', update_ready_desc: '版本 {0} 已下載完成,可以開始安裝。', item_found: '{0} 項符合', items: '項', items_found: '{0} 項符合', language: '語言', last_refresh: '最後更新:', latest_version_desc: '最新的版本為:{0}', line: '行', lines: '行', loading: '載入中...', local: '本地', match: '符合', migrate_confirm: 'SwitchHosts v4.0 使用了新的資料儲存格式,是否遷移舊資料到新格式?', migrate_data: '遷移資料', minimize: '最小化', minute: '分鐘', minutes: '分鐘', move_items_to_trashcan: '移動 {0} 項到垃圾桶', move_to_trashcan: '移到垃圾桶', multi_chose_folder_switch_all: '多選資料夾開關控制子項目', need_to_relaunch: '需要重啟', need_to_relaunch_after_setting_changed: '設定已更改,應用重啟後生效。', never: '永不', new: '新建', new_version_found: '發現新版本', next: '下一個', no_access_to_hosts: '沒有寫入 Hosts 檔案的權限。', no_record: '沒有紀錄', overwrite: '覆寫', password: '密碼', paste: '貼上', port: '通訊埠', preferences: '選項', previous: '上一個', protocol: '協議', proxy: '代理', quit: '退出', read_only: '唯讀', redo: '重做', refresh: '更新', regexp: '正規表達式', reload: '重載', remote: '遠端', remove_duplicate_records: '移除重複的紀錄', remove_duplicate_records_desc: '如果一個網域指向多個 IP,只有第一條會生效,剩下的將被轉為註解。', replace: '替換', replace_all: '替換全部', replace_history: '替換歷史', reset: '重設', reset_data_dir_confirm: '確定要把資料夾重設為預設路徑嗎?({0})?', reset_zoom: '重設縮放', search: '搜尋', select_all: '全選', selected: '已選', show_dock_icon: '顯示工作列圖示', show_history: '顯示歷史紀錄', show_main_window: '顯示主視窗', show_title_on_tray: '在通知區域顯示標題', source_code: '原始碼', success: '操作成功!', sudo_prompt_title: '請輸入你的登入密碼(sudo 密碼)', system_hosts: '系統 Hosts', system_hosts_history: '系統 Hosts 歷史版本', system_hosts_history_delete_confirm: '確定要刪除該項紀錄嗎?', system_hosts_history_help: '如果歷史紀錄的總數超過這個限制,最舊的紀錄將被刪除。', system_hosts_history_limit: '最大紀錄數:', test: '測試', theme: '主題', theme_dark: '深色', theme_light: '亮色', title: '標題', to_show_source: '雙擊顯示原始碼。', toggle_developer_tools: '切換開發者工具', toggle_dock_icon: '顯示/隱藏任務列圖示', toggle_full_screen: '切換全螢幕', trashcan: '垃圾桶', trashcan_clear: '清除垃圾桶', trashcan_clear_confirm: '確定要清除垃圾桶嗎?', trashcan_delete_confirm: '要完全清除本項嗎?', trashcan_restore: '復原', tray_mini_window: '任務列快捷視窗', undo: '取消', unhide: '取消隱藏', untitled: '未命名', url_placeholder: 'http:// 或 https:// 或 file://', usage_data_agree: '好的,寄送匿名的使用資料', usage_data_help: '您願意寄送匿名的使用資料來幫助我們改善 SwitchHosts 嗎?資料中不會包含任何隱私資訊。', usage_data_title: '幫助改善 SwitchHosts', use_proxy: '使用代理', use_system_window_frame: '使用系統視窗外框,需要重啟程式', view: '視窗', where_is_my_data: '我的資料儲存在哪裡?', where_is_my_hosts: '我的 hosts 檔案在哪裡?', window: 'Window', write_mode: '寫入模式', write_mode_append_help: '新紀錄將附加到現有系統 hosts 檔案後面。', write_mode_overwrite_help: '新紀錄將覆寫現有系統 hosts 檔案。', write_mode_set: '設定寫入模式', your_data_is: '你的資料在:', your_hosts_file_is: '你的 hosts 檔案在:', zoom: '縮放', zoom_in: '放大', zoom_out: '縮小', } export default lang ================================================ FILE: src/common/i18n/languages/zh.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { LanguageDict } from '@common/types' const lang: LanguageDict = { _app_name: 'SwitchHosts', _key: 'zh', _name: '中文', about: '关于', acknowledgement: '特别致谢', advanced: '高级', all: '全部', append: '追加', auto_refresh: '自动刷新', btn_cancel: '取消', btn_ok: '确定', change: '更改', check_update: '检查更新', choice_mode: '选择模式', choice_mode_default: '默认', choice_mode_desc: '只对顶层项目生效,每个文件夹可设置自己的选择模式。', choice_mode_multiple: '多选', choice_mode_single: '单选', choices: '选项', chosen: '已选', clear_history: '清除历史记录', click_to_open: '点击打开', close: '关闭', colon: ':', commands: '命令', commands_help: '每次 Hosts 应用后将执行下面的系统命令:', commands_title: 'Hosts 应用后命令', comment_current_line: '注释当前行', content: '内容', copy: '复制', cut: '剪切', day: '天', days: '天', delete: '删除', download: '下载', edit: '编辑', export: '导出', export_done: '导出已完成。', fail: '操作失败!', feedback: '意见反馈', file: '文件', find: '查找', find_all: '查找所有', find_and_replace: '查找并替换', find_history: '查找历史', folder: '文件夹', front: '前置', general: '通用', group: '组合', help: 'Help', hide: '隐藏', hide_at_launch: '启动时隐藏', hide_dock_icon: '隐藏任务栏图标', hide_history: '隐藏历史记录', hide_others: '隐藏其他', homepage: '主页', host: '主机', hosts_add: '添加 hosts', hosts_delete: '删除当前方案', hosts_delete_confirm: '确实要删除当前方案吗?', hosts_edit: '编辑 hosts', hosts_title: 'Hosts 标题', hosts_type: 'Hosts 类型', hosts_updated: 'Hosts 文件已更新。', hour: '小时', hours: '小时', http_api_on: '开启 HTTP API', http_api_on_desc: '运行于 {0} 端口,可用于 Alfred 等第三方软件切换 hosts。', http_api_only_local: 'HTTP API 仅监听 127.0.0.1', ignore_case: '忽略大小写', import: '导入', import_done: '导入已完成。', import_fail: '导入失败!', import_from_url: '从 URL 导入', is_latest_version_inform: '太棒了,你正在运行的是最新版本!', check_update_failed: '检查更新失败!', update_download_now: '下载更新', update_install_now: '安装并重启', update_downloading_desc: '正在下载版本 {0}:{1}', update_ready_desc: '版本 {0} 已下载完成,可以开始安装。', item_found: '{0} 项匹配', items: '项', items_found: '{0} 项匹配', language: '语言', last_refresh: '最后刷新:', latest_version_desc: '最新的版本为:{0}', line: '行', lines: '行', loading: '加载中...', local: '本地', match: '匹配', migrate_confirm: 'SwitchHosts v4.0 使用了新的数据存储格式,是否迁移旧数据为新格式?', migrate_data: '迁移数据', minimize: '最小化', minute: '分钟', minutes: '分钟', move_items_to_trashcan: '移动 {0} 项到回收站', move_to_trashcan: '移到回收站', multi_chose_folder_switch_all: '多选文件夹开关控制子项目', need_to_relaunch: '需要重启', need_to_relaunch_after_setting_changed: '设置已更改,应用重启后生效。', never: '从不', new: '新建', new_version_found: '发现新版本', next: '下一个', no_access_to_hosts: '没有写入 Hosts 文件的权限。', no_record: '没有记录', overwrite: '覆盖', password: '密码', paste: '粘贴', port: '端口', preferences: '选项', previous: '上一个', protocol: '协议', proxy: '代理', quit: '退出', read_only: '只读', redo: '重做', refresh: '刷新', regexp: '正则表达式', reload: '重载', remote: '远程', remove_duplicate_records: '移除重复的记录', remove_duplicate_records_desc: '如果一个域名指向多个 IP,只有第一条会生效,后面的将被转为注释。', replace: '替换', replace_all: '替换所有', replace_history: '替换历史', reset: '重置', reset_data_dir_confirm: '确定要把数据文件夹重置为默认地址吗?({0})?', reset_zoom: '重置缩放', search: '搜索', select_all: '全选', selected: '已选', show_dock_icon: '显示任务栏图标', show_history: '显示历史记录', show_main_window: '显示主窗口', show_title_on_tray: '在系统托盘显示标题', source_code: '源码', success: '操作成功!', sudo_prompt_title: '请输入你的登录密码(sudo 密码)', system_hosts: '系统 Hosts', system_hosts_history: '系统 Hosts 历史版本', system_hosts_history_delete_confirm: '确实要删除该项记录吗?', system_hosts_history_help: '如果历史记录的总数超过这个限制,最老的记录将被删除。', system_hosts_history_limit: '最大记录数:', test: '测试', theme: '主题', theme_dark: '夜间', theme_light: '明亮', title: '标题', to_show_source: '双击显示源代码。', toggle_developer_tools: '切换开发者工具', toggle_dock_icon: '显示/隐藏任务栏图标', toggle_full_screen: '切换全屏', trashcan: '回收站', trashcan_clear: '清空回收站', trashcan_clear_confirm: '确实要清空回收站吗?', trashcan_delete_confirm: '要彻底删除本项吗?', trashcan_restore: '还原', tray_mini_window: '任务栏快捷小窗', undo: '撤销', unhide: '取消隐藏', untitled: '未命名', url_placeholder: 'http:// 或 https:// 或 file://', usage_data_agree: '好的,发送匿名的使用数据', usage_data_help: '您愿意发送匿名的使用数据来帮助我们改进 SwitchHosts 吗?数据中不会包含任何隐私信息。', usage_data_title: '帮助改进 SwitchHosts', use_proxy: '使用代理', use_system_window_frame: '使用系统窗口框架,需要重启程序', view: '视图', where_is_my_data: '我的数据存储在哪里?', where_is_my_hosts: '我的 hosts 文件在哪里?', window: 'Window', write_mode: '写入模式', write_mode_append_help: '新记录将追加到现有系统 hosts 文件末尾。', write_mode_overwrite_help: '新记录将覆盖现有系统 hosts 文件。', write_mode_set: '设置写入模式', your_data_is: '你的数据在:', your_hosts_file_is: '你的 hosts 文件在:', zoom: '缩放', zoom_in: '放大', zoom_out: '缩小', } export default lang ================================================ FILE: src/common/newlines.ts ================================================ export type LineEnding = '\n' | '\r\n' const LINE_ENDING_RE = /\r\n?/g export function normalizeLineEndings(content: string): string { return content.replace(LINE_ENDING_RE, '\n') } export function getLineEndingForPlatform(platform = process.platform): LineEnding { if (platform === 'win32') { return '\r\n' } return '\n' } export function restoreLineEndings(content: string, lineEnding: LineEnding): string { const normalized = normalizeLineEndings(content) if (lineEnding === '\r\n') { return normalized.replace(/\n/g, '\r\n') } return normalized } ================================================ FILE: src/common/normalize.ts ================================================ /** * normalize * @author: oldj * @homepage: https://oldj.net */ import * as os from 'os' const default_options = { remove_duplicate_records: false, } export type INormalizeOptions = Partial interface IHostsLineObj { ip: string domains: string[] comment: string } interface IDomainsIPMap { [domain: string]: string } export const parseLine = (line: string): IHostsLineObj => { let [cnt, ...cmt] = line.split('#') let comment = cmt.join('#').trim() let [ip, ...domains] = cnt.trim().replace(/\s+/g, ' ').split(' ') return { ip, domains, comment } } export const formatLine = (o: Partial): string => { let comment = o.comment || '' if (comment) { comment = '# ' + comment } return [o.ip || '', (o.domains || []).join(' '), comment].join(' ').trim() } const removeDuplicateRecords = (content: string): string => { let domain_ip_map: IDomainsIPMap = {} let lines = content.split('\n') let new_lines: string[] = [] lines.map((line) => { let { ip, domains, comment } = parseLine(line) if (!ip || domains.length === 0) { new_lines.push(line) return } const ipv = /:/.test(ip) ? 6 : 4 let new_domains: string[] = [] let duplicate_domains: string[] = [] domains.map((domain) => { const domain_v = `${domain}_${ipv}` if (domain_v in domain_ip_map) { duplicate_domains.push(domain) } else { new_domains.push(domain) domain_ip_map[domain_v] = ip } }) if (new_domains.length > 0) { new_lines.push(formatLine({ ip, domains: new_domains, comment })) } if (duplicate_domains.length > 0) { new_lines.push( formatLine({ comment: 'invalid hosts (repeated): ' + formatLine({ ip, domains: duplicate_domains }), }), ) } }) return new_lines.join(os.EOL) } export default ( hosts_content: string, options: INormalizeOptions = {}, ): string => { // 在这儿执行去重等等操作 if (options.remove_duplicate_records) { hosts_content = removeDuplicateRecords(hosts_content) } return hosts_content } ================================================ FILE: src/common/tree.ts ================================================ export type NodeIdType = string export interface ITreeNodeData { id: NodeIdType title?: string can_select?: boolean // 是否可以被选中,默认为 true can_drag?: boolean // 是否可以拖动,默认为 true can_drop_before?: boolean // 是否可以接受 drop before,默认为 true can_drop_in?: boolean // 是否可以接受 drop in,默认为 true can_drop_after?: boolean // 是否可以接受 drop after,默认为 true is_collapsed?: boolean children?: ITreeNodeData[] [key: string]: any } interface IWithChildren { children?: IWithChildren[] } export function flatten(tree_list: T[]): T[] { let arr: any[] = [] Array.isArray(tree_list) && tree_list.map((item) => { if (!item) return arr.push(item) if (Array.isArray(item.children)) { let a2 = flatten(item.children) arr = arr.concat(a2) } }) return arr } interface IWidthId extends IWithChildren { id: string } export function getNodeById( tree_list: T[], id: string, ): T | undefined { return flatten(tree_list).find((i) => i.id === id) } ================================================ FILE: src/common/types.d.ts ================================================ /** * types * @author: oldj * @homepage: https://oldj.net */ import { HostsType } from '@common/data' import { MenuItemConstructorOptions, WebContents } from 'electron' import { default as lang } from './i18n/languages/en' import * as actions from '@main/actions' export type LanguageDict = typeof lang export type LanguageKey = keyof LanguageDict export interface IActionFunc { sender: WebContents } export type Actions = typeof actions & IActionFunc export interface IMenuItemOption extends MenuItemConstructorOptions { // 参见:https://www.electronjs.org/docs/api/menu-item _click_evt?: string } export interface IPopupMenuOption { menu_id: string items: IMenuItemOption[] } export interface IFindPosition { start: number end: number line: number line_pos: number end_line: number end_line_pos: number before: string match: string after: string } export interface IFindSplitter { before: string match: string after: string replace?: string } export interface IFindItem { item_id: string item_title: string item_type: HostsType positions: IFindPosition[] splitters: IFindSplitter[] } export type IFindShowSourceParam = IFindPosition & { item_id: string [key: string]: any } ================================================ FILE: src/common/update.ts ================================================ export interface AppUpdateInfo { version: string releaseName?: string | null releaseNotes?: string | null } export interface AppUpdateProgress { percent: number transferred: number total: number bytesPerSecond: number } export interface AppDownloadedUpdateInfo extends AppUpdateInfo { downloadedFile?: string | null } ================================================ FILE: src/common/utils/wait.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) ================================================ FILE: src/main/actions/checkUpdate.ts ================================================ import * as updater from '@main/core/updater' export default async (): Promise => { try { const update = await updater.checkUpdate() return !!update } catch (error) { console.error(error) return null } } ================================================ FILE: src/main/actions/closeMainWindow.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default async () => { let win = global.main_win win && win.isClosable() && win.close() } ================================================ FILE: src/main/actions/cmd/changeDataDir.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { app, BrowserWindow, dialog, OpenDialogOptions, OpenDialogReturnValue } from 'electron' import { localdb } from '@main/data' import getDataFolder, { getDefaultDataDir } from '@main/libs/getDataDir' import getI18N from '@main/core/getI18N' import { IActionFunc } from '@common/types' export default async function ( this: IActionFunc, to_default?: boolean, ): Promise { let { sender } = this let { lang } = await getI18N() let current_dir = getDataFolder() let dir: string = '' if (to_default) { dir = getDefaultDataDir() } else { let parent = BrowserWindow.fromWebContents(sender) if (parent?.isFullScreen()) { parent?.setFullScreen(false) } let options: OpenDialogOptions = { // title: '选择数据目录', defaultPath: current_dir, properties: ['openDirectory', 'createDirectory'], } let r: OpenDialogReturnValue if (parent) { r = await dialog.showOpenDialog(parent, options) } else { r = await dialog.showOpenDialog(options) } if (r.canceled) { return } dir = r.filePaths[0] } if (!dir || dir === current_dir) { return } await localdb.dict.local.set('data_dir', dir) dialog.showMessageBoxSync({ message: lang.need_to_relaunch_after_setting_changed, }) app.relaunch() app.exit(0) return dir } ================================================ FILE: src/main/actions/cmd/clearHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' export default async () => { return await cfgdb.collection.cmd_history.remove() } ================================================ FILE: src/main/actions/cmd/deleteHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' export default async (_id: string) => { return await cfgdb.collection.cmd_history.delete((i) => i._id === _id) } ================================================ FILE: src/main/actions/cmd/focusMainWindow.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default () => { global.main_win.show() global.main_win.focus() } ================================================ FILE: src/main/actions/cmd/getHistoryList.ts ================================================ /** * getHistoryList * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' import { ICommandRunResult } from '@common/data' export default async (): Promise => { return await cfgdb.collection.cmd_history.all() } ================================================ FILE: src/main/actions/cmd/toggleDevTools.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default () => { let win = global.main_win if (!win) return win.webContents.toggleDevTools() } ================================================ FILE: src/main/actions/cmd/tryToRun.ts ================================================ /** * run * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' import { ICommandRunResult } from '@common/data' import { exec } from 'child_process' import { broadcast } from '@main/core/agent' import events from '@common/events' const run = (cmd: string): Promise => new Promise((resolve) => { exec(cmd, (error, stdout, stderr) => { // command output is in stdout let success: boolean = !error resolve({ success, stdout, stderr, add_time_ms: new Date().getTime(), }) }) }) export default async () => { let cmd = await cfgdb.dict.cfg.get('cmd_after_hosts_apply') if (!cmd || typeof cmd !== 'string' || !cmd.trim()) { return } console.log(`to run cmd...`) let result = await run(cmd) console.log(result) await cfgdb.collection.cmd_history.insert(result) broadcast(events.cmd_run_result, result) // auto delete old records const max_records = 200 let all = await cfgdb.collection.cmd_history.all() if (all.length > max_records) { let n = all.length - max_records for (let i = 0; i < n; i++) { await cfgdb.collection.cmd_history.delete((item) => item._id === all[i]._id) } } global.tracer.add(`cmd:${result.success ? 1 : 0}`) } ================================================ FILE: src/main/actions/config/all.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' import default_configs, { ConfigsType } from '@common/default_configs' export default async (): Promise => { if (!default_configs.locale && global.system_locale) { default_configs.locale = global.system_locale } let cfgs: Partial = await cfgdb.dict.cfg.all() return Object.assign({}, default_configs, cfgs) } ================================================ FILE: src/main/actions/config/get.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' import default_configs, { ConfigsType } from '@common/default_configs' export default async (key: K) => { return (await cfgdb.dict.cfg.get(key, default_configs[key])) as ConfigsType[K] } ================================================ FILE: src/main/actions/config/set.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' import { ConfigsType } from '@common/default_configs' export default async (key: K, value: ConfigsType[K]) => { console.log(`config:store.set [${key}]: ${value}`) await cfgdb.dict.cfg.set(key, value) } ================================================ FILE: src/main/actions/config/update.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { updateTrayTitle } from '@main/actions' import { cfgdb } from '@main/data' import * as http_api from '@main/http' import { makeMainMenu } from '@main/ui/menu' import { ConfigsType } from '@common/default_configs' import { app } from 'electron' export default async (data: Partial) => { const old_configs = (await cfgdb.dict.cfg.all()) as ConfigsType await cfgdb.dict.cfg.update(data) await updateTrayTitle(!!data.show_title_on_tray) if (old_configs.locale !== data.locale) { makeMainMenu(data.locale) } if (old_configs.http_api_on !== data.http_api_on) { if (data.http_api_on) { http_api.start(data.http_api_only_local) } else { http_api.stop() } } else if (old_configs.http_api_only_local !== data.http_api_only_local) { if (data.http_api_on) { await http_api.stop() http_api.start(data.http_api_only_local) } } if (old_configs.hide_dock_icon !== data.hide_dock_icon) { if (!app.dock) { return } if (data.hide_dock_icon) { app.dock.hide() } else { app.dock.show().catch((e) => console.error(e)) } } } ================================================ FILE: src/main/actions/downloadUpdate.ts ================================================ import * as updater from '@main/core/updater' export default async (): Promise => { await updater.downloadUpdate() return true } ================================================ FILE: src/main/actions/find/addHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import getHistory from '@main/actions/find/getHistory' import setHistory, { IFindHistoryData } from '@main/actions/find/setHistory' const MAX_LENGTH = 20 export default async (data: IFindHistoryData) => { let history_all = await getHistory() // remove old history_all = history_all.filter((i) => i.value !== data.value) // insert new history_all.push(data) while (history_all.length > MAX_LENGTH) { history_all.shift() } await setHistory(history_all) return history_all } ================================================ FILE: src/main/actions/find/addReplaceHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import getReplaceHistory from '@main/actions/find/getReplaceHistory' import setReplaceHistory from '@main/actions/find/setReplaceHistory' const MAX_LENGTH = 20 export default async (value: string) => { let history_all = await getReplaceHistory() // remove old history_all = history_all.filter((v) => v !== value) // insert new history_all.push(value) while (history_all.length > MAX_LENGTH) { history_all.shift() } await setReplaceHistory(history_all) return history_all } ================================================ FILE: src/main/actions/find/findBy.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import splitContent from '@main/actions/find/splitContent' import getContentOfHosts from '@main/actions/hosts/getContent' import { flatten } from '@common/hostsFn' import { IFindItem } from '@common/types' import findInContent from 'src/main/actions/find/findPositionsInContent' import { getList } from '../index' export interface IFindOptions { is_regexp: boolean is_ignore_case: boolean } export default async (keyword: string, options: IFindOptions): Promise => { console.log(keyword) let result_items: IFindItem[] = [] let tree = await getList() let items = flatten(tree) let exp: RegExp if (options.is_regexp) { exp = new RegExp(keyword, options.is_ignore_case ? 'ig' : 'g') } else { let kw = keyword.replace(/([.^$([?*+])/gi, '\\$1') exp = new RegExp(kw, options.is_ignore_case ? 'ig' : 'g') } for (let item of items) { const item_type = item.type || 'local' if (item_type === 'group' || item_type === 'folder') { continue } let content = await getContentOfHosts(item.id) let positions = findInContent(content, exp) if (positions.length === 0) { continue } result_items.push({ item_title: item.title || '', item_id: item.id, item_type, positions, splitters: splitContent(content, positions), }) } return result_items } ================================================ FILE: src/main/actions/find/findPositionsInContent.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IFindPosition } from '@common/types' type MatchResult = Pick< IFindPosition, 'start' | 'end' | 'before' | 'match' | 'after' | 'line' | 'line_pos' | 'end_line' | 'end_line_pos' > export default (content: string, exp: RegExp): MatchResult[] => { let result_items: MatchResult[] = [] let m = content.match(exp) if (!m) { return [] } let line = 1 let start = 0 let cnt = content for (let i of m) { let idx = cnt.indexOf(i) if (idx === -1) continue let head = cnt.slice(0, idx) cnt = cnt.slice(idx + i.length) let head_lines = head.split('\n') line += head_lines.length - 1 start += head.length let before_lines = content.slice(0, start).split('\n') let before = before_lines[before_lines.length - 1] let after = cnt.split('\n')[0] let i_ln = i.split('\n') let end_line = line + i_ln.length - 1 let end_line_pos = before.length + i.length if (i_ln.length > 1) { end_line_pos = i_ln[i_ln.length - 1].length } result_items.push({ start, end: start + i.length, before, match: i, after, line, line_pos: before.length, end_line, end_line_pos, }) start += i.length } return result_items } ================================================ FILE: src/main/actions/find/getHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IFindHistoryData } from '@main/actions/find/setHistory' import { cfgdb } from '@main/data' export default async (): Promise => { return (await cfgdb.list.find_history.all()) as IFindHistoryData[] } ================================================ FILE: src/main/actions/find/getReplaceHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' export default async (): Promise => { return (await cfgdb.list.replace_history.all()) as string[] } ================================================ FILE: src/main/actions/find/setHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' export interface IFindHistoryData { value: string is_regexp: boolean is_ignore_case: boolean } export default async (data: IFindHistoryData[]) => { await cfgdb.list.find_history.set(data) } ================================================ FILE: src/main/actions/find/setReplaceHistory.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { cfgdb } from '@main/data' export default async (data: string[]) => { await cfgdb.list.replace_history.set(data) } ================================================ FILE: src/main/actions/find/show.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { makeWindow } from '@main/ui/find' export default async () => { if (!global.find_win) { global.find_win = await makeWindow() } global.find_win?.show() global.find_win?.focus() } ================================================ FILE: src/main/actions/find/splitContent.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IFindPosition, IFindSplitter } from '@common/types' type MatchResult = Pick & { [key: string]: any } export default (content: string, find_results: MatchResult[]): IFindSplitter[] => { let spliters: IFindSplitter[] = [] let last_end = 0 find_results.map((r, idx) => { let { start, match } = r let before = content.slice(last_end, start) let after = '' last_end += before.length + match.length if (idx === find_results.length - 1) { after = content.slice(last_end) } let spliter: IFindSplitter = { before, after, match, } spliters.push(spliter) }) return spliters } ================================================ FILE: src/main/actions/getBasicData.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsBasicData, IHostsListObject, ITrashcanListObject, VersionType } from '@common/data' import { flatten } from '@common/hostsFn' import { v4 as uuid4 } from 'uuid' import version from '@/version.json' const app_version = version as unknown as VersionType const normalizeList = (list: IHostsListObject[]): IHostsListObject[] => { let flat = flatten(list) flat.map((item) => { if (!item.id) { item.id = uuid4() } }) return list } const normalizeTrashcan = (list: ITrashcanListObject[]): ITrashcanListObject[] => { list.map((item) => { if (!item.id) { item.id = uuid4() } }) return list } export default async (): Promise => { const default_data: IHostsBasicData = { list: [], trashcan: [], version: app_version, } let list = normalizeList(await swhdb.list.tree.all()) let trashcan = normalizeTrashcan(await swhdb.list.trashcan.all()) let v = (await swhdb.dict.meta.get('version', app_version)) || [0, 0, 0, 0] return { ...default_data, list, trashcan, version: v, } } ================================================ FILE: src/main/actions/getDataDir.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import getDataDir from '@main/libs/getDataDir' export default async () => getDataDir() ================================================ FILE: src/main/actions/getDefaultDataDir.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getDefaultDataDir } from '@main/libs/getDataDir' export default async () => getDefaultDataDir() ================================================ FILE: src/main/actions/hosts/deleteHistory.ts ================================================ /** * removeHistory * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' export default async (id: string) => { console.log('delete history #' + id) await swhdb.collection.history.delete((item) => item.id === id) } ================================================ FILE: src/main/actions/hosts/getContent.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { configGet, getItemFromList, getList } from '@main/actions' import { swhdb } from '@main/data' import { IHostsContentObject } from '@common/data' import { findItemById, flatten } from '@common/hostsFn' import { normalizeLineEndings } from '@common/newlines' const getContentById = async (id: string) => { let hosts_content = await swhdb.collection.hosts.find((i) => i.id === id) return normalizeLineEndings(hosts_content?.content || '') } const getContentOfHosts = async (id: string): Promise => { let hosts = await getItemFromList(id) if (!hosts) { return await getContentById(id) } const { type } = hosts if (!type || type === 'local' || type === 'remote') { return await getContentById(id) } let list = await getList() let multi_chose_folder_switch_all = await configGet('multi_chose_folder_switch_all') let isSkipFolder = multi_chose_folder_switch_all && hosts.folder_mode !== 1 if (type === 'folder' && !isSkipFolder) { const items = flatten(hosts.children || []) let a = await Promise.all( items.map(async (item) => { return `# file: ${item.title}\n` + (await getContentOfHosts(item.id)) }), ) return a.join('\n\n') } if (type === 'group') { let a = await Promise.all( (hosts.include || []).map(async (id) => { let item = findItemById(list, id) if (!item) return '' return `# file: ${item.title}\n` + (await getContentOfHosts(id)) }), ) return a.join('\n\n') } return '' } export default getContentOfHosts ================================================ FILE: src/main/actions/hosts/getHistoryList.ts ================================================ /** * getHistoryList * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsHistoryObject } from '@common/data' export default async (): Promise => { let list = await swhdb.collection.history.all() list = list.map((item) => { item.content = item.content || '' return item }) return list } ================================================ FILE: src/main/actions/hosts/getPathOfSystemHostsPath.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ export default async (): Promise => { // Windows 系统有可能不安装在 C 盘 return process.platform === 'win32' ? `${process.env.windir || 'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts` : '/etc/hosts' } ================================================ FILE: src/main/actions/hosts/getSystemHosts.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import getPathOfSystemHosts from './getPathOfSystemHostsPath' import * as fs from 'fs' import { normalizeLineEndings } from '@common/newlines' export default async (): Promise => { const fn = await getPathOfSystemHosts() if (!fs.existsSync(fn)) { return '' } return normalizeLineEndings(await fs.promises.readFile(fn, 'utf-8')) } ================================================ FILE: src/main/actions/hosts/refresh.ts ================================================ /** * refreshHosts * @author: oldj * @homepage: https://oldj.net */ import { getHostsContent, setHostsContent, setList } from '@main/actions/index' import { broadcast } from '@main/core/agent' import { swhdb } from '@main/data' import { GET } from '@main/libs/request' import { IHostsListObject, IOperationResult } from '@common/data' import events from '@common/events' import * as hostsFn from '@common/hostsFn' import dayjs from 'dayjs' import * as fs from 'fs' import { URL } from 'url' export default async (hosts_id: string): Promise => { let list = await swhdb.list.tree.all() let hosts: IHostsListObject | undefined = hostsFn.findItemById(list, hosts_id) if (!hosts) { return { success: false, code: 'invalid_id', } } let { type, url } = hosts if (type !== 'remote') { return { success: false, code: 'not_remote', } } if (!url) { return { success: false, code: 'no_url', } } let old_content: string = await getHostsContent(hosts.id) let new_content: string try { console.log(`-> refreshHosts URL: "${url}"`) if (url.startsWith('file://')) { new_content = await fs.promises.readFile(new URL(url), 'utf-8') } else { let resp = await GET(url) new_content = resp.data } } catch (e: any) { console.error(e) return { success: false, message: e.message, } } hosts.last_refresh = dayjs().format('YYYY-MM-DD HH:mm:ss') hosts.last_refresh_ms = new Date().getTime() await setList(list) if (old_content !== new_content) { await setHostsContent(hosts_id, new_content) broadcast(events.hosts_refreshed, hosts) broadcast(events.hosts_content_changed, hosts_id) } return { success: true, data: { ...hosts }, } } ================================================ FILE: src/main/actions/hosts/setContent.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsContentObject } from '@common/data' import { normalizeLineEndings } from '@common/newlines' export default async (id: string, content: string) => { const normalizedContent = normalizeLineEndings(content) let d = await swhdb.collection.hosts.find((i) => i.id === id) if (!d || !d._id) { await swhdb.collection.hosts.insert({ id, content: normalizedContent }) } else { await swhdb.collection.hosts.update((i) => i._id === d?._id, { content: normalizedContent }) } } ================================================ FILE: src/main/actions/hosts/setSystemHosts.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { configGet, deleteHistory, getHistoryList, updateTrayTitle } from '@main/actions' import tryToRun from '@main/actions/cmd/tryToRun' import { broadcast } from '@main/core/agent' import { swhdb } from '@main/data' import safePSWD from '@main/libs/safePSWD' import { IHostsWriteOptions } from '@main/types' import { IHostsHistoryObject } from '@common/data' import events from '@common/events' import { getLineEndingForPlatform, normalizeLineEndings, restoreLineEndings } from '@common/newlines' import { exec } from 'child_process' import * as fs from 'fs' import md5 from 'md5' import md5File from 'md5-file' import * as os from 'os' import * as path from 'path' import { v4 as uuid4 } from 'uuid' import getPathOfSystemHosts from './getPathOfSystemHostsPath' interface IWriteResult { success: boolean code?: string message?: string old_content?: string new_content?: string } const CONTENT_START = '# --- SWITCHHOSTS_CONTENT_START ---' let sudo_pswd: string = '' const checkAccess = async (fn: string): Promise => { try { await fs.promises.access(fn, fs.constants.W_OK) return true } catch (e) { // console.error(e) } return false } const addHistory = async (content: string) => { await swhdb.collection.history.insert({ id: uuid4(), content, add_time_ms: new Date().getTime(), }) let history_limit = await configGet('history_limit') if (typeof history_limit !== 'number' || history_limit <= 0) return let lists = await swhdb.collection.history.all() if (lists.length <= history_limit) { return } for (let i = 0; i < lists.length - history_limit; i++) { if (!lists[i] || !lists[i].id) break await deleteHistory(lists[i].id) } } const writeWithSudo = (sys_hosts_path: string, content: string): Promise => new Promise((resolve) => { let tmp_fn = path.join(os.tmpdir(), `swh_${new Date().getTime()}_${Math.random()}.txt`) fs.writeFileSync(tmp_fn, content, 'utf-8') let cmd = [ `echo '${sudo_pswd}' | sudo -S chmod 777 ${sys_hosts_path}`, `cat "${tmp_fn}" > ${sys_hosts_path}`, `echo '${sudo_pswd}' | sudo -S chmod 644 ${sys_hosts_path}`, // , 'rm -rf ' + tmp_fn ].join(' && ') exec(cmd, function (error, stdout, stderr) { // command output is in stdout console.log('stdout', stdout) console.log('stderr', stderr) if (fs.existsSync(tmp_fn)) { fs.unlinkSync(tmp_fn) } let result: IWriteResult if (!error) { console.log('success.') result = { success: true, } } else { console.log('fail!') sudo_pswd = '' result = { success: false, message: stderr, } } resolve(result) }) }) const write = async (content: string, options?: IHostsWriteOptions): Promise => { const sys_hosts_path = await getPathOfSystemHosts() let old_content_raw = '' try { old_content_raw = await fs.promises.readFile(sys_hosts_path, 'utf-8') } catch (e) { console.error(e) } const lineEnding = getLineEndingForPlatform() const diskContent = restoreLineEndings(content, lineEnding) const fn_md5 = await md5File(sys_hosts_path) const content_md5 = md5(diskContent) if (fn_md5 === content_md5) { // file not change return { success: true } } const old_content = normalizeLineEndings(old_content_raw) let has_access = await checkAccess(sys_hosts_path) if (!has_access) { if (options && options.sudo_pswd) { sudo_pswd = safePSWD(options.sudo_pswd) } let platform = process.platform if ((platform === 'darwin' || platform === 'linux') && sudo_pswd) { let result = await writeWithSudo(sys_hosts_path, diskContent) if (result.success) { result.old_content = old_content result.new_content = content } return result } return { success: false, code: 'no_access', } } try { await fs.promises.writeFile(sys_hosts_path, diskContent, 'utf-8') } catch (e: any) { console.error(e) let code = 'fail' if (e.code === 'EPERM' || e.message.includes('operation not permitted')) { code = 'no_access' } return { success: false, code, message: e.message, } } return { success: true, old_content, new_content: content } } const makeAppendContent = async (content: string): Promise => { const sys_hosts_path = await getPathOfSystemHosts() const old_content = normalizeLineEndings(await fs.promises.readFile(sys_hosts_path, 'utf-8')) let index = old_content.indexOf(CONTENT_START) let new_content = index > -1 ? old_content.substring(0, index).trimEnd() : old_content if (!content) { return new_content + '\n' } return `${new_content}\n\n${CONTENT_START}\n\n${content}` } const setSystemHosts = async ( content: string, options?: IHostsWriteOptions, ): Promise => { content = normalizeLineEndings(content) let write_mode = await configGet('write_mode') console.log(`write_mode: ${write_mode}`) if (write_mode === 'append') { content = await makeAppendContent(content) } let result = await write(content, options) let { success, old_content } = result if (success) { if (typeof old_content === 'string') { let histories = await getHistoryList() if (histories.length === 0 || histories[histories.length - 1].content !== old_content) { await addHistory(old_content) } } await addHistory(content) await updateTrayTitle() broadcast(events.system_hosts_updated) await tryToRun() } global.tracer.add(`w:${success ? 1 : 0}`) return result } export default setSystemHosts ================================================ FILE: src/main/actions/index.ts ================================================ /** * index * @author: oldj * @homepage: https://oldj.net */ export { default as ping } from './ping' export { default as getBasicData } from './getBasicData' export { default as getDataDir } from './getDataDir' export { default as getDefaultDataDir } from './getDefaultDataDir' export { default as configGet } from './config/get' export { default as configSet } from './config/set' export { default as configAll } from './config/all' export { default as configUpdate } from './config/update' export { default as getPathOfSystemHosts } from './hosts/getPathOfSystemHostsPath' export { default as getHostsContent } from './hosts/getContent' export { default as setHostsContent } from './hosts/setContent' export { default as refreshHosts } from './hosts/refresh' export { default as getSystemHosts } from './hosts/getSystemHosts' export { default as setSystemHosts } from './hosts/setSystemHosts' export { default as getHistoryList } from './hosts/getHistoryList' export { default as deleteHistory } from './hosts/deleteHistory' export { default as getList } from './list/getList' export { default as setList } from './list/setList' export { default as getItemFromList } from './list/getItem' export { default as getContentOfList } from './list/getContentOfList' export { default as moveToTrashcan } from './list/moveItemToTrashcan' export { default as moveManyToTrashcan } from './list/moveManyToTrashcan' export { default as getTrashcanList } from './trashcan/getList' export { default as clearTrashcan } from './trashcan/clear' export { default as deleteItemFromTrashcan } from './trashcan/deleteItem' export { default as restoreItemFromTrashcan } from './trashcan/restoreItem' export { default as cmdGetHistoryList } from './cmd/getHistoryList' export { default as cmdDeleteHistory } from './cmd/deleteHistory' export { default as cmdClearHistory } from './cmd/clearHistory' export { default as cmdFocusMainWindow } from './cmd/focusMainWindow' export { default as cmdToggleDevTools } from './cmd/toggleDevTools' export { default as cmdChangeDataDir } from './cmd/changeDataDir' export { default as openUrl } from './openUrl' export { default as showItemInFolder } from './showItemInFolder' export { default as updateTrayTitle } from './updateTrayTitle' export { default as checkUpdate } from './checkUpdate' export { default as downloadUpdate } from './downloadUpdate' export { default as installUpdate } from './installUpdate' export { default as closeMainWindow } from './closeMainWindow' export { default as quit } from './quit' export { default as findShow } from './find/show' export { default as findBy } from './find/findBy' export { default as findAddHistory } from './find/addHistory' export { default as findGetHistory } from './find/getHistory' export { default as findSetHistory } from './find/setHistory' export { default as findAddReplaceHistory } from './find/addReplaceHistory' export { default as findGetReplaceHistory } from './find/getReplaceHistory' export { default as findSetReplaceHistory } from './find/setReplaceHistory' export { default as migrateCheck } from './migrate/checkIfMigration' export { default as migrateData } from './migrate/migrateData' export { default as exportData } from './migrate/export' export { default as importData } from './migrate/import' export { default as importDataFromUrl } from './migrate/importFromUrl' ================================================ FILE: src/main/actions/installUpdate.ts ================================================ import * as updater from '@main/core/updater' export default async (): Promise => { await updater.installUpdate() return true } ================================================ FILE: src/main/actions/list/getContentOfList.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { configGet, getHostsContent } from '@main/actions' import { IHostsListObject } from '@common/data' import { flatten } from '@common/hostsFn' import normalize, { INormalizeOptions } from '@common/normalize' const getContentOfList = async (list: IHostsListObject[]): Promise => { const content_list: string[] = [] const flat = flatten(list).filter((item) => item.on) for (let hosts of flat) { let c = await getHostsContent(hosts.id) content_list.push(c) } let content = content_list.join('\n\n') // console.log(content) let options: INormalizeOptions = {} if (await configGet('remove_duplicate_records')) { options.remove_duplicate_records = true } content = normalize(content, options) return content } export default getContentOfList ================================================ FILE: src/main/actions/list/getItem.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getList } from '@main/actions' import { IHostsListObject } from '@common/data' import { findItemById } from '@common/hostsFn' export default async (id: string): Promise => { let list = await getList() return findItemById(list, id) } ================================================ FILE: src/main/actions/list/getList.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsListObject } from '@common/data' export default async (): Promise => { return await swhdb.list.tree.all() } ================================================ FILE: src/main/actions/list/moveItemToTrashcan.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getList } from '@main/actions' import { broadcast } from '@main/core/agent' import { swhdb } from '@main/data' import { IHostsListObject, ITrashcanObject } from '@common/data' import events from '@common/events' import * as hostsFn from '@common/hostsFn' export default async (id: string) => { let list: IHostsListObject[] = await getList() let node = hostsFn.findItemById(list, id) if (!node) { console.error(`can't find node by id #${id}.`) return } if (node.on) { // current hosts is in use, update system hosts broadcast(events.toggle_item, node.id, false) } let obj: ITrashcanObject = { data: { ...node, on: false, }, add_time_ms: new Date().getTime(), parent_id: hostsFn.getParentOfItem(list, id)?.id || null, } await swhdb.list.trashcan.push(obj) hostsFn.deleteItemById(list, id) await swhdb.list.tree.set(list) } ================================================ FILE: src/main/actions/list/moveManyToTrashcan.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { moveToTrashcan } from '@main/actions' export default async function (ids: string[]) { for (let id of ids) { await moveToTrashcan(id) } } ================================================ FILE: src/main/actions/list/setList.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsListObject } from '@common/data' export default async (list: IHostsListObject[]) => { await swhdb.list.tree.set(list) } ================================================ FILE: src/main/actions/migrate/checkIfMigration.ts ================================================ /** * checkIfMigration * check if migration is required * @author: oldj * @homepage: https://oldj.net */ import getDataFolder from '@main/libs/getDataDir' import { isDir } from '@main/utils/fs2' import * as fs from 'fs' import * as path from 'path' export default async (): Promise => { let dir = getDataFolder() let old_data_file = path.join(dir, 'data.json') let new_data_dir = path.join(dir, 'data') let has_new_data = isDir(new_data_dir) && isDir(path.join(new_data_dir, 'collection')) return fs.existsSync(old_data_file) && !has_new_data } ================================================ FILE: src/main/actions/migrate/export.ts ================================================ /** * export * @author: oldj * @homepage: https://oldj.net */ import getI18N from '@main/core/getI18N' import { swhdb } from '@main/data' import { dialog } from 'electron' import { promises as fs } from 'fs' import * as path from 'path' import version from '@/version.json' export default async (): Promise => { let { lang } = await getI18N() let result = await dialog.showSaveDialog({ title: lang.import, defaultPath: path.join(global.last_path || '', 'swh_data.json'), properties: ['createDirectory', 'showOverwriteConfirmation'], }) if (result.canceled || !result.filePath) { return null } let target_dir = result.filePath let data = await swhdb.toJSON() try { await fs.writeFile( target_dir, JSON.stringify({ data, version, }), 'utf-8', ) } catch (e) { console.error(e) return false } return target_dir } ================================================ FILE: src/main/actions/migrate/import.ts ================================================ /** * import * @author: oldj * @homepage: https://oldj.net */ import importV3Data from '@main/actions/migrate/importV3Data' import getI18N from '@main/core/getI18N' import { swhdb } from '@main/data' import { dialog } from 'electron' import { promises as fs } from 'fs' export default async (): Promise => { let { lang } = await getI18N() let result = await dialog.showOpenDialog({ title: lang.import, defaultPath: global.last_path, filters: [ { name: 'JSON', extensions: ['json'] }, { name: 'All Files', extensions: ['*'] }, ], properties: ['openFile'], }) if (result.canceled) { return null } let paths = result.filePaths let fn = paths[0] let content = await fs.readFile(fn, 'utf-8') let data: any try { data = JSON.parse(content) } catch (e) { console.error(e) return 'parse_error' } if ( typeof data !== 'object' || !data.version || !Array.isArray(data.version) ) { return 'invalid_data' } let { version } = data if (version[0] === 3) { // import v3 data try { await importV3Data(data) } catch (e) { console.error(e) return 'invalid_v3_data' } return true } if (version[0] > 4) { return 'new_version' } if (!data.data || typeof data.data !== 'object') { return 'invalid_data_key' } await swhdb.loadJSON(data.data) return true } ================================================ FILE: src/main/actions/migrate/importFromUrl.ts ================================================ /** * importFromUrl * @author: oldj * @homepage: https://oldj.net */ import importV3Data from '@main/actions/migrate/importV3Data' import { swhdb } from '@main/data' import { GET } from '@main/libs/request' export default async (url: string): Promise => { console.log(`import from url: ${url}`) let res try { res = await GET(url) } catch (e: any) { console.error(e) return e.message } // console.log(res) if (res.status !== 200) { return `error_${res.status}` } let data: any if (typeof res.data === 'string') { try { data = JSON.parse(res.data) } catch (e) { console.error(e) return 'parse_error' } } else { data = res.data } if (typeof data !== 'object' || !data.version || !Array.isArray(data.version)) { return 'invalid_data' } let { version } = data if (version[0] === 3) { // import v3 data try { await importV3Data(data) } catch (e) { console.error(e) return 'invalid_v3_data' } return true } if (version[0] > 4) { return 'new_version' } if (!data.data || typeof data.data !== 'object') { return 'invalid_data_key' } await swhdb.loadJSON(data.data) return true } ================================================ FILE: src/main/actions/migrate/importV3Data.ts ================================================ /** * importV3Data * @author: oldj * @homepage: https://oldj.net */ // import data from v3 to v4 import { swhdb } from '@main/data' import { cleanHostsList, flatten } from '@common/hostsFn' import version from '@/version.json' export default async (old_data: any) => { old_data = cleanHostsList(old_data) await swhdb.collection.hosts.remove() await swhdb.list.tree.remove() let { list } = old_data let hosts = flatten(list) for (let h of hosts) { if (h.refresh_interval) { h.refresh_interval *= 3600 } h.type = h.where delete h.where await swhdb.collection.hosts.insert(h) h.content = '' } await swhdb.list.tree.extend(...list) await swhdb.dict.meta.set('version', version) } ================================================ FILE: src/main/actions/migrate/migrateData.ts ================================================ /** * migrateData * @author: oldj * @homepage: https://oldj.net */ // migrate data from v3 to v4 import importV3Data from '@main/actions/migrate/importV3Data' import getDataFolder from '@main/libs/getDataDir' import { IHostsBasicData, VersionType } from '@common/data' import { cleanHostsList } from '@common/hostsFn' import version from '@/version.json' import * as fs from 'fs' import path from 'path' const readOldData = async (): Promise => { const fn = path.join(await getDataFolder(), 'data.json') const default_data: IHostsBasicData = { list: [], trashcan: [], version: version as VersionType, } if (!fs.existsSync(fn)) { return default_data } let content = await fs.promises.readFile(fn, 'utf-8') try { let data = JSON.parse(content) as IHostsBasicData return cleanHostsList(data) } catch (e) { console.error(e) return default_data } } export default async () => { let old_data = await readOldData() await importV3Data(old_data) } ================================================ FILE: src/main/actions/openUrl.ts ================================================ /** * @author oldj * @blog https://oldj.net */ import { shell } from 'electron' export default async (url: string) => { await shell.openExternal(url) } ================================================ FILE: src/main/actions/ping.ts ================================================ /** * ping * @author: oldj * @homepage: https://oldj.net */ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default async (ms: number = 1000): Promise => { await wait(ms) return 'pong' } ================================================ FILE: src/main/actions/quit.ts ================================================ /** * quit * @author: oldj * @homepage: https://oldj.net */ import { app } from 'electron' export default async () => { console.log('to quit...') try { global.main_win.webContents.closeDevTools() } catch (e) { console.error(e) } app.quit() } ================================================ FILE: src/main/actions/showItemInFolder.ts ================================================ /** * showItemInFolder * @author: oldj * @homepage: https://oldj.net */ import { isDir } from '@main/utils/fs2' import { shell } from 'electron' export default async (link: string) => { if (isDir(link)) { await shell.openPath(link) return } await shell.showItemInFolder(link) } ================================================ FILE: src/main/actions/trashcan/clear.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { flatten } from '@common/hostsFn' export default async () => { let trashcan_items = await swhdb.list.trashcan.all() let ids: string[] = [] trashcan_items.map((i) => { ids.push(i.data.id) flatten(i.data.children || []).map((i) => ids.push(i.id)) }) await swhdb.collection.hosts.delete((i) => ids.includes(i.id)) await swhdb.list.tree.delete((i) => ids.includes(i.id)) await swhdb.list.trashcan.remove() return true } ================================================ FILE: src/main/actions/trashcan/deleteItem.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { ITrashcanListObject } from '@common/data' import { flatten } from '@common/hostsFn' export default async (id: string): Promise => { // Permanently delete the specified item with id. let trashcan_item: ITrashcanListObject | undefined = await swhdb.list.trashcan.find( (i) => i.data.id === id, ) if (!trashcan_item) { console.log(`can't find trashcan_item with id #${id}.`) return false } let ids: string[] = [id] flatten(trashcan_item.data.children || []).map((i) => ids.push(i.id)) await swhdb.collection.hosts.delete((i) => ids.includes(i.id)) await swhdb.list.tree.delete((i) => i.id === id) await swhdb.list.trashcan.delete((i) => i.data.id === id) return true } ================================================ FILE: src/main/actions/trashcan/getList.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { swhdb } from '@main/data' import { IHostsListObject } from '@common/data' export default async (): Promise => { return await swhdb.list.trashcan.all() } ================================================ FILE: src/main/actions/trashcan/restoreItem.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getList, setList } from '@main/actions' import { swhdb } from '@main/data' import { getNodeById } from '@common/tree' import { IHostsListObject, ITrashcanListObject } from '@common/data' export default async (id: string): Promise => { let trashcan_item: ITrashcanListObject | undefined = await swhdb.list.trashcan.find( (i) => i.data.id === id, ) if (!trashcan_item) { console.log(`can't find trashcan_item with id #${id}.`) return false } let hosts = trashcan_item.data if (!hosts || !hosts.id) { console.log(`bad trashcan_item!`) return false } let list = await getList() let { parent_id } = trashcan_item if (!parent_id) { await setList([...list, hosts]) } else { let parent_hosts = getNodeById(list, parent_id) if (!parent_hosts) { console.log(`can't find parent_hosts with id #${parent_id}.`) return false } parent_hosts.children = [...(parent_hosts.children || []), hosts] await setList(list) } await swhdb.list.trashcan.delete((i) => i.data.id === id) return true } ================================================ FILE: src/main/actions/updateTrayTitle.ts ================================================ /** * toggleTrayTitle * @author: oldj * @homepage: https://oldj.net */ import { getList } from '@main/actions/index' import { cfgdb } from '@main/data' import { tray } from '@main/ui/tray' import { flatten } from '@common/hostsFn' export default async (show?: boolean, title?: string) => { if (!tray) return if (typeof show !== 'boolean') { show = await cfgdb.dict.cfg.get('show_title_on_tray') } if (!show) { tray.setTitle('') return } if (!title) { let list = await getList() let on_items = flatten(list).filter((i) => i.on) title = on_items.map((i) => i.title).join(',') if (title.length > 20) { title = title.substring(0, 17) + '...' } } tray.setTitle(title) } ================================================ FILE: src/main/core/agent.ts ================================================ /** * agent * @author: oldj * @homepage: https://oldj.net */ import { ipcMain } from 'electron' export const broadcast = (event: string, ...args: any[]) => { ipcMain.emit('x_broadcast', null, { event, args }) } ================================================ FILE: src/main/core/getI18N.ts ================================================ /** * getLang * @author: oldj * @homepage: https://oldj.net */ import { configGet } from '@main/actions' import { LocaleName } from '@common/i18n' import { I18N } from '@common/i18n' export default async (locale?: LocaleName): Promise => { if (!locale) { locale = await configGet('locale') } return new I18N(locale) } ================================================ FILE: src/main/core/message.ts ================================================ /** * message * @author: oldj * @homepage: https://oldj.net */ import * as actions from '@main/actions' import { ActionData } from '@main/types' import { ipcMain } from 'electron' import { EventEmitter } from 'events' import { IActionFunc } from '@common/types' const ee = new EventEmitter() const registered_clients: { [key: string]: any } = {} let i_reg_idx = 0 ipcMain.on('x_reg', (e, d) => { i_reg_idx++ let name = d?.name || i_reg_idx.toString() registered_clients[name] = e.sender }) ipcMain.on('x_unreg', (e, d) => { let name: string | undefined = d?.name if (name === '*') { for (let k in registered_clients) { if (registered_clients.hasOwnProperty(k)) { delete registered_clients[k] } } } else if (name) { delete registered_clients[name] } else { for (let k in registered_clients) { if (registered_clients.hasOwnProperty(k) && registered_clients[k] === e.sender) { delete registered_clients[k] break } } } }) ipcMain.on('x_broadcast', (e, d) => { // 广播给内部 ee.emit(d.event, ...d.args) // 广播给 renderer for (let k in registered_clients) { if (registered_clients.hasOwnProperty(k)) { try { registered_clients[k].send('y_broadcast', d) } catch (e) { console.error(e) } } } }) function sendBack(sender: any, event_name: string, data: [any] | [any, any]) { try { sender.send(event_name, ...data) } catch (e) { console.error(e) } } ipcMain.on('x_action', async (e, action_data: ActionData) => { let sender = e.sender let { action, data, callback } = action_data let fn = actions[action] if (typeof fn === 'function') { let params = data || [] if (!Array.isArray(params)) { params = [params] } try { let obj: IActionFunc = { sender } // @ts-ignore let v = await fn.call(obj, ...params) sendBack(sender, callback, [null, v]) } catch (e) { console.error(e) sendBack(sender, callback, [e]) } } else { let e = `unknow action [${action}].` console.error(e) sendBack(sender, callback, [e]) } }) export const on = (event: string, handler: (...args: any[]) => void) => { ee.on(event, (d, ...args) => { handler(d, ...args) }) } ================================================ FILE: src/main/core/popupMenu.ts ================================================ /** * contextMenu * @author: oldj * @homepage: https://oldj.net */ import { broadcast } from '@main/core/agent' import { IPopupMenuOption } from '@common/types' import { ipcMain, Menu, MenuItem } from 'electron' ipcMain.on('x_popup_menu', (e, options: IPopupMenuOption) => { // console.log(options) const menu = new Menu() options.items.map((opt) => { if (typeof opt._click_evt === 'string') { let evt: string = opt._click_evt opt.click = () => { broadcast(evt) } } const item = new MenuItem(opt) menu.append(item) }) menu.on('menu-will-close', () => { // console.log('menu-will-close') broadcast(`popup_menu_close:${options.menu_id}`) }) menu.popup() }) ================================================ FILE: src/main/core/updater.ts ================================================ import events from '@common/events' import { AppDownloadedUpdateInfo, AppUpdateInfo, AppUpdateProgress } from '@common/update' import { broadcast } from '@main/core/agent' import { autoUpdater } from 'electron-updater' import type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater' let isBound = false let currentUpdateInfo: AppUpdateInfo | null = null let downloadedUpdateInfo: AppDownloadedUpdateInfo | null = null function normalizeReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | null { if (!releaseNotes) { return null } if (typeof releaseNotes === 'string') { return releaseNotes } return releaseNotes .map((item) => { if (!item.note) { return '' } if (!item.version) { return item.note } return `## ${item.version}\n${item.note}` }) .filter(Boolean) .join('\n\n') } function toAppUpdateInfo(info: UpdateInfo): AppUpdateInfo { return { version: info.version, releaseName: info.releaseName || null, releaseNotes: normalizeReleaseNotes(info.releaseNotes), } } function toProgressPayload(info: ProgressInfo): AppUpdateProgress { return { percent: info.percent, transferred: info.transferred, total: info.total, bytesPerSecond: info.bytesPerSecond, } } function toDownloadedUpdateInfo(event: UpdateDownloadedEvent): AppDownloadedUpdateInfo { return { ...(currentUpdateInfo || toAppUpdateInfo(event)), downloadedFile: event.downloadedFile || null, } } function bindUpdaterEvents() { if (isBound) { return } // Bind lazily so test environments that stub Electron do not initialize // the updater before an explicit update action is requested. isBound = true autoUpdater.autoDownload = false autoUpdater.allowPrerelease = false autoUpdater.autoInstallOnAppQuit = false autoUpdater.on('update-available', (info) => { currentUpdateInfo = toAppUpdateInfo(info) downloadedUpdateInfo = null console.log('update-available', currentUpdateInfo) broadcast(events.new_version, currentUpdateInfo) }) autoUpdater.on('update-not-available', (info) => { console.log('update-not-available', info) currentUpdateInfo = null downloadedUpdateInfo = null }) autoUpdater.on('download-progress', (info) => { const payload = toProgressPayload(info) console.log('download-progress', payload) broadcast(events.update_download_progress, payload) }) autoUpdater.on('update-downloaded', (event) => { downloadedUpdateInfo = toDownloadedUpdateInfo(event) console.log('update-downloaded', downloadedUpdateInfo) broadcast(events.update_downloaded, downloadedUpdateInfo) }) autoUpdater.on('error', (error, message) => { console.error('autoUpdater error', message || '', error) }) } export async function checkUpdate(): Promise { bindUpdaterEvents() const result = await autoUpdater.checkForUpdates() console.log('updater checkForUpdates', result) if (!result?.isUpdateAvailable) { currentUpdateInfo = null downloadedUpdateInfo = null return null } // Normalize the updater payload so renderer code does not depend on // electron-updater's version-specific event shape. currentUpdateInfo = toAppUpdateInfo(result.updateInfo) return currentUpdateInfo } export async function downloadUpdate() { bindUpdaterEvents() if (!currentUpdateInfo) { throw new Error('No update is available to download.') } downloadedUpdateInfo = null return autoUpdater.downloadUpdate() } export async function installUpdate() { bindUpdaterEvents() if (!downloadedUpdateInfo) { throw new Error('No downloaded update is ready to install.') } global.is_will_quit = true autoUpdater.quitAndInstall() } ================================================ FILE: src/main/data/index.ts ================================================ /** * db * @author: oldj * @homepage: https://oldj.net */ import * as path from 'path' import PotDb from 'potdb' import { app } from 'electron' import getDataFolder from '@main/libs/getDataDir' import getConfigFolder from '@main/libs/getConfigDir' let localdb: PotDb let cfgdb: PotDb let swhdb: PotDb if (!global.localdb) { let db_dir: string = path.join(app.getPath('userData'), 'swh_local') localdb = new PotDb(db_dir) console.log(`local db: ${localdb.dir}`) global.localdb = localdb } else { localdb = global.localdb } if (!global.cfgdb) { let db_dir: string = path.join(getConfigFolder(), 'config') cfgdb = new PotDb(db_dir) console.log(`config db: ${cfgdb.dir}`) global.cfgdb = cfgdb } else { cfgdb = global.cfgdb } async function getSwhDb(): Promise { if (!swhdb) { global.data_dir = await localdb.dict.local.get('data_dir') let db_dir: string = path.join(getDataFolder(), 'data') swhdb = new PotDb(db_dir) console.log(`data db: ${swhdb.dir}`) global.swhdb = swhdb } return swhdb } export { localdb, cfgdb, swhdb, getSwhDb } ================================================ FILE: src/main/http/api/index.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { Hono } from 'hono' import list from './list' import toggle from './toggle' const router = new Hono() router.get('/list', list) router.get('/toggle', toggle) export default router ================================================ FILE: src/main/http/api/list.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getList } from '@main/actions' import { IHostsListObject } from '@common/data' import { flatten } from '@common/hostsFn' import type { Context } from 'hono' const list = async (c: Context) => { let list: IHostsListObject[] try { list = await getList() } catch (error) { const message = error instanceof Error ? error.message : String(error) return c.json({ success: false, message, }) } list = flatten(list) return c.json({ success: true, data: list, }) } export default list ================================================ FILE: src/main/http/api/toggle.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { getList } from '@main/actions' import { broadcast } from '@main/core/agent' import events from '@common/events' import { findItemById } from '@common/hostsFn' import type { Context } from 'hono' const toggle = async (c: Context) => { const id = c.req.query('id') console.log(`http_api toggle: ${id}`) if (!id) { return c.text('bad id.') } let list = await getList() let item = findItemById(list, id) if (!item) { return c.text('not found.') } broadcast(events.toggle_item, id, !item.on) return c.text('ok') } export default toggle ================================================ FILE: src/main/http/index.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { http_api_port } from '@common/constants' import { serve } from '@hono/node-server' import { Hono } from 'hono' import type { Context, Next } from 'hono' import api_router from './api/index' export const app = new Hono() export const requestLogger = async (c: Context, next: Next) => { const url = new URL(c.req.url) console.log( `> "${new Date().toString()}"`, c.req.method, `${url.pathname}${url.search}`, `"${c.req.header('user-agent')}"`, ) await next() } export const homeHandler = (c: Context) => c.text('Hello SwitchHosts!') export const remoteTestHandler = (c: Context) => c.text(`# remote-test\n# ${new Date().toString()}`) app.use('*', requestLogger) app.get('/', homeHandler) app.get('/remote-test', remoteTestHandler) app.route('/api', api_router) let server: ReturnType | undefined export const start = (http_api_only_local: boolean): boolean => { try { let listenIp = http_api_only_local ? '127.0.0.1' : '0.0.0.0' server = serve( { fetch: app.fetch, port: http_api_port, hostname: listenIp, }, () => { console.log(`SwitchHosts HTTP server is listening on port ${http_api_port}!`) console.log(`-> http://${listenIp}:${http_api_port}`) }, ) } catch (e) { console.error(e) return false } return true } export const stop = () => { if (!server) return try { server.close() server = undefined } catch (e) { console.error(e) } } ================================================ FILE: src/main/libs/cron.ts ================================================ /** * cron * @author: oldj * @homepage: https://oldj.net */ import { checkUpdate, configGet, getList, refreshHosts } from '@main/actions' import { broadcast } from '@main/core/agent' import { IHostsListObject } from '@common/data' import events from '@common/events' import { flatten } from '@common/hostsFn' let t: any let ts_last_server_check = 0 const isNeedRefresh = (hosts: IHostsListObject): boolean => { let { refresh_interval, last_refresh_ms, url } = hosts if (!refresh_interval || refresh_interval <= 0) return false if (!url || !url.match(/^https?:\/\//i)) return false if (!last_refresh_ms) return true let ts = new Date().getTime() if ((ts - last_refresh_ms) / 1000 >= refresh_interval) { return true } // false return false } const checkRefresh = async () => { // console.log('check refresh...') let list = await getList() let remote_hosts = flatten(list).filter((h) => h.type === 'remote') for (let hosts of remote_hosts) { if (isNeedRefresh(hosts)) { try { await refreshHosts(hosts.id) } catch (e) { console.error(e) } } } broadcast(events.reload_list) } const checkServer = async () => { let auto_download_update = await configGet('auto_download_update') if (!auto_download_update) return let ts = new Date().getTime() if (!ts_last_server_check || ts - ts_last_server_check > 3600 * 1000) { await checkUpdate() ts_last_server_check = ts } } const check = async () => { checkRefresh().catch((e) => console.error(e)) checkServer().catch((e) => console.error(e)) global.tracer.emit().catch((e) => console.error(e)) } export const start = () => { setTimeout(checkServer, 5000) clearInterval(t) t = setInterval(check, 60 * 1000) } ================================================ FILE: src/main/libs/getConfigDir.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import * as path from 'path' import { homedir } from 'os' export default (): string => { // todo data folder should be current working dir for portable version return path.join(homedir(), '.SwitchHosts') } ================================================ FILE: src/main/libs/getDataDir.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import * as path from 'path' import { homedir } from 'os' export function getDefaultDataDir() { return path.join(homedir(), '.SwitchHosts') } export default (): string => { // todo data folder should be current working dir for portable version return global.data_dir || getDefaultDataDir() } ================================================ FILE: src/main/libs/getIndex.ts ================================================ /** * getIndex * @author: oldj * @homepage: https://oldj.net */ import isDev from '@main/libs/isDev' import path from 'path' import * as url from 'url' export default (): string => { let index: string if (isDev()) { index = 'http://127.0.0.1:8220' } else { index = url.format({ pathname: path.join(__dirname, 'index.html'), protocol: 'file:', slashes: true, }) } return index } ================================================ FILE: src/main/libs/isDev.ts ================================================ /** * isDev * @author: oldj * @homepage: https://oldj.net */ export default () => { return process.env.NODE_ENV === 'development' } ================================================ FILE: src/main/libs/request.ts ================================================ /** * request * @author: oldj * @homepage: https://oldj.net */ import { configGet } from '@main/actions' import axios, { AxiosRequestConfig } from 'axios' import querystring from 'querystring' import version from '@/version.json' interface IParams { [key: string]: string | string[] | number } interface IRequestOptions { timeout?: number headers?: { [key: string]: string | string[] } } export const GET = async ( url: string, params: IParams | null = null, options: IRequestOptions = {}, ) => { let s = '' if (params) { s = querystring.stringify(params) } if (s) { url += (url.includes('?') ? '&' : '?') + s } const default_headers = { 'user-agent': `${global.ua} SwitchHosts/${version.join('.')}`, } let configs: AxiosRequestConfig = { timeout: options.timeout || 30000, headers: { ...default_headers, ...options.headers, }, } if (await configGet('use_proxy')) { let protocol = await configGet('proxy_protocol') let host = await configGet('proxy_host') let port = await configGet('proxy_port') if (host && port) { configs.proxy = { protocol, host, port } } } const instance = axios.create(configs) return await instance.get(url) } ================================================ FILE: src/main/libs/safePSWD.ts ================================================ /** * safe-pswd * @author oldj * @blog https://oldj.net */ export default (pswd: string): string => { return ( pswd .replace(/\\/g, '\\\\') //.replace(/'/g, "\\''") .replace(/'/g, '\\x27') ) } ================================================ FILE: src/main/libs/tracer.ts ================================================ import { configGet } from '@main/actions' import { GET } from '@main/libs/request' import { server_url } from '@common/constants' class Tracer { data: string[] constructor() { this.data = [] } add(action: string) { this.data.push(action) } async emit() { if (this.data.length === 0) return let send_usage_data = await configGet('send_usage_data') if (send_usage_data) { // Tracking is temporarily disabled. console.log('Tracking is temporarily disabled.') // console.log('send usage data...') // await GET(`${server_url}/api/tick/`, { // sid: global.session_id, // t: 1, // a: this.data.join(','), // }) } this.data = [] } } export default Tracer ================================================ FILE: src/main/main.ts ================================================ /** * main.ts * @author oldj * @homepage https://oldj.net */ import { configAll, configGet } from '@main/actions' import '@main/core/agent' import * as message from '@main/core/message' import '@main/core/popupMenu' import '@main/data' import * as http_api from '@main/http' import * as cron from '@main/libs/cron' import getIndex from '@main/libs/getIndex' import isDev from '@main/libs/isDev' import Tracer from '@main/libs/tracer' import checkSystemLocale from '@main/ui/checkSystemLocale' import * as find from '@main/ui/find' import { makeMainMenu } from '@main/ui/menu' import '@main/ui/tray' import version from '@/version.json' import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import windowStateKeeper from 'electron-window-state' import * as path from 'path' import { v4 as uuid4 } from 'uuid' import { getSwhDb } from '@main/data' let win: BrowserWindow | null const createWindow = async () => { await getSwhDb() const configs = await configAll() let main_window_state = windowStateKeeper({ defaultWidth: 800, defaultHeight: 480, }) let linux_icon = {} if (process.platform === 'linux') { linux_icon = { icon: path.join(__dirname, '/assets/icon.png'), } } win = new BrowserWindow({ x: main_window_state.x, y: main_window_state.y, width: main_window_state.width, height: main_window_state.height, minWidth: 300, minHeight: 200, autoHideMenuBar: true, titleBarStyle: 'hiddenInset', frame: configs.use_system_window_frame || false, hasShadow: true, webPreferences: { contextIsolation: true, preload: path.join(__dirname, 'preload.js'), spellcheck: true, }, ...linux_icon, }) main_window_state.manage(win) const ses = win.webContents.session // console.log(ses.getUserAgent()) global.ua = ses.getUserAgent() global.main_win = win if (configs.hide_at_launch) { win.hide() } let hide_dock_icon = await configGet('hide_dock_icon') if (hide_dock_icon) { app.dock && app.dock.hide() } else { app.dock && app.dock.show().catch((e) => console.error(e)) } console.log('isDev: ', isDev()) if (isDev()) { process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = '1' // eslint-disable-line require-atomic-updates } makeMainMenu(configs.locale) win.loadURL(getIndex()).catch((e) => console.error(e)) if (isDev()) { // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready win.webContents.once('dom-ready', () => { win!.webContents.openDevTools() }) } win.on('close', (e: Electron.Event) => { if (global.is_will_quit) { win = null } else { e.preventDefault() win?.hide() } }) win.on('closed', () => { win = null }) ipcMain.handle('dark-mode:toggle', () => { if (nativeTheme.shouldUseDarkColors) { nativeTheme.themeSource = 'light' } else { nativeTheme.themeSource = 'dark' } return nativeTheme.shouldUseDarkColors }) ipcMain.handle('dark-mode:dark', () => { nativeTheme.themeSource = 'dark' }) ipcMain.handle('dark-mode:light', () => { nativeTheme.themeSource = 'light' }) ipcMain.handle('dark-mode:system', () => { nativeTheme.themeSource = 'system' }) } const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() } else { app.on('second-instance', (event, commandLine, workingDirectory) => { if (win) { if (win.isMinimized()) { win.restore() } win.focus() } }) } const onActive = async () => { if (win === null) { await createWindow() } else if (win.isMinimized()) { await win.restore() } win?.show() } global.tracer = new Tracer() app.on('ready', async () => { console.log(`VERSION: ${version.join('.')}`) global.session_id = uuid4() await checkSystemLocale() await createWindow() cron.start() let http_api_on = await configGet('http_api_on') let http_api_only_local = await configGet('http_api_only_local') if (http_api_on) { http_api.start(http_api_only_local) } find.makeWindow() }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) app.on('before-quit', () => (global.is_will_quit = true)) app.on('activate', onActive) message.on('active_main_window', onActive) ================================================ FILE: src/main/preload.ts ================================================ /** * preload * @author: oldj * @homepage: https://oldj.net */ import { Actions } from '@common/types' import { IPopupMenuOption } from '@common/types' import { contextBridge, ipcRenderer } from 'electron' import { EventEmitter } from 'events' declare global { interface Window { _agent: typeof _agent } } export type EventHandler = (...args: any[]) => void const ee = new EventEmitter() let x_get_idx = 0 const callAction = (action: keyof Actions, ...params: any[]) => { const callback = ['_cb', new Date().getTime(), x_get_idx++].join('_') return new Promise((resolve, reject) => { ipcRenderer.send('x_action', { action, data: params, callback, }) ipcRenderer.once(callback, (sender, err, d) => { if (err) { reject(err) } else { resolve(d) } }) }) } const broadcast = (event: string, ...args: any) => { // 广播消息给所有 render 窗口 ipcRenderer.send('x_broadcast', { event, args }) } const on = (event: string, handler: EventHandler) => { // console.log(`on [${event}]`) ee.on(event, handler) return () => off(event, handler) } const once = (event: string, handler: EventHandler) => { // console.log(`once [${event}]`) ee.once(event, handler) return () => off(event, handler) } const off = (event: string, handler: EventHandler) => { // console.log(`off [${event}]`) ee.off(event, handler) } const popupMenu = (options: IPopupMenuOption) => { ipcRenderer.send('x_popup_menu', options) } ipcRenderer.on('y_broadcast', (e, d) => { // 接收其他(包括当前) render 窗口广播的消息 ee.emit(d.event, ...d.args) }) ipcRenderer.send('x_reg') // 窗口销毁时 unreg window.addEventListener('beforeunload', () => { ipcRenderer.send('x_unreg') }) const _agent = { call: callAction, broadcast, on, once, off, popupMenu, platform: process.platform, darkModeToggle: (theme?: 'dark' | 'light' | 'system') => ipcRenderer.invoke(`dark-mode:${theme ?? 'toggle'}`), } contextBridge.exposeInMainWorld('_agent', _agent) ================================================ FILE: src/main/types.d.ts ================================================ /** * index * @author: oldj * @homepage: https://oldj.net */ import Tracer from '@main/libs/tracer' import { LocaleName } from '@common/i18n' import SwhDb from 'potdb' import { BrowserWindow } from 'electron' import * as actions from '@main/actions' export interface ActionData { action: keyof typeof actions data?: any callback: string } export interface IHostsWriteOptions { sudo_pswd?: string } declare global { var data_dir: string | undefined var swhdb: SwhDb var cfgdb: SwhDb var localdb: SwhDb var ua: string // user agent var session_id: string // A random value, refreshed every time the app starts, used to identify different startup sessions. var main_win: BrowserWindow var find_win: BrowserWindow | null var last_path: string // the last path opened by SwitchHosts var tracer: Tracer var is_will_quit: boolean var system_locale: LocaleName } ================================================ FILE: src/main/ui/checkSystemLocale.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { languages, LocaleName } from '@common/i18n' import { app } from 'electron' const isLocaleName = (locale: string): locale is LocaleName => { return Object.keys(languages).includes(locale) } export default async () => { let locale = app.getLocale() if (!locale) { return } console.log(`System locale: ${locale}`) if (locale.startsWith('en')) { locale = 'en' } else if (locale.startsWith('zh')) { locale = 'zh' } else if (locale.startsWith('fr')) { locale = 'fr' } else if (locale.startsWith('de')) { locale = 'de' } else if (locale.startsWith('ja')) { locale = 'ja' } else if (locale.startsWith('tr')) { locale = 'tr' } else if (locale.startsWith('ko')) { locale = 'ko' } if (!isLocaleName(locale)) { return } global.system_locale = locale } ================================================ FILE: src/main/ui/find.ts ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { broadcast } from '@main/core/agent' import getIndex from '@main/libs/getIndex' import isDev from '@main/libs/isDev' import events from '@common/events' import { BrowserWindow } from 'electron' import path from 'path' const makeWindow = () => { let win: BrowserWindow | null win = new BrowserWindow({ // frame: false, // titleBarStyle: 'hidden', hasShadow: true, // resizable: false, // transparent: true, width: 480, height: 400, minWidth: 400, minHeight: 400, maximizable: false, minimizable: false, skipTaskbar: true, show: false, autoHideMenuBar: true, webPreferences: { contextIsolation: true, preload: path.join(__dirname, 'preload.js'), spellcheck: true, }, }) // win.setVisibleOnAllWorkspaces(true, { // visibleOnFullScreen: true, // }) win.loadURL(`${getIndex()}#/find`).catch((e) => console.error(e)) // win.on('blur', () => win?.hide()) win.on('close', (e: Electron.Event) => { if (global.is_will_quit) { win = null global.find_win = null } else { e.preventDefault() win?.hide() broadcast(events.close_find) } }) if (isDev()) { // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready win.webContents.once('dom-ready', () => { win!.webContents.openDevTools() }) } global.find_win = win return win } export { makeWindow } ================================================ FILE: src/main/ui/menu.ts ================================================ /** * @author oldj * @blog https://oldj.net */ import { findShow } from '@main/actions' import events from '@common/events' import { BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron' import { I18N, LocaleName } from '@common/i18n' import { homepage_url, feedback_url } from '@common/constants' import { broadcast } from '@main/core/agent' export const makeMainMenu = (locale: LocaleName = 'en') => { const i18n = new I18N(locale) const { lang } = i18n const template: MenuItemConstructorOptions[] = [ { label: lang.file, submenu: [ { label: lang.new, accelerator: 'CommandOrControl+N', click: () => { broadcast(events.add_new) }, }, { type: 'separator', // }, // { // label: lang.import, // accelerator: 'Alt+CommandOrControl+I', // click: () => { // } // }, // { // label: lang.export, // accelerator: 'Alt+CommandOrControl+E', // click: () => { // } // }, // { // type: 'separator' }, { label: lang.preferences, accelerator: 'CommandOrControl+,', click: () => { broadcast(events.show_preferences) }, }, ], }, { label: lang.edit, submenu: [ { role: 'undo', label: lang.undo, }, { role: 'redo', label: lang.redo, }, { type: 'separator', }, { role: 'cut', label: lang.cut, }, { role: 'copy', label: lang.copy, }, { role: 'paste', label: lang.paste, }, { role: 'delete', label: lang.delete, }, { role: 'selectAll', label: lang.select_all, }, { type: 'separator', }, { label: lang.comment_current_line, accelerator: 'CommandOrControl+/', click() { broadcast(events.toggle_comment) }, }, { label: lang.find_and_replace, accelerator: 'CommandOrControl+F', click() { findShow() }, }, ], }, { label: lang.view, submenu: [ { label: lang.reload, accelerator: 'CmdOrCtrl+R', click(_item: MenuItem, focusedWindow) { if (!(focusedWindow instanceof BrowserWindow)) return if (focusedWindow) focusedWindow.reload() }, }, { label: lang.toggle_developer_tools, // 'Toggle Developer Tools', accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', click(_item: MenuItem, focusedWindow) { if (!(focusedWindow instanceof BrowserWindow)) return if (focusedWindow) focusedWindow.webContents.toggleDevTools() }, }, { type: 'separator', }, { role: 'resetZoom', label: lang.reset_zoom, }, { role: 'zoomIn', label: lang.zoom_in, }, { role: 'zoomOut', label: lang.zoom_out, }, { type: 'separator', }, { role: 'togglefullscreen', label: lang.toggle_full_screen, }, ], }, { label: lang.window, role: 'window', submenu: [ { role: 'minimize', label: lang.minimize, }, { role: 'close', label: lang.close, }, ], }, { label: lang.help, role: 'help', submenu: [ // { // label: lang.check_update, // click () { // checkUpdate.check() // } // }, // { // type: 'separator', // }, { label: lang.feedback, click() { shell.openExternal(feedback_url).catch((e) => console.log(e)) }, }, { label: lang.homepage, click() { shell.openExternal(homepage_url).catch((e) => console.log(e)) }, }, ], }, ] const name = 'SwitchHosts' const os = process.platform if (os === 'darwin') { template.unshift({ label: name, submenu: [ { label: lang.about, //role: 'about', click: () => { broadcast(events.show_about) }, }, { type: 'separator', }, // { // role: 'services', // submenu: [] // }, // { // type: 'separator' // }, { role: 'hide', label: lang.hide, }, { role: 'hideOthers', label: lang.hide_others, }, { role: 'unhide', label: lang.unhide, }, { type: 'separator', }, { role: 'quit', label: lang.quit, }, ], }) // Edit menu. /*template[2].submenu.push( { type: 'separator' }, { label: 'Speech', submenu: [ { role: 'startspeaking' }, { role: 'stopspeaking' } ] } );*/ // Window menu. template[4].submenu = [ { accelerator: 'CmdOrCtrl+W', role: 'close', label: lang.close, }, { accelerator: 'CmdOrCtrl+M', role: 'minimize', label: lang.minimize, }, { role: 'zoom', label: lang.zoom, }, { type: 'separator', }, // { // role: 'front', // label: lang.front, // }, ] } else if (os === 'win32' || os === 'linux') { let submenu = (template[0] && template[0].submenu) as MenuItemConstructorOptions[] if (submenu) { submenu.unshift({ type: 'separator', }) submenu.unshift({ label: `${lang.about} ${name}`, //role: 'about', click: () => { broadcast(events.show_about) }, }) submenu.push({ type: 'separator', }) submenu.push({ role: 'quit', label: lang.quit, accelerator: 'CmdOrCtrl+Q', }) } // VIEW submenu = (template[2] && template[2].submenu) as MenuItemConstructorOptions[] submenu.splice(0, 4) } // if (isDev()) { // // VIEW // // @ts-ignore // template[3].submenu = [ // // @ts-ignore // ...template[3].submenu, // ] // } const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } ================================================ FILE: src/main/ui/tray/index.ts ================================================ /** * tray * @author: oldj * @homepage: https://oldj.net */ import { configGet, configSet, updateTrayTitle } from '@main/actions' import { broadcast } from '@main/core/agent' import { makeWindow } from '@main/ui/tray/window' import events from '@common/events' import { I18N } from '@common/i18n' import version from '@/version.json' import { app, BrowserWindow, Menu, MenuItemConstructorOptions, screen, Tray } from 'electron' import * as fs from 'fs' import * as path from 'path' let tray: Tray let win: BrowserWindow const getTrayIconPath = () => { const iconCandidates = process.platform === 'darwin' ? ['logoTemplate.png', 'logoTemplate@2x.png', 'logo@512w.png'] : ['logo@512w.png', 'logo.png'] const baseDirCandidates = [path.join(__dirname, 'assets'), path.join(__dirname, '..', 'src', 'assets')] for (const icon of iconCandidates) { for (const baseDir of baseDirCandidates) { const iconPath = path.join(baseDir, icon) if (fs.existsSync(iconPath)) { return iconPath } } } return path.join(__dirname, 'assets', process.platform === 'darwin' ? 'logoTemplate.png' : 'logo@512w.png') } const makeTray = async () => { tray = new Tray(getTrayIconPath()) win = makeWindow() updateTrayTitle().catch((e) => console.error(e)) tray.setToolTip('SwitchHosts') let locale = await configGet('locale') if (process.platform === 'linux') { locale = global.system_locale // configGet() always get undefined on Linux } const i18n = new I18N(locale) const { lang } = i18n const ver = version.slice(0, 3).join('.') + ` (${version[3]})` if (process.platform === 'linux') { const menu = Menu.buildFromTemplate([ { label: lang.click_to_open, click: () => window(), }, { type: 'separator' }, { label: lang._app_name, toolTip: lang.show_main_window, click: () => { broadcast(events.active_main_window) }, }, { label: `v${ver}`, enabled: false, }, { type: 'separator' }, { label: lang.quit, role: 'quit', }, ]) // Linux requires setContextMenu to be called in order for the context menu to populate correctly tray.setContextMenu(menu) return } tray.on('click', async () => { let tray_mini_window = await configGet('tray_mini_window') tray_mini_window ? window() : broadcast(events.active_main_window) }) tray.on('double-click', () => broadcast(events.active_main_window)) tray.on('right-click', async () => { let hide_dock_icon = await configGet('hide_dock_icon') const menu = Menu.buildFromTemplate([ { label: lang._app_name, toolTip: lang.show_main_window, click() { broadcast(events.active_main_window) }, }, { label: `v${ver}`, enabled: false, }, ...(app.dock ? [ { type: 'separator' }, { label: hide_dock_icon ? lang.show_dock_icon : lang.hide_dock_icon, async click() { let hide_dock_icon = await configGet('hide_dock_icon') hide_dock_icon = !hide_dock_icon await configSet('hide_dock_icon', hide_dock_icon) if (!app.dock) return if (hide_dock_icon) { app.dock.hide() } else { app.dock.show().catch((e) => console.error(e)) } }, }, ] : []), { type: 'separator' }, { label: lang.quit, role: 'quit', }, ]) tray.popUpContextMenu(menu) }) } const getPosition = () => { const tray_bounds = tray.getBounds() const window_bounds = win.getBounds() const point = screen.getCursorScreenPoint() const screen_bounds0 = screen.getDisplayNearestPoint(point).bounds const screen_bounds = screen.getDisplayNearestPoint(point).workAreaSize let x: number let y: number let dw = screen_bounds0.width - screen_bounds.width if (dw > 0 && tray_bounds.x < dw) { // tray is at left x = dw } else { x = tray_bounds.x + tray_bounds.width / 2 - window_bounds.width / 2 } // let dh = screen_bounds0.height - screen_bounds.height if (tray_bounds.y < screen_bounds.height / 2) { y = tray_bounds.y + tray_bounds.height } else { y = tray_bounds.y - window_bounds.height - 2 } if (x < 0) x = 0 if (x + window_bounds.width > screen_bounds.width) x = screen_bounds.width - window_bounds.width x = Math.round(x) y = Math.round(y) return { x, y } } const getLinuxPosition = () => { const window_bounds = win.getBounds() const point = screen.getCursorScreenPoint() const screen_bounds0 = screen.getDisplayNearestPoint(point).bounds const screen_bounds = screen.getDisplayNearestPoint(point).workAreaSize let x: number let y: number if (point.x - screen_bounds0.x > screen_bounds.width / 2) { // display on the right of the active screen x = screen_bounds0.x + screen_bounds0.width - window_bounds.width } else { x = 0 } if (point.y < screen_bounds.height / 2) { // display on the top of the active screen y = 0 } else { y = screen_bounds.height - window_bounds.height } x = Math.round(x) y = Math.round(y) return { x, y } } const window = () => { if (!win) { makeWindow() return } if (win.isVisible()) { if (win.isFocused()) { win.hide() } else { show() win.focus() } } else { show() } } const show = () => { let { x, y } = process.platform === 'linux' ? getLinuxPosition() : getPosition() win.setPosition(x, y, true) win.show() // win.focus() } app && app.whenReady().then(() => { if (!tray) { makeTray() } }) export { tray, makeTray } ================================================ FILE: src/main/ui/tray/window.ts ================================================ /** * window * @author: oldj * @homepage: https://oldj.net */ import getIndex from '@main/libs/getIndex' import isDev from '@main/libs/isDev' import { BrowserWindow } from 'electron' import path from 'path' const makeWindow = () => { let win: BrowserWindow | null // Linux AppImage APP can't automatically recognize dock icon, requires special configuration to display correctly let linux_icon = {} if (process.platform === 'linux') { linux_icon = { icon: path.join(__dirname, '/assets/icon.png'), } } win = new BrowserWindow({ frame: false, // titleBarStyle: 'hidden', hasShadow: true, resizable: false, // transparent: true, width: 300, height: 600, minWidth: 300, minHeight: 200, maximizable: false, minimizable: false, skipTaskbar: true, show: false, webPreferences: { contextIsolation: true, preload: path.join(__dirname, 'preload.js'), spellcheck: true, }, ...linux_icon, }) win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, }) win.loadURL(`${getIndex()}#/tray`).catch((e) => console.error(e)) win.on('blur', () => win?.hide()) win.on('close', (e: Electron.Event) => { if (global.is_will_quit) { win = null } else { e.preventDefault() win?.hide() } }) if (isDev()) { // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready win.webContents.once('dom-ready', () => { win!.webContents.openDevTools() }) } return win } export { makeWindow } ================================================ FILE: src/main/utils/fs2.ts ================================================ /** * fs2 * @author: oldj * @homepage: https://oldj.net */ import * as fs from 'fs' export const isDir = (dir_path: string): boolean => { return fs.existsSync(dir_path) && fs.lstatSync(dir_path).isDirectory() } ================================================ FILE: src/renderer/common/PageWrapper.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import Loading from '@renderer/components/Loading' import React, { Suspense } from 'react' interface IProps { children?: React.ReactNode } function PageWrapper(props: IProps) { const { children } = props return }>{children} } export default PageWrapper ================================================ FILE: src/renderer/components/About/AboutContent.module.scss ================================================ @use '../../styles/common'; .root { // padding-bottom: 20px; a { color: var(--swh-primary-color); &:hover { opacity: 0.8; } } } .logo { width: 64px; height: 64px; border-radius: 50%; box-shadow: 0 1px 1px 1px rgba(0, 0, 0, 0.1); } .names { @include common.swh-scroll-y; display: flex; flex-wrap: wrap; gap: 4px 16px; //justify-content: center; margin: 8px 0; //max-width: 350px; max-height: 160px; overflow-y: auto; border: 1px solid var(--swh-border-color-1); border-radius: 4px; padding: 8px; // a { // margin: 0 0.5em; // display: inline-block; // } } ================================================ FILE: src/renderer/components/About/AboutContent.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import logo from '@/assets/logo@512w.png' import version from '@/version.json' import acknowledgements from '@common/acknowledgements' import { homepage_url, source_url } from '@common/constants' import { Box, Center, Flex, Image, Stack } from '@mantine/core' import { default as Link } from '@renderer/components/BrowserLink' import useI18n from '@renderer/models/useI18n' import styles from './AboutContent.module.scss' const AboutContent = () => { const { lang } = useI18n() const version_str = version.slice(0, 3).join('.') + ` (${version[3]})` return (
{lang._app_name}
v{version_str}
{lang.homepage} {lang.source_code}
{lang.acknowledgement}
{acknowledgements.map((o, idx) => ( {o.name} ))}
) } export default AboutContent ================================================ FILE: src/renderer/components/About/index.module.scss ================================================ .close_btn { position: absolute; right: 12px; top: 12px; } .body { padding-top: 10px; } ================================================ FILE: src/renderer/components/About/index.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import events from '@common/events' import { Modal } from '@mantine/core' import AboutContent from '@renderer/components/About/AboutContent' import useOnBroadcast from '@renderer/core/useOnBroadcast' import { useState } from 'react' import styles from './index.module.scss' const About = () => { const [opened, setOpened] = useState(false) const onClose = () => setOpened(false) useOnBroadcast(events.show_about, () => setOpened(true)) return (
) } export default About ================================================ FILE: src/renderer/components/BrowserLink.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import events from '@common/events' import { actions, agent } from '@renderer/core/agent' import React from 'react' interface Props { href: string children: React.ReactElement | string [key: string]: any } const BrowserLink = (props: Props) => { const { href } = props const onClick = (e: React.MouseEvent) => { e.preventDefault() agent.broadcast(events.browser_link, href) actions.openUrl(href).catch((e) => console.error(e)) } return ( {props.children} ) } export default BrowserLink ================================================ FILE: src/renderer/components/EditHostsInfo.module.scss ================================================ .ln { margin-bottom: 20px; &:last-child { margin-bottom: 0; } } .refresh_info { color: var(--swh-font-color-weak); span { margin-right: 8px; } } ================================================ FILE: src/renderer/components/EditHostsInfo.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { FolderModeType, HostsType, IHostsListObject } from '@common/data' import events from '@common/events' import * as hostsFn from '@common/hostsFn' import { Box, Button, Group, NativeSelect, Radio, SimpleGrid, Text, TextInput, } from '@mantine/core' import ItemIcon from '@renderer/components/ItemIcon' import SideDrawer from '@renderer/components/SideDrawer' import Transfer from '@renderer/components/Transfer' import { actions, agent } from '@renderer/core/agent' import useOnBroadcast from '@renderer/core/useOnBroadcast' import lodash from 'lodash' import React, { useState } from 'react' import { BiEdit, BiTrash } from 'react-icons/bi' import { v4 as uuidv4 } from 'uuid' import useHostsData from '../models/useHostsData' import useI18n from '../models/useI18n' import styles from './EditHostsInfo.module.scss' const EditHostsInfo = () => { const { lang } = useI18n() const [hosts, setHosts] = useState(null) const { hosts_data, setList, current_hosts, setCurrentHosts } = useHostsData() const [is_show, setIsShow] = useState(false) const [is_add, setIsAdd] = useState(true) const [is_refreshing, setIsRefreshing] = useState(false) const onCancel = () => { setHosts(null) setIsShow(false) } const onSave = async () => { let data: Omit & { id?: string } = { ...hosts } const keys_to_trim = ['title', 'url'] keys_to_trim.map((k) => { if (data[k]) { data[k] = data[k].trim() } }) if (is_add) { let h: IHostsListObject = { ...data, id: uuidv4(), } let list: IHostsListObject[] = [...hosts_data.list, h] await setList(list) agent.broadcast(events.select_hosts, h.id, 1000) } else if (data && data.id) { let h: IHostsListObject | undefined = hostsFn.findItemById(hosts_data.list, data.id) if (h) { Object.assign(h, data) await setList([...hosts_data.list]) if (data.id === current_hosts?.id) { setCurrentHosts(h) } } else { setIsAdd(true) setTimeout(onSave, 300) return } } else { alert('unknown error!') } setIsShow(false) } const onUpdate = (kv: Partial) => { let obj: IHostsListObject = Object.assign({}, hosts, kv) setHosts(obj) } useOnBroadcast(events.edit_hosts_info, (hosts?: IHostsListObject) => { setHosts(hosts || null) setIsAdd(!hosts) setIsShow(true) }) useOnBroadcast(events.add_new, () => { setHosts(null) setIsAdd(true) setIsShow(true) }) useOnBroadcast( events.hosts_refreshed, (_hosts: IHostsListObject) => { if (hosts && hosts.id === _hosts.id) { onUpdate(lodash.pick(_hosts, ['last_refresh', 'last_refresh_ms'])) } }, [hosts], ) const forRemote = (): React.ReactElement => { return ( <> URL ) => onUpdate({ url: e.target.value })} placeholder={lang.url_placeholder} onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && onSave()} /> {lang.auto_refresh} onUpdate({ refresh_interval: parseInt(e.target.value) || 0 })} data={[ { value: '0', label: lang.never }, { value: '60', label: `1 ${lang.minute}` }, { value: `${60 * 5}`, label: `5 ${lang.minutes}` }, { value: `${60 * 15}`, label: `15 ${lang.minutes}` }, { value: `${60 * 60}`, label: `1 ${lang.hour}` }, { value: `${60 * 60 * 24}`, label: `24 ${lang.hours}` }, { value: `${60 * 60 * 24 * 7}`, label: `7 ${lang.days}` }, ]} maw={160} /> {is_add ? null : ( {lang.last_refresh} {hosts?.last_refresh || 'N/A'} )} ) } const renderTransferItem = (item: IHostsListObject): React.ReactElement => { return ( {item.title || lang.untitled} ) } const forGroup = (): React.ReactElement => { const list = hostsFn.flatten(hosts_data.list) let source_list: IHostsListObject[] = list .filter((item) => !item.type || item.type === 'local' || item.type === 'remote') .map((item) => { let o = { ...item } o.key = o.id return o }) let target_keys: string[] = hosts?.include || [] return ( {lang.content} { onUpdate({ include: next_target_keys }) }} /> ) } const forFolder = (): React.ReactElement => { return ( {lang.choice_mode} onUpdate({ folder_mode: (parseInt(v) || 0) as FolderModeType })} > ) } const types: HostsType[] = ['local', 'remote', 'group', 'folder'] return ( {is_add ? lang.hosts_add : lang.hosts_edit} } scrollAreaStyle={{ paddingBottom: 24, }} footer={ {is_add ? null : ( )} } > {lang.hosts_type} onUpdate({ type: v as HostsType })} > {types.map((type) => ( {lang[type]} } /> ))} {lang.hosts_title} ) => onUpdate({ title: e.target.value })} onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && onSave()} /> {hosts?.type === 'remote' ? forRemote() : null} {hosts?.type === 'group' ? forGroup() : null} {hosts?.type === 'folder' ? forFolder() : null} ) } export default EditHostsInfo ================================================ FILE: src/renderer/components/Editor/HostsEditor.module.scss ================================================ @use '../../styles/common'; .root { @include common.code; width: 100%; height: 100%; } .editor { height: calc(100% - var(--swh-status-bar-height)); background: inherit; &.read_only { .surface { background: var(--swh-editor-read-only-bg); caret-color: transparent; opacity: 0.8; // box-shadow: inset 0 0 0 1px var(--swh-border-color-0); } :global { .codejar-linenumbers, .codejar-linenumbers-inner-wrap { background: var(--swh-editor-read-only-bg) !important; } } } :global { .codejar-wrap { height: 100%; } .codejar-linenumbers-inner-wrap { background: var(--swh-editor-gutter-bg) !important; } .codejar-linenumbers { border-right: none; padding-right: 6px; background: var(--swh-editor-gutter-bg); user-select: none; cursor: pointer; } .codejar-linenumber { color: var(--swh-editor-line-number-color); font-size: 12px; } .hl-comment { color: var(--swh-editor-comment); } .hl-ip { color: var(--swh-editor-ip); font-weight: bold; } .hl-error { color: var(--swh-editor-error); } } } .mount { height: 100%; } .surface { box-sizing: border-box; width: 100%; height: 100%; padding: 8px 10px; overflow: auto; background: var(--swh-editor-bg-color); color: var(--swh-editor-text-color); font-family: common.$font-editor; font-size: var(--swh-editor-font-size); line-height: var(--swh-editor-line-height); white-space: pre; caret-color: var(--swh-editor-text-color); } .surface:focus { outline: none; } :global(.theme-dark) { .editor { .surface { caret-color: #39c; } &.read_only { .surface { // box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); caret-color: transparent; } } } } ================================================ FILE: src/renderer/components/Editor/HostsEditor.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IHostsListObject } from '@common/data' import events from '@common/events' import { normalizeLineEndings } from '@common/newlines' import { IFindShowSourceParam } from '@common/types' import StatusBar from '@renderer/components/StatusBar' import { actions, agent } from '@renderer/core/agent' import useOnBroadcast from '@renderer/core/useOnBroadcast' import useHostsData from '@renderer/models/useHostsData' import { useDebounceFn } from 'ahooks' import clsx from 'clsx' import { CodeJar, type Position } from 'codejar' import { withLineNumbers } from 'codejar-linenumbers' import 'codejar-linenumbers/es/codejar-linenumbers.css' import { useEffect, useRef, useState } from 'react' import { highlightHosts, toggleCommentByLine, toggleCommentBySelection } from './hosts_highlight' import styles from './HostsEditor.module.scss' const HostsEditor = () => { const { current_hosts, isReadOnly } = useHostsData() const hosts_id = current_hosts?.id || '0' const is_read_only = isReadOnly(current_hosts) const [content, setContent] = useState('') const ref_mount = useRef(null) // outer container that hosts the CodeJar wrapper const ref_editor = useRef(null) // contenteditable div managed by CodeJar const ref_jar = useRef | null>(null) // Refs mirror React state so that callbacks inside the CodeJar effect // (which only re-runs on hosts_id change) can always read the latest values. const ref_hosts_id = useRef(hosts_id) const ref_is_read_only = useRef(is_read_only) // Pending find: when a show_source event arrives before the target hosts is loaded, // we stash the params here and apply them once loadContent finishes (with a 3s timeout). const ref_pending_find = useRef(null) const ref_pending_find_timer = useRef(null) useEffect(() => { ref_hosts_id.current = hosts_id }, [hosts_id]) useEffect(() => { ref_is_read_only.current = is_read_only }, [is_read_only]) const clearPendingFind = () => { if (ref_pending_find_timer.current) { window.clearTimeout(ref_pending_find_timer.current) ref_pending_find_timer.current = null } ref_pending_find.current = null } useEffect(() => clearPendingFind, []) const { run: toSave } = useDebounceFn( (id: string, nextContent: string) => { actions .setHostsContent(id, nextContent) .then(() => agent.broadcast(events.hosts_content_changed, id)) .catch((e) => console.error(e)) }, { wait: 1000 }, ) /** Toggle contenteditable between 'plaintext-only' and 'false' (Chromium/Electron only). */ const setEditorReadOnly = (readOnly: boolean) => { const editor = ref_editor.current if (!editor) return editor.setAttribute('contenteditable', readOnly ? 'false' : 'plaintext-only') editor.setAttribute('aria-readonly', readOnly ? 'true' : 'false') } /** Scroll the current selection/cursor into view after programmatic focus changes. */ const scrollSelectionIntoView = () => { const editor = ref_editor.current if (!editor) return const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return const range = selection.getRangeAt(0) const startNode = range.startContainer const target = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : (startNode as Element | null) ;(target ?? editor).scrollIntoView({ block: 'nearest', inline: 'nearest', }) } /** Restore a character-offset selection in the editor (used by find/show-source). */ const setSelection = (params: IFindShowSourceParam) => { const jar = ref_jar.current const editor = ref_editor.current if (!jar || !editor) return const editorContent = jar.toString() const start = Math.max(0, Math.min(params.start, editorContent.length)) const end = Math.max(0, Math.min(params.end, editorContent.length)) jar.restore({ start, end, dir: '->', }) editor.focus() window.requestAnimationFrame(scrollSelectionIntoView) } /** Fetch and display the hosts content. Applies any pending find selection after loading. */ const loadContent = async (targetHostsId = hosts_id) => { const jar = ref_jar.current if (!jar) return const nextContent = normalizeLineEndings( targetHostsId === '0' ? await actions.getSystemHosts() : await actions.getHostsContent(targetHostsId), ) if (ref_hosts_id.current !== targetHostsId) return setContent(nextContent) jar.updateCode(nextContent, false) const pendingFind = ref_pending_find.current if (pendingFind && pendingFind.item_id === targetHostsId) { setSelection(pendingFind) clearPendingFind() } } const getCurrentSelection = (): Position => { const jar = ref_jar.current const editor = ref_editor.current const fallbackOffset = jar?.toString().length ?? 0 if (!jar || !editor) { return { start: fallbackOffset, end: fallbackOffset, dir: '->', } } try { return jar.save() } catch { return { start: fallbackOffset, end: fallbackOffset, dir: '->', } } } const onChange = (nextContent: string) => { const normalizedContent = normalizeLineEndings(nextContent) setContent(normalizedContent) toSave(hosts_id, normalizedContent) } /** Push a programmatic edit into CodeJar: update content, restore selection, and record undo history. */ const applyEditorChange = (nextContent: string, nextSelection: Position) => { const jar = ref_jar.current const editor = ref_editor.current if (!jar || !editor) return editor.focus() jar.recordHistory() jar.updateCode(nextContent, false) jar.restore(nextSelection) editor.focus() jar.recordHistory() onChange(nextContent) } const toggleComment = () => { if (ref_is_read_only.current) return const jar = ref_jar.current if (!jar) return const selection = getCurrentSelection() const next = toggleCommentBySelection(jar.toString(), selection.start, selection.end, true) if (!next.changed) return applyEditorChange(next.content, { start: next.selectionStart, end: next.selectionEnd, dir: '->', }) } /** Handle a click on the line-number gutter to toggle comment on that line. */ const onGutterClick = (lineIndex: number) => { if (ref_is_read_only.current) return const jar = ref_jar.current if (!jar) return const selection = getCurrentSelection() const next = toggleCommentByLine(jar.toString(), lineIndex, selection.start, selection.end) if (!next.changed) return applyEditorChange(next.content, { start: next.selectionStart, end: next.selectionEnd, dir: '->', }) } useEffect(() => { const mount = ref_mount.current if (!mount) return mount.replaceChildren() const editor = document.createElement('div') editor.className = styles.surface editor.tabIndex = 0 mount.appendChild(editor) const jar = CodeJar( editor, withLineNumbers(highlightHosts, { width: '25px', backgroundColor: 'var(--swh-editor-gutter-bg)', color: 'var(--swh-editor-line-number-color)', }), ) ref_editor.current = editor ref_jar.current = jar setEditorReadOnly(is_read_only) const onEditorUpdate = (nextContent: string) => { onChange(nextContent) } // Detect clicks on the line-number gutter and convert the click Y position // into a zero-based line index, accounting for scroll offset of the wrapper. const onMountClick = (event: MouseEvent) => { const target = event.target as HTMLElement | null const gutter = target?.closest('.codejar-linenumbers') if (!gutter) return const lineHeight = parseFloat(window.getComputedStyle(editor).lineHeight) || 24 const scrollContainer = gutter.closest('.codejar-wrap') ?? editor const relativeY = event.clientY - gutter.getBoundingClientRect().top + scrollContainer.scrollTop const lineCount = Math.max(1, jar.toString().split('\n').length) const lineIndex = Math.max(0, Math.min(lineCount - 1, Math.floor(relativeY / lineHeight))) event.preventDefault() onGutterClick(lineIndex) } jar.onUpdate(onEditorUpdate) jar.updateCode('', false) mount.addEventListener('click', onMountClick) loadContent(hosts_id).catch((e) => console.error(e)) return () => { mount.removeEventListener('click', onMountClick) jar.destroy() mount.replaceChildren() ref_jar.current = null ref_editor.current = null } }, [hosts_id]) useEffect(() => { setEditorReadOnly(is_read_only) }, [is_read_only]) useOnBroadcast( events.hosts_refreshed, (h: IHostsListObject) => { if (hosts_id !== '0' && h.id !== hosts_id) return loadContent().catch((e) => console.error(e)) }, [hosts_id], ) useOnBroadcast( events.hosts_refreshed_by_id, (id: string) => { if (hosts_id !== '0' && hosts_id !== id) return loadContent().catch((e) => console.error(e)) }, [hosts_id], ) useOnBroadcast( events.set_hosts_on_status, () => { if (hosts_id === '0') { loadContent().catch((e) => console.error(e)) } }, [hosts_id], ) useOnBroadcast( events.system_hosts_updated, () => { if (hosts_id === '0') { loadContent().catch((e) => console.error(e)) } }, [hosts_id], ) useOnBroadcast(events.toggle_comment, toggleComment, [hosts_id]) useOnBroadcast( events.show_source, (params: IFindShowSourceParam) => { if (params.item_id !== hosts_id || !ref_jar.current) { clearPendingFind() ref_pending_find.current = params ref_pending_find_timer.current = window.setTimeout(clearPendingFind, 3000) return } clearPendingFind() setSelection(params) }, [hosts_id], ) return (
) } export default HostsEditor ================================================ FILE: src/renderer/components/Editor/hosts_highlight.test.ts ================================================ /** * Tests for hosts file syntax highlighting and comment toggling. * Covers HTML rendering of comment / IP / error lines, * single-line and multi-line comment toggle with cursor adjustment, * and gutter (line-index) based toggling. */ import { highlightHostsLine, highlightHostsText, toggleCommentByLine, toggleCommentBySelection, } from './hosts_highlight' import { describe, expect, it } from 'vitest' describe('hosts_highlight', () => { it('highlights comment lines', () => { expect(highlightHostsLine(' # localhost')).toBe( ' # localhost', ) }) it('highlights valid hosts lines with leading whitespace', () => { expect(highlightHostsLine(' 127.0.0.1 localhost')).toBe( ' 127.0.0.1 localhost', ) }) it('marks invalid lines as errors and escapes html', () => { expect(highlightHostsLine('foo ')).toBe( 'foo <bar>', ) }) it('preserves multiline output including trailing newline', () => { expect(highlightHostsText('127.0.0.1 localhost\n# ok\n')).toBe( '127.0.0.1 localhost\n# ok\n', ) }) it('normalizes CRLF input before highlighting', () => { expect(highlightHostsText('127.0.0.1 localhost\r\n# ok\r\n')).toBe( '127.0.0.1 localhost\n# ok\n', ) }) it('toggles the current line and moves the cursor to the next line', () => { const code = '127.0.0.1 localhost\nfoo' const result = toggleCommentBySelection(code, 0, 0, true) expect(result.content).toBe('# 127.0.0.1 localhost\nfoo') expect(result.selectionStart).toBe('# 127.0.0.1 localhost\n'.length) expect(result.selectionEnd).toBe('# 127.0.0.1 localhost\n'.length) }) it('toggles every line touched by a selection', () => { const code = '127.0.0.1 localhost\nfoo' const result = toggleCommentBySelection(code, 0, code.length) expect(result.content).toBe('# 127.0.0.1 localhost\n# foo') expect(result.selectionStart).toBe(2) expect(result.selectionEnd).toBe(code.length + 4) }) it('keeps blank lines as no-op', () => { const code = 'foo\n\nbar' const result = toggleCommentBySelection(code, 4, 4, true) expect(result.changed).toBe(false) expect(result.content).toBe(code) expect(result.selectionStart).toBe(4) expect(result.selectionEnd).toBe(4) }) it('adjusts selection offsets when uncommenting indented lines', () => { const code = ' # foo\nbar' const result = toggleCommentBySelection(code, 4, 7) expect(result.content).toBe(' foo\nbar') expect(result.selectionStart).toBe(2) expect(result.selectionEnd).toBe(5) }) it('toggles a single line by gutter index', () => { const code = 'foo\nbar' const result = toggleCommentByLine(code, 1, 0, 0) expect(result.content).toBe('foo\n# bar') expect(result.selectionStart).toBe(0) expect(result.selectionEnd).toBe(0) }) it('normalizes CRLF before toggling comments', () => { const result = toggleCommentBySelection('foo\r\nbar', 0, 0, true) expect(result.content).toBe('# foo\nbar') expect(result.selectionStart).toBe('# foo\n'.length) expect(result.selectionEnd).toBe('# foo\n'.length) }) }) ================================================ FILE: src/renderer/components/Editor/hosts_highlight.ts ================================================ /** * Hosts file syntax highlighting and comment toggling for CodeJar. * * Highlighting: converts plain-text hosts content into HTML with * `hl-comment`, `hl-ip`, and `hl-error` spans for styling. * * Comment toggling: adds/removes `# ` prefixes while preserving * cursor/selection positions via offset-based transforms. */ import type { Position } from 'codejar' import { normalizeLineEndings } from '@common/newlines' /** Matches a valid hosts entry: optional whitespace, an IPv4/IPv6 address, then a hostname. */ const HOSTS_LINE_RE = /^\s*([\d.]+|[\da-f:.%lo]+)\s+\w/i /** Captures the leading indent and `# ` prefix of a comment line for removal. */ const COMMENT_LINE_RE = /^(\s*)#\s*/ /** A single line with its byte offsets within the full document. */ interface LineInfo { start: number end: number text: string } /** * Transform records describe how a single toggle operation shifted characters. * They are collected per-line and then applied to map the original cursor/selection * offsets to their new positions in the modified text. */ interface InsertTransform { type: 'insert' at: number length: number } interface RemoveTransform { type: 'remove' start: number end: number } type Transform = InsertTransform | RemoveTransform export interface CommentToggleResult { content: string selectionStart: number selectionEnd: number changed: boolean } interface ToggleLineResult { nextText: string changed: boolean transform?: Transform } export function escapeHtml(text: string): string { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } export function isHostsCommentLine(line: string): boolean { return /^\s*#/.test(line) } export function isValidHostsLine(line: string): boolean { return HOSTS_LINE_RE.test(line) } export function highlightHostsLine(line: string): string { if (!line) return '' if (isHostsCommentLine(line)) { return `${escapeHtml(line)}` } if (!isValidHostsLine(line)) { return `${escapeHtml(line)}` } const match = line.match(/^(\s*)([\w.:%]+)/) if (!match) { return escapeHtml(line) } const [, indent, ip] = match const rest = line.slice(indent.length + ip.length) return `${escapeHtml(indent)}${escapeHtml(ip)}${escapeHtml(rest)}` } export function highlightHostsText(code: string): string { return normalizeLineEndings(code) .split('\n') .map((line) => highlightHostsLine(line)) .join('\n') } /** CodeJar highlight callback — replaces the editor's innerHTML with syntax-highlighted HTML. */ export function highlightHosts(editor: HTMLElement, _pos?: Position): void { editor.innerHTML = highlightHostsText(editor.textContent || '') } function getLines(code: string): LineInfo[] { const parts = normalizeLineEndings(code).split('\n') let start = 0 return parts.map((text) => { const line = { start, end: start + text.length, text, } start += text.length + 1 return line }) } function getLineIndexAtOffset(lines: LineInfo[], offset: number): number { if (lines.length === 0) return 0 for (let i = lines.length - 1; i >= 0; i -= 1) { if (offset >= lines[i].start) { return i } } return 0 } function toggleLine(line: string, lineStart: number): ToggleLineResult { if (/^\s*$/.test(line)) { return { nextText: line, changed: false, } } const commentMatch = line.match(COMMENT_LINE_RE) if (commentMatch) { const indent = commentMatch[1] return { nextText: line.replace(COMMENT_LINE_RE, '$1'), changed: true, transform: { type: 'remove', start: lineStart + indent.length, end: lineStart + commentMatch[0].length, }, } } return { nextText: `# ${line}`, changed: true, transform: { type: 'insert', at: lineStart, length: 2, }, } } /** Map an original document offset through a series of insert/remove transforms. */ function mapOffset(offset: number, transforms: Transform[]): number { let mapped = offset for (const transform of transforms) { if (transform.type === 'insert') { if (offset >= transform.at) { mapped += transform.length } continue } if (offset <= transform.start) continue if (offset < transform.end) { mapped -= offset - transform.start continue } mapped -= transform.end - transform.start } return mapped } function getLineStartOffsets(lines: string[]): number[] { const starts: number[] = [] let start = 0 for (const line of lines) { starts.push(start) start += line.length + 1 } return starts } function getSelectionRange(selectionStart: number, selectionEnd: number) { return { start: Math.min(selectionStart, selectionEnd), end: Math.max(selectionStart, selectionEnd), } } /** * Core toggle implementation: comment/uncomment lines in [startLineIndex, endLineIndex], * returning the updated text and adjusted selection offsets. * When `moveToNextLine` is true and the selection is collapsed (cursor), the cursor * is moved to the start of the next line after toggling (mimics IDE behavior). */ function toggleCommentLines( code: string, selectionStart: number, selectionEnd: number, startLineIndex: number, endLineIndex: number, moveToNextLine: boolean, ): CommentToggleResult { const lines = getLines(code) const nextLines = lines.map((line) => line.text) const transforms: Transform[] = [] let changed = false for (let i = startLineIndex; i <= endLineIndex; i += 1) { const line = lines[i] const result = toggleLine(line.text, line.start) nextLines[i] = result.nextText changed ||= result.changed if (result.transform) { transforms.push(result.transform) } } if (!changed) { return { content: code, selectionStart, selectionEnd, changed: false, } } const nextContent = nextLines.join('\n') if (moveToNextLine && selectionStart === selectionEnd) { const nextStarts = getLineStartOffsets(nextLines) const nextLineIndex = startLineIndex + 1 const nextOffset = nextStarts[nextLineIndex] ?? nextContent.length return { content: nextContent, selectionStart: nextOffset, selectionEnd: nextOffset, changed: true, } } return { content: nextContent, selectionStart: mapOffset(selectionStart, transforms), selectionEnd: mapOffset(selectionEnd, transforms), changed: true, } } /** Toggle comment on all lines touched by the current selection range. */ export function toggleCommentBySelection( code: string, selectionStart: number, selectionEnd: number, moveToNextLine = false, ): CommentToggleResult { const normalizedCode = normalizeLineEndings(code) const lines = getLines(normalizedCode) const { start, end } = getSelectionRange(selectionStart, selectionEnd) const startLineIndex = getLineIndexAtOffset(lines, start) const endLineIndex = start === end ? startLineIndex : getLineIndexAtOffset(lines, Math.max(start, end - 1)) return toggleCommentLines( normalizedCode, selectionStart, selectionEnd, startLineIndex, endLineIndex, moveToNextLine, ) } /** Toggle comment on a single line identified by its zero-based index (used for gutter clicks). */ export function toggleCommentByLine( code: string, lineIndex: number, selectionStart: number, selectionEnd: number, ): CommentToggleResult { const normalizedCode = normalizeLineEndings(code) const lines = getLines(normalizedCode) if (lineIndex < 0 || lineIndex >= lines.length) { return { content: normalizedCode, selectionStart, selectionEnd, changed: false, } } return toggleCommentLines( normalizedCode, selectionStart, selectionEnd, lineIndex, lineIndex, false, ) } ================================================ FILE: src/renderer/components/History.module.scss ================================================ .selected { background: var(--swh-tree-selected-bg); } ================================================ FILE: src/renderer/components/History.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IHostsHistoryObject } from '@common/data' import events from '@common/events' import { Box, Button, Center, Flex, Group, Loader, NativeSelect, Text, Tooltip, } from '@mantine/core' import HostsViewer from '@renderer/components/HostsViewer' import SideDrawer from '@renderer/components/SideDrawer' import { actions } from '@renderer/core/agent' import useOnBroadcast from '@renderer/core/useOnBroadcast' import useConfigs from '@renderer/models/useConfigs' import useI18n from '@renderer/models/useI18n' import { IconFileTime, IconHelpCircle, IconHistory, IconX } from '@tabler/icons-react' import clsx from 'clsx' import dayjs from 'dayjs' import prettyBytes from 'pretty-bytes' import React, { useState } from 'react' import styles from './History.module.scss' interface IHistoryProps { list: IHostsHistoryObject[] selected_item: IHostsHistoryObject | undefined setSelectedItem: (item: IHostsHistoryObject) => void } const HistoryList = (props: IHistoryProps): React.ReactElement => { const { list, selected_item, setSelectedItem } = props const { lang } = useI18n() if (list.length === 0) { return (
{lang.no_record}
) } return ( {list.map((item) => ( setSelectedItem(item)} px="12px" py="8px" style={{ userSelect: 'none' }} className={clsx(item.id === selected_item?.id && styles.selected)} > {dayjs(item.add_time_ms).format('YYYY-MM-DD HH:mm:ss')} {item.content.split('\n').length} lines {prettyBytes(item.content.length)} ))} ) } const Loading = () => (
Loading...
) const History = () => { const { configs, updateConfigs } = useConfigs() const [is_open, setIsOpen] = useState(false) const [is_loading, setIsLoading] = useState(false) const [list, setList] = useState([]) const [selected_item, setSelectedItem] = useState() const { lang } = useI18n() const loadData = async () => { setIsLoading(true) let next_list = await actions.getHistoryList() next_list = next_list.reverse() setList(next_list) if (!selected_item) { setSelectedItem(next_list[0]) } setIsLoading(false) return next_list } const onClose = () => { setIsOpen(false) setList([]) } const deleteItem = async (id: string) => { if (!confirm(lang.system_hosts_history_delete_confirm)) { return } let idx = list.findIndex((i) => i.id === id) await actions.deleteHistory(id) setSelectedItem(undefined) let list2 = await loadData() let next_item = list2[idx] || list2[idx - 1] if (next_item) { setSelectedItem(next_item) } } const updateHistoryLimit = async (value: number) => { if (!value || value < 0) return await updateConfigs({ history_limit: value }) } useOnBroadcast(events.show_history, () => { setIsOpen(true) loadData().catch((e) => { console.error(e) }) }) let history_limit_values: number[] = [10, 50, 100, 500] if (configs && !history_limit_values.includes(configs.history_limit)) { history_limit_values.push(configs.history_limit) history_limit_values.sort() } return ( {lang.system_hosts_history} } footer={ {lang.system_hosts_history_limit} v.toString())} value={String(configs?.history_limit ?? '')} onChange={(e) => updateHistoryLimit(parseInt(e.target.value || '0'))} w={100} /> } > {is_loading ? ( ) : ( )} ) } export default History ================================================ FILE: src/renderer/components/HostsViewer.module.scss ================================================ @use "../styles/common"; .root { @include common.code; height: 100%; } .content { height: calc(100% - var(--swh-status-bar-height)); background: var(--swh-editor-read-only-bg); font-size: var(--swh-editor-font-size); line-height: var(--swh-editor-line-height); color: var(--swh-editor-text-color); overflow: auto; padding: 8px 10px; } .line { min-height: var(--swh-editor-line-height); } ================================================ FILE: src/renderer/components/HostsViewer.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import StatusBar from '@renderer/components/StatusBar' import styles from './HostsViewer.module.scss' interface Props { content: string } const HostsViewer = (props: Props) => { const { content } = props const lines = content.split('\n') const Line = (p: { line: string }) => { return
{p.line}
} return (
{lines.map((line, idx) => ( ))}
) } export default HostsViewer ================================================ FILE: src/renderer/components/ItemIcon.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IconDeviceDesktop, IconFileText, IconFolder, IconStack2, IconTrash, IconWorld, } from '@tabler/icons-react' interface Props { type?: string is_collapsed?: boolean } const ItemIcon = (props: Props) => { const { type, is_collapsed } = props const iconAttrs = { size: 16, stroke: 1.5, } switch (type) { case 'folder': return is_collapsed ? : case 'remote': return case 'group': return case 'system': return case 'trashcan': return default: return } } export default ItemIcon ================================================ FILE: src/renderer/components/Lang.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { LocaleName } from '@common/i18n' import useI18n from '@renderer/models/useI18n' import React from 'react' interface Props { locale: LocaleName children: string | React.ReactElement | React.ReactElement[] } const Lang = (props: Props): React.ReactElement | null => { const { locale } = useI18n() if (locale !== props.locale) { return null } return <>{props.children} } export default Lang ================================================ FILE: src/renderer/components/LeftPanel/SystemHostsItem.module.scss ================================================ .root { height: var(--swh-tree-row-height); line-height: var(--swh-tree-row-height); border-radius: var(--swh-border-radius); padding: 0 10px; cursor: default; } .selected { background: var(--swh-tree-selected-bg); } .icon { margin-right: 0.5em; display: inline-block; vertical-align: middle; } ================================================ FILE: src/renderer/components/LeftPanel/SystemHostsItem.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import ItemIcon from '@renderer/components/ItemIcon' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import clsx from 'clsx' import styles from './SystemHostsItem.module.scss' const SystemHostsItem = () => { const { i18n } = useI18n() const { current_hosts, setCurrentHosts } = useHostsData() const is_selected = !current_hosts const showSystemHosts = () => { setCurrentHosts(null) } return (
{i18n.lang.system_hosts}
) } export default SystemHostsItem ================================================ FILE: src/renderer/components/LeftPanel/Trashcan.module.scss ================================================ .root { user-select: none; } ================================================ FILE: src/renderer/components/LeftPanel/Trashcan.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { ITrashcanListObject } from '@common/data' import TrashcanItem from '@renderer/components/LeftPanel/TrashcanItem' import list_styles from '@renderer/components/List/index.module.scss' import { Tree } from '@renderer/components/Tree' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import { useEffect, useState } from 'react' import { BiChevronRight } from 'react-icons/bi' import styles from './Trashcan.module.scss' const Trashcan = () => { const { lang } = useI18n() const { hosts_data, current_hosts, setCurrentHosts } = useHostsData() const [trash_list, setTrashList] = useState([]) const [is_collapsed, setIsCollapsed] = useState(true) useEffect(() => { let root: ITrashcanListObject = { id: '0', data: { id: '0', title: lang.trashcan, }, add_time_ms: 0, children: [], can_drag: false, can_select: false, is_collapsed, is_root: true, type: 'trashcan', parent_id: null, } let list: ITrashcanListObject[] = [root] hosts_data.trashcan.map((i) => { root.children && root.children.push({ ...i, id: i.data.id, can_drag: false, type: i.data.type, }) }) setTrashList(list) }, [hosts_data.trashcan, is_collapsed]) const onSelect = (ids: string[]) => { let id = ids[0] let item = hosts_data.trashcan.find((i) => i.data.id === id) if (!item) return setCurrentHosts(item.data) } return (
} collapseArrow={
} nodeClassName={list_styles.node} nodeSelectedClassName={list_styles.node_selected} nodeCollapseArrowClassName={list_styles.arrow} onSelect={onSelect} selected_ids={current_hosts ? [current_hosts.id] : []} onChange={(list) => setIsCollapsed(!!list[0]?.is_collapsed)} />
) } export default Trashcan ================================================ FILE: src/renderer/components/LeftPanel/TrashcanItem.module.scss ================================================ .root { } .count { color: var(--swh-font-color-weak); margin-left: 1em; font-size: 90%; } .trashcan_title { opacity: 0.5; } .title { display: flex; align-items: center; height: var(--swh-tree-row-height); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ================================================ FILE: src/renderer/components/LeftPanel/TrashcanItem.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { ITrashcanListObject } from '@common/data' import ItemIcon from '@renderer/components/ItemIcon' import list_item_styles from '@renderer/components/List/ListItem.module.scss' import { actions } from '@renderer/core/agent' import { PopupMenu } from '@renderer/core/PopupMenu' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import clsx from 'clsx' import styles from './TrashcanItem.module.scss' interface Props { data: ITrashcanListObject } const TrashcanItem = (props: Props) => { const { data } = props const { lang } = useI18n() const { hosts_data, loadHostsData } = useHostsData() const onSelect = (i: any) => { console.log(i) } const menu_for_all = new PopupMenu([ { label: lang.trashcan_clear, enabled: hosts_data.trashcan.length > 0, click() { if (confirm(lang.trashcan_clear_confirm)) { actions .clearTrashcan() .then(loadHostsData) .catch((e) => console.error(e)) } }, }, ]) const menu_for_item = new PopupMenu([ { label: lang.trashcan_restore, click() { actions.restoreItemFromTrashcan(data.id).then((success) => { success && loadHostsData() }) }, }, { type: 'separator', }, { label: lang.hosts_delete, click() { if (confirm(lang.trashcan_delete_confirm)) { actions.deleteItemFromTrashcan(data.id).then((success) => { success && loadHostsData() }) } }, }, ]) return (
{ if (data.is_root) { menu_for_all.show() } else { menu_for_item.show() } e.preventDefault() e.stopPropagation() }} >
{data.data.title || lang.untitled} {data.is_root ? {data.children?.length || 0} : null}
) } export default TrashcanItem ================================================ FILE: src/renderer/components/LeftPanel/index.module.scss ================================================ @use "../../styles/common"; .list { position: relative; height: calc(100vh - var(--swh-top-bar-height)); overflow: auto; padding: 5px 10px; } :global(.platform-win32) { .list { @include common.swh-scroll-y(); } } ================================================ FILE: src/renderer/components/LeftPanel/index.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import events from '@common/events' import Trashcan from '@renderer/components/LeftPanel/Trashcan' import List from '@renderer/components/List' import { agent } from '@renderer/core/agent' import { PopupMenu } from '@renderer/core/PopupMenu' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import styles from './index.module.scss' interface Props { width: number } const Index = (props: Props) => { const { lang } = useI18n() const { hosts_data } = useHostsData() const menu = new PopupMenu([ { label: lang.hosts_add, click() { agent.broadcast(events.add_new) }, }, ]) return (
menu.show()}> {hosts_data.trashcan.length > 0 ? : null}
) } export default Index ================================================ FILE: src/renderer/components/List/ListItem.module.scss ================================================ .root { display: flex; &.selected:not(.is_tray):hover { .edit { display: flex; } } .edit { display: none; align-items: center; } } .title { display: flex; align-items: center; flex: 1 1 auto; position: relative; //padding-left: 10px; height: var(--swh-tree-row-height); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; } .folder_open { .folder_arrow { transform: rotate(90deg); margin-top: 1px; margin-left: -10px; } } .folder_arrow { position: absolute; margin-top: 1px; margin-left: -10px; cursor: pointer; transition: 0.3s; font-size: 8px; } .icon { margin-right: 0.5em; display: flex; align-items: center; justify-content: center; &.folder { cursor: pointer; } svg { position: relative; top: -1px; } } .status { flex: 0 0 auto; display: flex; align-items: center; gap: 5px; margin: auto 6px auto auto; } .children { overflow: hidden; transition: 0.3s; } ================================================ FILE: src/renderer/components/List/ListItem.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IHostsListObject } from '@common/data' import events from '@common/events' import { updateOneItem } from '@common/hostsFn' import { IMenuItemOption } from '@common/types' import { ActionIcon } from '@mantine/core' import ItemIcon from '@renderer/components/ItemIcon' import SwitchButton from '@renderer/components/SwitchButton' import { actions, agent } from '@renderer/core/agent' import { PopupMenu } from '@renderer/core/PopupMenu' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import { IconEdit } from '@tabler/icons-react' import clsx from 'clsx' import React, { useEffect, useRef, useState } from 'react' import scrollIntoView from 'smooth-scroll-into-view-if-needed' import styles from './ListItem.module.scss' interface Props { data: IHostsListObject selected_ids: string[] is_tray?: boolean } const ListItem = (props: Props) => { const { data, is_tray, selected_ids } = props const { lang, i18n } = useI18n() const { hosts_data, setList, current_hosts, setCurrentHosts } = useHostsData() const [is_collapsed, setIsCollapsed] = useState(!!data.is_collapsed) const [is_on, setIsOn] = useState(data.on) const el = useRef(null) // const [item_height, setItemHeight] = useState(0) const ref_toast_refresh = useRef(null) useEffect(() => { setIsOn(data.on) }, [data]) useEffect(() => { const is_selected = data.id === current_hosts?.id if (is_selected && el.current) { // el.current.scrollIntoViewIfNeeded() scrollIntoView(el.current, { behavior: 'smooth', scrollMode: 'if-needed', }) } }, [data, current_hosts, el]) const onSelect = () => { setCurrentHosts(data.is_sys ? null : data) } const toggleIsCollapsed = () => { if (!is_folder) return let _is_collapsed = !is_collapsed setIsCollapsed(_is_collapsed) setList( updateOneItem(hosts_data.list, { id: data.id, is_collapsed: _is_collapsed, }), ).catch((e) => console.error(e)) } const toggleOn = (on?: boolean) => { on = typeof on === 'boolean' ? on : !is_on setIsOn(on) agent.broadcast(events.toggle_item, data.id, on) } if (!data) return null const is_folder = data.type === 'folder' const is_selected = data.id === current_hosts?.id return (
{ let deal_count = 1 if (selected_ids.includes(data.id)) { deal_count = selected_ids.length } let menu_items: IMenuItemOption[] = [ { label: lang.edit, click() { agent.broadcast(events.edit_hosts_info, data) }, }, { label: lang.refresh, async click() { ref_toast_refresh.current = `${Date.now()}` actions .refreshHosts(data.id) .then((r) => { console.log(r) if (!r.success) { console.error(r.message || r.code || 'Error!') return } console.log('OK!') }) .catch((e) => { console.log(e) console.error(e.message) }) .finally(() => { if (ref_toast_refresh.current) { ref_toast_refresh.current = null } }) }, }, { type: 'separator', }, { label: deal_count === 1 ? lang.move_to_trashcan : i18n.trans('move_items_to_trashcan', [deal_count.toLocaleString()]), click() { let ids = deal_count === 1 ? [data.id] : selected_ids agent.broadcast(events.move_to_trashcan, ids) }, }, ] if (data.type !== 'remote') { menu_items = menu_items.filter((i) => i.label !== lang.refresh) } const menu = new PopupMenu(menu_items) !data.is_sys && !is_tray && menu.show() e.preventDefault() e.stopPropagation() }} ref={el} onClick={(e: React.MouseEvent) => { if (is_tray) { e.preventDefault() e.stopPropagation() } }} >
{data.title || lang.untitled}
{data.is_sys ? null : ( <>
{ agent.broadcast(events.edit_hosts_info, data) }} size={24} >
toggleOn(on)} /> )}
) } export default ListItem ================================================ FILE: src/renderer/components/List/index.module.scss ================================================ .root { } .node { position: relative; line-height: var(--swh-tree-row-height); border-radius: 4px; &:hover { background: var(--swh-tree-hover-bg); } } .node_selected { background: var(--swh-tree-selected-bg); &:hover { background: var(--swh-tree-selected-bg); .edit { display: block; } } } .node_drop_in { div[data-role="tree-node-body"] { background: var(--swh-primary-color); color: var(--swh-font-color-reverse); border-radius: var(--swh-border-radius); } } .arrow { font-size: 16px; } .for_drag { border: 1px solid var(--swh-border-color-1); background: var(--swh-tree-node-drag-bg); padding: 4px 8px; display: flex; align-items: center; span.icon { margin-right: 8px; } } .items_count { background: var(--swh-tree-selected-bg); margin-left: 0.5em; padding: 2px 0.5em; border-radius: var(--swh-border-radius); font-size: 12px; } ================================================ FILE: src/renderer/components/List/index.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { IHostsListObject } from '@common/data' import events from '@common/events' import { findItemById, getNextSelectedItem, setOnStateOfItem } from '@common/hostsFn' import { IFindShowSourceParam } from '@common/types' import { IHostsWriteOptions } from '@main/types' import ItemIcon from '@renderer/components/ItemIcon' import { Tree } from '@renderer/components/Tree' import { actions, agent } from '@renderer/core/agent' import useOnBroadcast from '@renderer/core/useOnBroadcast' import useConfigs from '@renderer/models/useConfigs' import useHostsData from '@renderer/models/useHostsData' import useI18n from '@renderer/models/useI18n' import clsx from 'clsx' import { useEffect, useState } from 'react' import { BiChevronRight } from 'react-icons/bi' import styles from './index.module.scss' import ListItem from './ListItem' interface Props { is_tray?: boolean } const List = (props: Props) => { const { is_tray } = props const { hosts_data, loadHostsData, setList, current_hosts, setCurrentHosts } = useHostsData() const { configs } = useConfigs() const { lang } = useI18n() const [selected_ids, setSelectedIds] = useState([current_hosts?.id || '0']) const [show_list, setShowList] = useState([]) useEffect(() => { if (!is_tray) { setShowList([ { id: '0', title: lang.system_hosts, is_sys: true, }, ...hosts_data.list, ]) } else { setShowList([...hosts_data.list]) } }, [hosts_data]) useEffect(() => { if (is_tray || !current_hosts) return if (!hosts_data.trashcan.find((item) => item.data.id === current_hosts.id)) return setSelectedIds([]) }, [current_hosts, hosts_data.trashcan, is_tray]) const onToggleItem = async (id: string, on: boolean) => { console.log(`writeMode: ${configs?.write_mode}`) console.log(`toggle hosts #${id} as ${on ? 'on' : 'off'}`) if (!configs?.write_mode) { agent.broadcast(events.show_set_write_mode, { id, on }) return } const new_list = setOnStateOfItem( hosts_data.list, id, on, configs?.choice_mode ?? 0, configs?.multi_chose_folder_switch_all ?? false, ) let success = await writeHostsToSystem(new_list) if (success) { console.log(lang.success) agent.broadcast(events.set_hosts_on_status, id, on) } else { agent.broadcast(events.set_hosts_on_status, id, !on) } } const writeHostsToSystem = async ( list?: IHostsListObject[], options?: IHostsWriteOptions, ): Promise => { if (!Array.isArray(list)) { list = hosts_data.list } let content: string = await actions.getContentOfList(list) const result = await actions.setSystemHosts(content, options) if (result.success) { setList(list).catch((e) => console.error(e)) // new Notification(lang.success, { // body: lang.hosts_updated, // }) if (current_hosts) { let hosts = findItemById(list, current_hosts.id) if (hosts) { agent.broadcast(events.set_hosts_on_status, current_hosts.id, hosts.on) } } } else { console.log(result) loadHostsData().catch((e) => console.log(e)) let err_desc = lang.fail // let body: string = lang.no_access_to_hosts if (result.code === 'no_access') { if (agent.platform === 'darwin' || agent.platform === 'linux') { agent.broadcast(events.show_sudo_password_input, list) } // } else { // body = result.message || 'Unknown error!' err_desc = lang.no_access_to_hosts } // new Notification(lang.fail, { // body, // }) console.error(err_desc) } agent.broadcast(events.tray_list_updated) return result.success } if (!is_tray) { useOnBroadcast(events.toggle_item, onToggleItem, [hosts_data, configs]) useOnBroadcast(events.write_hosts_to_system, writeHostsToSystem, [hosts_data]) } else { useOnBroadcast(events.tray_list_updated, loadHostsData) } useOnBroadcast( events.move_to_trashcan, async (ids: string[]) => { console.log(`move_to_trashcan: #${ids}`) await actions.moveManyToTrashcan(ids) await loadHostsData() if (current_hosts && ids.includes(current_hosts.id)) { // 选中删除指定节点后的兄弟节点 let next_item = getNextSelectedItem(hosts_data.list, (i) => ids.includes(i.id)) setCurrentHosts(next_item || null) setSelectedIds(next_item ? [next_item.id] : []) } }, [current_hosts, hosts_data], ) useOnBroadcast( events.select_hosts, async (id: string, wait_ms: number = 0) => { let hosts = findItemById(hosts_data.list, id) if (!hosts) { if (wait_ms > 0) { setTimeout(() => { agent.broadcast(events.select_hosts, id, wait_ms - 50) }, 50) } return } setCurrentHosts(hosts) setSelectedIds([id]) }, [hosts_data], ) useOnBroadcast(events.reload_list, loadHostsData) useOnBroadcast(events.hosts_content_changed, async (hosts_id: string) => { let list: IHostsListObject[] = await actions.getList() let hosts = findItemById(list, hosts_id) if (!hosts || !hosts.on) return // 当前 hosts 是开启状态,且内容发生了变化 await writeHostsToSystem(list) }) useOnBroadcast(events.show_source, async (params: IFindShowSourceParam) => { agent.broadcast(events.select_hosts, params.item_id) }) return (
{/**/} { setShowList(list) setList(list).catch((e) => console.error(e)) }} onSelect={(ids: string[]) => { // console.log(ids) setSelectedIds(ids) }} nodeRender={(data) => ( )} collapseArrow={
} nodeAttr={(item) => { return { can_drag: !item.is_sys && !is_tray, can_drop_before: !item.is_sys, can_drop_in: item.type === 'folder', can_drop_after: !item.is_sys, } }} draggingNodeRender={(data) => { return (
{data.title || lang.untitled} {selected_ids.length > 1 ? ( {selected_ids.length} {lang.items} ) : null}
) }} nodeClassName={styles.node} nodeDropInClassName={styles.node_drop_in} nodeSelectedClassName={styles.node_selected} nodeCollapseArrowClassName={styles.arrow} allowed_multiple_selection={true} />
) } export default List ================================================ FILE: src/renderer/components/Loading.module.scss ================================================ .root { padding: 40px 20px; } ================================================ FILE: src/renderer/components/Loading.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import useI18n from '@renderer/models/useI18n' import styles from './Loading.module.scss' const Loading = () => { const { i18n } = useI18n() return
{i18n.lang.loading}
} export default Loading ================================================ FILE: src/renderer/components/MainPanel/index.module.scss ================================================ .root { width: 100%; height: calc(100vh - var(--swh-top-bar-height)); //overflow: auto; background: var(--swh-main-bg); } ================================================ FILE: src/renderer/components/MainPanel/index.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import events from '@common/events' import HostsEditor from '@renderer/components/Editor/HostsEditor' import useOnBroadcast from '@renderer/core/useOnBroadcast' import styles from './index.module.scss' const MainPanel = () => { useOnBroadcast(events.cmd_run_result, (result) => { // console.log(result) if (!result.success) { console.error(result.stderr || 'cmd run error') } }) return (
) } export default MainPanel ================================================ FILE: src/renderer/components/Pref/Advanced.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { ConfigsType } from '@common/default_configs' import { Button, Checkbox, Group, Stack, Tooltip } from '@mantine/core' import { actions } from '@renderer/core/agent' import useI18n from '@renderer/models/useI18n' import React, { useEffect, useState } from 'react' import styles from './styles.module.scss' interface IProps { data: ConfigsType onChange: (kv: Partial) => void } const PathLink = (props: { link: string }) => { const { link } = props const { lang } = useI18n() const isDisabled = !link return ( { e.preventDefault() e.stopPropagation() if (isDisabled) return actions.showItemInFolder(link) }} href={isDisabled ? undefined : 'file://' + link} style={{ opacity: isDisabled ? 0.5 : 1, pointerEvents: isDisabled ? 'none' : 'auto' }} > {link} ) } const Advanced = (props: IProps) => { const { data, onChange } = props const { i18n, lang } = useI18n() const [hosts_path, setHostsPath] = useState('') const [data_dir, setDataDir] = useState('') const [default_data_dir, setDefaultDataDir] = useState('') useEffect(() => { actions.getPathOfSystemHosts().then((hosts_path) => setHostsPath(hosts_path)) actions.getDataDir().then((data_dir) => setDataDir(data_dir)) actions.getDefaultDataDir().then((default_data_dir) => setDefaultDataDir(default_data_dir)) }, []) return (
{lang.usage_data_title}
{lang.usage_data_help}
onChange({ send_usage_data: e.target.checked })} />
{lang.where_is_my_hosts}
{lang.your_hosts_file_is}
{lang.where_is_my_data}
{lang.your_data_is}
{data_dir !== default_data_dir && ( )}
) } export default Advanced ================================================ FILE: src/renderer/components/Pref/Commands.tsx ================================================ /** * @author: oldj * @homepage: https://oldj.net */ import { ConfigsType } from '@common/default_configs' import { Box, Button, Stack, Textarea } from '@mantine/core' import CommandsHistory from '@renderer/components/Pref/CommandsHistory' import useI18n from '@renderer/models/useI18n' import { useState } from 'react' interface IProps { data: ConfigsType onChange: (kv: Partial) => void } const Commands = (props: IProps) => { const { data, onChange } = props const { lang } = useI18n() const [show_history, setShowHistory] = useState(false) const toggleShowHistory = () => { setShowHistory(!show_history) } return ( {lang.commands_title} {lang.commands_help}