├── .cargo └── config ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .mergify.yml ├── .parcelrc ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── config.example.yaml ├── doc ├── screenshot1.jpeg └── screenshot2.jpeg ├── license_collect.sh ├── package.json ├── pnpm-lock.yaml ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── bacon.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── tray_icon.png ├── src │ ├── decorations.rs │ ├── lib │ │ ├── config.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── mission.rs │ │ ├── properties.rs │ │ ├── skip_cache.rs │ │ ├── tmutil.rs │ │ ├── walker.rs │ │ └── watcher.rs │ ├── main.rs │ ├── metadata.rs │ └── plugins.rs ├── tauri.conf.json └── tests │ ├── configs │ ├── allow_missing_skip_dir.yaml │ ├── broken_dir.yaml │ ├── broken_rule.yaml │ ├── follow_symlink.yaml │ ├── inherit_rule.yaml │ ├── inherit_rule_loop.yaml │ ├── missing_dir.yaml │ └── simple.yaml │ └── read_config.rs ├── src ├── App.tsx ├── assets │ └── tmexclude.png ├── bindings │ ├── ApplyErrors.ts │ ├── BuildMeta.ts │ ├── ExclusionActionBatch.ts │ ├── Metrics.ts │ ├── PreConfig.ts │ ├── PreDirectory.ts │ ├── PreRule.ts │ ├── Rule.ts │ └── ScanStatus.ts ├── commands.ts ├── components │ ├── PathText.tsx │ ├── RuleItem.tsx │ ├── SelectionTable.tsx │ └── TipText.tsx ├── equalSelector.ts ├── i18n.tsx ├── i18n │ ├── en.json │ └── zh_hans.json ├── index.html ├── index.tsx ├── licenses.ts ├── pages │ ├── About.tsx │ ├── Ack.tsx │ ├── License.tsx │ └── main │ │ ├── Directories.tsx │ │ ├── General.tsx │ │ ├── Header.tsx │ │ ├── MainLayout.tsx │ │ ├── NavBar.tsx │ │ ├── Rules.tsx │ │ ├── Scan.tsx │ │ ├── Stats.tsx │ │ ├── routes.tsx │ │ └── scan │ │ ├── Applying.tsx │ │ ├── Detail.tsx │ │ ├── Done.tsx │ │ ├── InProgress.tsx │ │ ├── Log.tsx │ │ ├── Overview.tsx │ │ └── Welcome.tsx ├── states.tsx ├── transitions.ts └── utils.ts └── tsconfig.json /.cargo/config: -------------------------------------------------------------------------------- 1 | [alias] 2 | compgen = "run --package compgen --" 3 | xtask = "run --package xtask --" 4 | [env] 5 | CARGO_WORKSPACE_DIR = { value = "", relative = true } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*" 5 | name: Release 6 | jobs: 7 | release: 8 | runs-on: macos-12 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: setup node 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | - uses: pnpm/action-setup@v2 16 | name: Install pnpm 17 | id: pnpm-install 18 | with: 19 | version: 7 20 | run_install: false 21 | - name: Get pnpm store directory 22 | id: pnpm-cache 23 | shell: bash 24 | run: | 25 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 26 | - uses: actions/cache@v3 27 | name: Setup pnpm cache 28 | with: 29 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 30 | key: ${{ runner.os }}-pnpm-store-release-${{ hashFiles('**/pnpm-lock.yaml') }} 31 | restore-keys: | 32 | ${{ runner.os }}-pnpm-store-release- 33 | - name: install Rust stable 34 | uses: dtolnay/rust-toolchain@stable 35 | with: 36 | targets: aarch64-apple-darwin 37 | - name: install app dependencies and build it 38 | run: pnpm i && pnpm build 39 | - uses: Swatinem/rust-cache@v2 40 | with: 41 | workspaces: src-tauri 42 | - uses: tauri-apps/tauri-action@dev 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} 46 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 47 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 48 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 49 | APPLE_ID: ${{ secrets.APPLE_ID }} 50 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} 51 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 52 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 53 | with: 54 | tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version 55 | releaseName: 'v__VERSION__' 56 | releaseBody: 'See the assets to download this version and install.' 57 | releaseDraft: true 58 | prerelease: false 59 | args: --target universal-apple-darwin -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | name: Test 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | name: Checkout 🛎️ 18 | - name: setup node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | - uses: pnpm/action-setup@v2 23 | name: Install pnpm 24 | id: pnpm-install 25 | with: 26 | version: 7 27 | run_install: false 28 | - name: Get pnpm store directory 29 | id: pnpm-cache 30 | shell: bash 31 | run: | 32 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 33 | - uses: actions/cache@v3 34 | name: Setup pnpm cache 35 | with: 36 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 37 | key: ${{ runner.os }}-pnpm-store-lint-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pnpm-store-lint- 40 | - name: install Rust stable 41 | uses: dtolnay/rust-toolchain@stable 42 | with: 43 | components: rustfmt, clippy 44 | - name: install app dependencies and build it 45 | run: pnpm i && pnpm build 46 | - uses: Swatinem/rust-cache@v2 47 | - name: Check Code Format 🔧 48 | run: cargo fmt --check 49 | working-directory: ./src-tauri 50 | - name: Run Clippy Lints 🔨 51 | run: cargo clippy --all-targets --tests 52 | working-directory: ./src-tauri 53 | env: 54 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 55 | 56 | test: 57 | name: Test 58 | runs-on: macos-11 59 | env: 60 | TEST_FAST: 1 61 | steps: 62 | - uses: actions/checkout@v3 63 | name: Checkout 🛎️ 64 | - name: setup node 65 | uses: actions/setup-node@v3 66 | with: 67 | node-version: 18 68 | - uses: pnpm/action-setup@v2 69 | name: Install pnpm 70 | id: pnpm-install 71 | with: 72 | version: 7 73 | run_install: false 74 | - name: Get pnpm store directory 75 | id: pnpm-cache 76 | shell: bash 77 | run: | 78 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 79 | - uses: actions/cache@v3 80 | name: Setup pnpm cache 81 | with: 82 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 83 | key: ${{ runner.os }}-pnpm-store-test-${{ hashFiles('**/pnpm-lock.yaml') }} 84 | restore-keys: | 85 | ${{ runner.os }}-pnpm-store-test- 86 | - name: install Rust stable 87 | uses: dtolnay/rust-toolchain@stable 88 | - name: install app dependencies and build it 89 | run: pnpm i && pnpm build 90 | - uses: Swatinem/rust-cache@v2 91 | with: 92 | workspaces: src-tauri 93 | - name: Running Tests 🚀 94 | run: cargo test --tests 95 | working-directory: ./src-tauri 96 | env: 97 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 98 | 99 | package: 100 | runs-on: macos-12 101 | steps: 102 | - uses: actions/checkout@v3 103 | - name: setup node 104 | uses: actions/setup-node@v3 105 | with: 106 | node-version: 18 107 | - uses: pnpm/action-setup@v2 108 | name: Install pnpm 109 | id: pnpm-install 110 | with: 111 | version: 7 112 | run_install: false 113 | - name: Get pnpm store directory 114 | id: pnpm-cache 115 | shell: bash 116 | run: | 117 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 118 | - uses: actions/cache@v3 119 | name: Setup pnpm cache 120 | with: 121 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 122 | key: ${{ runner.os }}-pnpm-store-package-${{ hashFiles('**/pnpm-lock.yaml') }} 123 | restore-keys: | 124 | ${{ runner.os }}-pnpm-store-package- 125 | - name: install Rust stable 126 | uses: dtolnay/rust-toolchain@stable 127 | with: 128 | targets: aarch64-apple-darwin 129 | - name: install app dependencies and build it 130 | run: pnpm i && pnpm build 131 | - uses: Swatinem/rust-cache@v2 132 | with: 133 | workspaces: src-tauri 134 | - uses: tauri-apps/tauri-action@dev 135 | env: 136 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 137 | ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} 138 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 139 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 140 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 141 | APPLE_ID: ${{ secrets.APPLE_ID }} 142 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} 143 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 144 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 145 | with: 146 | args: --target universal-apple-darwin 147 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /target 3 | /.idea 4 | /dist 5 | /release 6 | 7 | /.next 8 | /.vscode 9 | /.parcel-cache -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Automatic merge on approval 3 | conditions: 4 | - author=dependabot[bot] 5 | - check-success=Lint 6 | - check-success=Test 7 | actions: 8 | merge: 9 | method: squash -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.txt": ["...", "@parcel/transformer-inline-string"] 5 | } 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.2] - 2023-01-03 11 | 12 | ### Added 13 | 14 | - NODUMP flag support. This flag is used by DUMP(8) and BorgBackup to indicate that a file should not be backed up. 15 | Check their documentation for more information. 16 | 17 | ### Fixed 18 | 19 | - No message is shown to the user when trying to save an invalid config. 20 | - The app crashes at startup if the config is invalid. 21 | 22 | ## [0.2.1] - 2022-12-12 23 | 24 | ### Added 25 | 26 | - i18n support for zh-Hans. 27 | - Telemetry for critical errors. 28 | 29 | ### Fixed 30 | 31 | - Fix a bug that the app crashes on macOS version lower than 13.0. 32 | - Sometimes apply log can be covered by the navigation bar. 33 | 34 | ## [0.2.0] - 2022-12-07 35 | 36 | ### Added 37 | 38 | - A new GUI interface using [tauri](https://tauri.studio/) to provide a better user experience. 39 | - Auto update support. 40 | 41 | ### Removed 42 | 43 | - The CLI interface has been temporarily removed. It will be re-added in a future release. 44 | - The homebrew formula is abandoned because we are now a GUI application. A new cask might be added in the future. 45 | 46 | [Unreleased]: https://github.com/PhotonQuantum/tmexclude/compare/v0.2.2...HEAD 47 | [0.2.2]: https://github.com/PhotonQuantum/tmexclude/compare/v0.2.1...v0.2.2 48 | [0.2.1]: https://github.com/PhotonQuantum/tmexclude/compare/v0.2.0...v0.2.1 49 | [0.2.0]: https://github.com/PhotonQuantum/tmexclude/releases/tag/v0.2.0 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 lightquantum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeMachine Exclude (tmexclude) 2 | 3 | [CHANGELOG](./CHANGELOG.md) 4 | 5 | Exclude undesired files (node_modules, target, etc) from your TimeMachine backup. 6 | 7 | This utility watches your filesystem and excludes the files once they appear, so you won't accidentally include them 8 | in your backups. Full scans can also be performed manually to ensure no file slips through the watcher. 9 | 10 | Screenshots available [here](#screenshots). 11 | 12 | *If you find this utility useful, please consider [buy me a coffee](https://buymeacoffee.com/lightquantum).* 13 | 14 | ## Installation 15 | 16 | Binary releases are available [here](https://github.com/PhotonQuantum/tmexclude/releases). 17 | 18 | ## Configuration 19 | 20 | While you may configure tmexclude through the GUI, you may also use a configuration file. 21 | 22 | See [`config.example.yaml`](config.example.yaml) for an example configuration file. 23 | 24 | The config file is located at `~/.config/tmexclude.yaml`. 25 | A default config is generated when the application starts if it doesn't exist. 26 | 27 | After modifying the config file manually, you must restart the application for the changes to take effect. 28 | 29 | ## Screenshots 30 | 31 | ![overview_page](./doc/screenshot1.jpeg) 32 | ![scan_page](./doc/screenshot2.jpeg) 33 | 34 | ## License 35 | 36 | This project is licensed under [MIT License](LICENSE.txt). -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Don't include files into backups even if they don't match the rules. 2 | no-include: true 3 | 4 | # Directories to scan and rules to apply. 5 | directories: 6 | - path: ~/ 7 | rules: [ Development ] 8 | 9 | # Skip the following paths. 10 | skips: 11 | - ~/Library 12 | - ~/Pictures 13 | - ~/.vscode 14 | - ~/.npm 15 | - ~/Dropbox 16 | - ~/.dropbox 17 | - ~/.Trash 18 | - ~/.pnpm-store 19 | # These directories trigger permission request dialogs repeatedly in daemon mode. 20 | - ~/Downloads 21 | - ~/Desktop 22 | - ~/Documents 23 | 24 | # Exclude paths that match these patterns. Notice that they must be referenced by at least one `directories` entry above to take effect. 25 | rules: 26 | # You may merge other rules by defining an array. 27 | # The development rules are adopted from [asimov](https://github.com/stevegrunwell/asimov) 28 | Development: 29 | - Swift 30 | - Gradle 31 | - Gradle Kotlin Script 32 | - Flutter (Dart) 33 | - Pub (Dart) 34 | - Stack (Haskell) 35 | - Tox (Python) 36 | - Vagrant 37 | - virtualenv (Python) 38 | - Carthage 39 | - CocoaPods 40 | - Bower (JavaScript) 41 | - Python 42 | - PyPI Publishing (Python) 43 | - "npm, Yarn (NodeJS)" 44 | - Cargo (Rust) 45 | - Maven 46 | - Composer (PHP) 47 | - Bundler (Ruby) 48 | - Go Modules (Golang) 49 | - Mix dependencies (Elixir) 50 | - Mix build files (Elixir) 51 | Swift: 52 | # Paths to exclude. 53 | excludes: [ ".build" ] 54 | # ... only if ANY of these paths exists adjacently. 55 | if-exists: [ Package.swift ] 56 | Gradle: 57 | excludes: [ build ] 58 | if-exists: [ build.gradle ] 59 | Gradle Kotlin Script: 60 | excludes: [ build ] 61 | if-exists: [ build.gradle.kts ] 62 | Flutter (Dart): 63 | excludes: [ build ] 64 | if-exists: [ pubspec.yaml ] 65 | Pub (Dart): 66 | excludes: [ ".packages" ] 67 | if-exists: [ pubspec.yaml ] 68 | Stack (Haskell): 69 | excludes: [ ".stack-work" ] 70 | if-exists: [ stack.yaml ] 71 | Tox (Python): 72 | excludes: [ ".tox" ] 73 | if-exists: [ tox.ini ] 74 | Vagrant: 75 | excludes: [ ".vagrant" ] 76 | if-exists: [ Vagrantfile ] 77 | virtualenv (Python): 78 | excludes: [ venv ] 79 | if-exists: [ requirements.txt ] 80 | Carthage: 81 | excludes: [ Carthage ] 82 | if-exists: [ Cartfile ] 83 | CocoaPods: 84 | excludes: [ Pods ] 85 | if-exists: [ Podfile ] 86 | Bower (JavaScript): 87 | excludes: [ bower_components ] 88 | if-exists: [ bower.json ] 89 | Python: 90 | excludes: [ build ] 91 | if-exists: [ setup.py ] 92 | PyPI Publishing (Python): 93 | excludes: [ dist ] 94 | if-exists: [ setup.py ] 95 | "npm, Yarn (NodeJS)": 96 | excludes: [ node_modules ] 97 | if-exists: [ package.json ] 98 | Cargo (Rust): 99 | excludes: [ target ] 100 | if-exists: [ Cargo.toml ] 101 | Maven: 102 | excludes: [ target ] 103 | if-exists: [ pom.xml ] 104 | Composer (PHP): 105 | excludes: [ vendor ] 106 | if-exists: [ composer.json ] 107 | Bundler (Ruby): 108 | excludes: [ vendor ] 109 | if-exists: [ Gemfile ] 110 | Go Modules (Golang): 111 | excludes: [ vendor ] 112 | if-exists: [ go.mod ] 113 | Mix dependencies (Elixir): 114 | excludes: [ deps ] 115 | if-exists: [ mix.exs ] 116 | Mix build files (Elixir): 117 | excludes: [ ".build" ] 118 | if-exists: [ mix.exs ] -------------------------------------------------------------------------------- /doc/screenshot1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/doc/screenshot1.jpeg -------------------------------------------------------------------------------- /doc/screenshot2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/doc/screenshot2.jpeg -------------------------------------------------------------------------------- /license_collect.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-shell 2 | #! nix-shell -i bash -p jq cargo-license nodejs nodePackages.pnpm 3 | 4 | # This script collects the license information for all the dependencies 5 | 6 | CARGO_LICENSES=$(cargo-license --manifest-path src-tauri/Cargo.toml --direct-deps-only --json | jq 'map(select(.license != null and .license != "") | {name, repository, license, version})|unique_by(.name)') 7 | NPM_LICENSES=$(pnpx license-checker --direct --json | jq 'to_entries|map(select(.value.licenses != null and .value.licenses != "" and (.key | contains("tmexclude") | not) ) | {name:.key, repository:.value.repository, license:.value.licenses})') 8 | 9 | cat <src/licenses.ts 10 | // noinspection AllyPlainJsInspection 11 | 12 | export type License = { 13 | name: string; 14 | version?: string; 15 | repository: string | null; 16 | license: string; 17 | }; 18 | export const cargoLicenses: Array = $CARGO_LICENSES; 19 | export const npmLicenses: Array = $NPM_LICENSES; 20 | EOF 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tmexclude", 3 | "source": "src/index.html", 4 | "version": "0.2.2", 5 | "scripts": { 6 | "dev": "parcel serve src/index.html -p 1420", 7 | "build": "parcel build src/index.html", 8 | "tauri": "tauri" 9 | }, 10 | "dependencies": { 11 | "@emotion/react": "^11.10.5", 12 | "@emotion/server": "^11.10.0", 13 | "@emotion/styled": "^11.10.5", 14 | "@mantine/core": "^5.9.6", 15 | "@mantine/hooks": "^5.9.6", 16 | "@mantine/notifications": "^5.9.6", 17 | "@mantine/utils": "^5.9.6", 18 | "@tabler/icons": "^1.119.0", 19 | "@tauri-apps/api": "^1.2.0", 20 | "framer-motion": "^8.1.3", 21 | "i18next": "^22.4.6", 22 | "lodash": "^4.17.21", 23 | "process": "^0.11.10", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-i18next": "^12.1.1", 27 | "react-router-dom": "^6.6.1", 28 | "react-timeago": "^7.1.0", 29 | "recoil": "^0.7.6", 30 | "swr": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@parcel/config-default": "^2.8.2", 34 | "@parcel/transformer-inline-string": "^2.8.2", 35 | "@tauri-apps/cli": "^1.2.2", 36 | "@types/lodash": "^4.14.191", 37 | "@types/node": "^18.11.18", 38 | "@types/react": "^18.0.26", 39 | "@types/react-dom": "^18.0.10", 40 | "@types/react-router-dom": "^5.3.3", 41 | "@types/react-timeago": "^4.1.3", 42 | "parcel": "^2.8.2", 43 | "typescript": "^4.9.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tmexclude" 3 | version = "0.2.2" 4 | description = "A Tauri App" 5 | authors = ["LightQuantum "] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | rust-version = "1.57" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [[bin]] 14 | name = "tmexclude" 15 | path = "src/main.rs" 16 | 17 | [lib] 18 | name = "tmexclude_lib" 19 | path = "src/lib/lib.rs" 20 | 21 | [build-dependencies] 22 | tauri-build = { version = "1.0.0", features = [] } 23 | vergen = "7.4" 24 | 25 | [dependencies] 26 | once_cell = "1.16" 27 | regex = "1.7" 28 | auto-launch = "0.4" 29 | fsevent-stream = "0.2" 30 | serde_json = "1.0" 31 | serde = { version = "1.0", features = ["derive", "rc"] } 32 | sentry = { version = "0.29", features = ["tracing"] } 33 | tauri = { version = "1.2.5", features = ["dialog-open", "macos-private-api", "path-all", "shell-open", "system-tray", "updater", "window-show", "window-start-dragging"] } 34 | itertools = "0.10" 35 | parking_lot = "0.12" 36 | maplit = "1.0" 37 | tap = "1.0" 38 | tracing = "0.1" 39 | tracing-subscriber = "0.3" 40 | thiserror = "1.0" 41 | ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs.git" } 42 | eyre = "0.6" 43 | xattr = "1.0" 44 | jwalk = "0.8" 45 | serde_yaml = "0.9" 46 | shellexpand = "3.0" 47 | futures = "0.3" 48 | moka = "0.9" 49 | core-foundation = { version = "0.9", features = ["mac_os_10_8_features"] } 50 | crossbeam = "0.8" 51 | directories = "4.0" 52 | arc-swap = "1.5" 53 | window-vibrancy = "0.3" 54 | cocoa = "0.24" 55 | objc = "0.2" 56 | libc = "0.2" 57 | 58 | [dev-dependencies] 59 | assert_cmd = "2.0" 60 | tempfile = "3.3" 61 | 62 | [features] 63 | # by default Tauri runs in production mode 64 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 65 | default = ["custom-protocol"] 66 | # this feature is used used for production builds where `devPath` points to the filesystem 67 | # DO NOT remove this 68 | custom-protocol = ["tauri/custom-protocol"] 69 | -------------------------------------------------------------------------------- /src-tauri/bacon.toml: -------------------------------------------------------------------------------- 1 | # This is a configuration file for the bacon tool 2 | # More info at https://github.com/Canop/bacon 3 | 4 | default_job = "clippy" 5 | 6 | [keybindings] 7 | k = "scroll-lines(-1)" 8 | j = "scroll-lines(1)" 9 | c = "job:clippy" 10 | t = "job:test" 11 | f = "job:fix" 12 | shift-F9 = "toggle-backtrace" 13 | ctrl-r = "toggle-raw-output" 14 | ctrl-u = "scroll-page(-1)" 15 | ctrl-d = "scroll-page(1)" 16 | 17 | [jobs] 18 | 19 | [jobs.clippy] 20 | command = ["cargo", "clippy", "--workspace", "--tests", "--color", "always", "--", "-W", "clippy::all", "-W", "clippy::nursery", "-W", "clippy::pedantic"] 21 | watch = ["src", "tests"] 22 | need_stdout = false 23 | 24 | [jobs.test] 25 | command = ["cargo", "test", "--color", "always"] 26 | need_stdout = true 27 | watch = ["tests"] 28 | 29 | [jobs.doc] 30 | command = ["cargo", "doc", "--color", "always", "--no-deps"] 31 | need_stdout = false 32 | 33 | [jobs.fix] 34 | command = ["cargo", "clippy", "--fix", "--allow-no-vcs", "--allow-staged", "--allow-dirty", "--workspace", "--tests", "--color", "always", "--", "-W", "clippy::all", "-W", "clippy::nursery", "-W", "clippy::pedantic"] 35 | need_stdout = false 36 | on_success = "job:clippy" -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | use vergen::{vergen, Config}; 2 | 3 | fn main() { 4 | tauri_build::build(); 5 | vergen(Config::default()).unwrap(); 6 | println!("cargo:rustc-link-lib=framework=ServiceManagement"); 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/tray_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src-tauri/icons/tray_icon.png -------------------------------------------------------------------------------- /src-tauri/src/decorations.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | 3 | use cocoa::appkit::NSWindowTitleVisibility; 4 | use cocoa::appkit::{CGFloat, NSView, NSWindow, NSWindowButton}; 5 | use cocoa::base::id; 6 | use cocoa::foundation::{NSPoint, NSRect}; 7 | use objc::msg_send; 8 | use tauri::{Runtime, Window, WindowEvent}; 9 | 10 | #[derive(Debug, Copy, Clone)] 11 | pub struct Margin { 12 | x: CGFloat, 13 | y: CGFloat, 14 | } 15 | 16 | pub trait WindowExt { 17 | fn set_transparent_titlebar(&self); 18 | fn set_trafficlights_position(&self, x: CGFloat, y: CGFloat); 19 | } 20 | 21 | impl WindowExt for Window { 22 | fn set_transparent_titlebar(&self) { 23 | unsafe { 24 | let id = self.ns_window().unwrap().cast::(); 25 | 26 | id.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden); 27 | id.setTitlebarAppearsTransparent_(cocoa::base::YES); 28 | } 29 | } 30 | fn set_trafficlights_position(&self, x: CGFloat, y: CGFloat) { 31 | let margin = Margin { x, y }; 32 | 33 | self.on_window_event({ 34 | let window = self.clone(); 35 | move |ev| { 36 | if let WindowEvent::Resized(_) | WindowEvent::Focused(true) = ev { 37 | unsafe { 38 | let id = window.ns_window().unwrap().cast::(); 39 | update_layout(id, margin); 40 | }; 41 | } 42 | } 43 | }); 44 | 45 | unsafe { 46 | let id = self.ns_window().unwrap().cast::(); 47 | 48 | update_layout(id, margin); 49 | } 50 | } 51 | } 52 | 53 | unsafe fn update_layout(window: impl NSWindow + Copy, margin: Margin) { 54 | let left = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton); 55 | let middle = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton); 56 | let right = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton); 57 | 58 | let button_width = NSView::frame(left).size.width; 59 | let button_height = NSView::frame(left).size.height; 60 | let padding = NSView::frame(middle).origin.x - NSView::frame(left).origin.x - button_width; 61 | 62 | let container = left.superview().superview(); 63 | let mut cbounds = NSView::frame(container); 64 | cbounds.size.height = 2.0f64.mul_add(margin.y, button_height); 65 | cbounds.origin.y = window.frame().size.height - cbounds.size.height; 66 | container.setFrame(cbounds); 67 | 68 | for (idx, btn) in [left, middle, right].into_iter().enumerate() { 69 | btn.setFrameOrigin(NSPoint::new( 70 | (button_width + padding).mul_add(idx as CGFloat, margin.x), 71 | margin.y, 72 | )); 73 | } 74 | } 75 | 76 | pub trait NSViewExt: Sized { 77 | unsafe fn setFrame(self, frame: NSRect); 78 | } 79 | 80 | impl NSViewExt for id { 81 | unsafe fn setFrame(self, frame: NSRect) { 82 | let _: () = msg_send![self, setFrame: frame]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src-tauri/src/lib/error.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::error::Error; 3 | use std::path::PathBuf; 4 | 5 | use core_foundation::error::CFError; 6 | use serde::Serialize; 7 | use thiserror::Error; 8 | use ts_rs::TS; 9 | 10 | /// Error that may occur when loading a config. 11 | #[allow(clippy::large_enum_variant)] 12 | #[derive(Debug, Error)] 13 | pub enum ConfigError { 14 | /// Error returned by serde deserializer. 15 | #[error("Error when deserializing config file")] 16 | Deserialize(#[source] Box), 17 | /// Missing rule. 18 | #[error("Missing rule: {0}")] 19 | Rule(String), 20 | /// No directories in config. 21 | #[error("No directory to scan")] 22 | NoDirectory, 23 | /// Specified path is invalid. 24 | #[error("Specified path is invalid: {path}")] 25 | InvalidPath { 26 | /// The invalid path. 27 | path: String, 28 | /// The underlying IO error. 29 | source: std::io::Error, 30 | }, 31 | /// Missing rule. 32 | #[error("Loop found in rules. Rendezvous point: {0}")] 33 | Loop(String), 34 | #[error("Error when reading/writing config file")] 35 | Load(#[from] ConfigIOError), 36 | } 37 | 38 | #[derive(Debug, Error)] 39 | pub enum ConfigIOError { 40 | #[error("Home directory not found")] 41 | MissingHome, 42 | #[error("Failed to create config directory")] 43 | CreateConfigDir(#[source] std::io::Error), 44 | #[error("Failed to read config")] 45 | ReadConfig(#[source] std::io::Error), 46 | #[error("Failed to write config")] 47 | WriteConfig(#[source] std::io::Error), 48 | #[error("Error when deserializing config file")] 49 | Deserialize(#[source] Box), 50 | #[error("Error when serializing config file")] 51 | Serialize(#[source] Box), 52 | } 53 | 54 | #[derive(Debug, Error)] 55 | pub enum ApplyError { 56 | #[error("URL is invalid")] 57 | InvalidURL, 58 | #[error("Failed to apply rule: {0}")] 59 | PropertyFail(#[from] CFError), 60 | #[error("Failed to apply rule: {0}")] 61 | IO(#[from] std::io::Error), 62 | } 63 | 64 | #[derive(Serialize, TS)] 65 | #[ts(export, export_to = "../src/bindings/")] 66 | pub struct ApplyErrors { 67 | pub errors: HashMap, 68 | } 69 | 70 | impl ApplyErrors { 71 | pub fn from(r: Result<(), HashMap>) -> Result<(), Self> { 72 | r.map_err(|e| Self { 73 | errors: e.into_iter().map(|(k, v)| (k, v.to_string())).collect(), 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src-tauri/src/lib/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions, clippy::default_trait_access)] 2 | 3 | pub use config::{ConfigManager, PreConfig}; 4 | pub use error::{ApplyError, ApplyErrors, ConfigError}; 5 | pub use metrics::Metrics; 6 | pub use mission::{Mission, ScanStatus}; 7 | pub use properties::Store; 8 | pub use tmutil::ExclusionActionBatch; 9 | pub use walker::{walk_non_recursive, walk_recursive}; 10 | pub use watcher::watch_task; 11 | 12 | mod config; 13 | mod error; 14 | mod metrics; 15 | mod mission; 16 | mod properties; 17 | mod skip_cache; 18 | mod tmutil; 19 | mod walker; 20 | mod watcher; 21 | -------------------------------------------------------------------------------- /src-tauri/src/lib/metrics.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; 3 | use std::sync::Arc; 4 | use std::time::SystemTime; 5 | 6 | use arc_swap::ArcSwap; 7 | use serde::{Serialize, Serializer}; 8 | use ts_rs::TS; 9 | 10 | #[derive(Serialize, TS)] 11 | #[ts(export, export_to = "../src/bindings/")] 12 | #[serde(rename_all = "kebab-case")] 13 | pub struct Metrics { 14 | #[ts(type = "number")] 15 | #[serde(serialize_with = "serialize_atomic_usize")] 16 | files_excluded: AtomicUsize, 17 | #[ts(type = "number")] 18 | #[serde(serialize_with = "serialize_atomic_usize")] 19 | files_included: AtomicUsize, 20 | #[ts(type = "string")] 21 | #[serde(serialize_with = "serialize_arc_path")] 22 | last_excluded: ArcSwap>, 23 | #[ts(type = "number")] 24 | #[serde(serialize_with = "serialize_atomic_u64")] 25 | last_excluded_time: AtomicU64, 26 | } 27 | 28 | impl Default for Metrics { 29 | fn default() -> Self { 30 | Self { 31 | files_excluded: AtomicUsize::new(0), 32 | files_included: AtomicUsize::new(0), 33 | last_excluded: ArcSwap::new(Arc::new(Box::from(Path::new("")))), 34 | last_excluded_time: AtomicU64::new(0), 35 | } 36 | } 37 | } 38 | 39 | impl Metrics { 40 | pub fn inc_excluded(&self, n: usize) { 41 | self.files_excluded.fetch_add(n, Ordering::Relaxed); 42 | } 43 | pub fn inc_included(&self, n: usize) { 44 | self.files_included.fetch_add(n, Ordering::Relaxed); 45 | } 46 | pub fn set_last_excluded(&self, path: &Path) { 47 | let now = SystemTime::now() 48 | .duration_since(SystemTime::UNIX_EPOCH) 49 | .expect("past is future") 50 | .as_secs(); 51 | self.last_excluded.store(Arc::new(Box::from(path))); 52 | self.last_excluded_time.store(now, Ordering::Relaxed); 53 | } 54 | } 55 | 56 | fn serialize_atomic_usize(t: &AtomicUsize, s: S) -> Result 57 | where 58 | S: Serializer, 59 | { 60 | t.load(Ordering::Relaxed).serialize(s) 61 | } 62 | 63 | fn serialize_atomic_u64(t: &AtomicU64, s: S) -> Result 64 | where 65 | S: Serializer, 66 | { 67 | t.load(Ordering::Relaxed).serialize(s) 68 | } 69 | 70 | fn serialize_arc_path(t: &ArcSwap>, s: S) -> Result 71 | where 72 | S: Serializer, 73 | { 74 | t.load().serialize(s) 75 | } 76 | -------------------------------------------------------------------------------- /src-tauri/src/lib/mission.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::use_self)] 2 | 3 | use std::path::PathBuf; 4 | use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; 5 | use std::sync::Arc; 6 | use std::{io, mem}; 7 | 8 | use arc_swap::ArcSwap; 9 | use parking_lot::{Mutex, RwLock}; 10 | use serde::Serialize; 11 | use serde_json::Value; 12 | use tauri::async_runtime::{channel, JoinHandle}; 13 | use tauri::{AppHandle, Manager}; 14 | use tracing::error; 15 | use ts_rs::TS; 16 | 17 | use crate::config::{Config, ConfigManager, PreConfig}; 18 | use crate::error::ConfigError; 19 | use crate::metrics::Metrics; 20 | use crate::properties::Store; 21 | use crate::tmutil::ExclusionActionBatch; 22 | use crate::walker::walk_recursive; 23 | use crate::watcher::watch_task; 24 | 25 | pub struct Mission { 26 | app: AppHandle, 27 | properties: Store, 28 | config_manager: ConfigManager, 29 | pre_config: ArcSwap, 30 | config: ArcSwap, 31 | watcher_handle: Mutex>>, 32 | metrics: Arc, 33 | scan_status: RwLock, 34 | scan_handle: Mutex>, 35 | } 36 | 37 | pub struct ScanHandle { 38 | abort_flag: Arc, 39 | task_handle: JoinHandle<()>, 40 | } 41 | 42 | impl ScanHandle { 43 | pub fn stop(self) { 44 | self.abort_flag.store(true, Ordering::Relaxed); 45 | self.task_handle.abort(); 46 | } 47 | } 48 | 49 | #[derive(Debug, Clone, Default, Serialize, TS)] 50 | #[ts(export, export_to = "../src/bindings/")] 51 | #[serde(tag = "step", content = "content", rename_all = "kebab-case")] 52 | pub enum ScanStatus { 53 | #[default] 54 | Idle, 55 | Scanning { 56 | current_path: PathBuf, 57 | found: usize, 58 | }, 59 | Result(ExclusionActionBatch), 60 | } 61 | 62 | impl Mission { 63 | /// Create a new mission. 64 | /// 65 | /// This function will start a watcher task. 66 | /// 67 | /// # Errors 68 | /// Returns error if can't load config. 69 | pub fn new_arc( 70 | app: AppHandle, 71 | config_manager: ConfigManager, 72 | properties: Store, 73 | ) -> Result, ConfigError> { 74 | let pre_config = match config_manager.load() { 75 | Ok(pre_config) => pre_config, 76 | Err(e) => { 77 | error!(?e, "Failed to load config, resetting to default"); 78 | config_manager.reset()?; 79 | config_manager.load()? 80 | } 81 | }; 82 | let config = Config::try_from(pre_config.clone())?; 83 | Ok(Arc::new_cyclic(move |this| { 84 | let task = watch_task(this.clone()); 85 | let handle = tauri::async_runtime::spawn(task); 86 | Self { 87 | app, 88 | properties, 89 | config_manager, 90 | pre_config: ArcSwap::from_pointee(pre_config), 91 | config: ArcSwap::from_pointee(config), 92 | watcher_handle: Mutex::new(handle), 93 | metrics: Arc::new(Metrics::default()), 94 | scan_status: Default::default(), 95 | scan_handle: Default::default(), 96 | } 97 | })) 98 | } 99 | pub fn store_get(&self, key: &str) -> Option { 100 | self.properties.get(key) 101 | } 102 | pub fn store_set(&self, key: String, value: Value) { 103 | self.properties.set(&self.app, key, value); 104 | } 105 | pub fn store_del(&self, key: &str) { 106 | self.properties.del(&self.app, key); 107 | } 108 | /// Get internal config. 109 | pub(crate) fn config_(&self) -> Arc { 110 | self.config.load().clone() 111 | } 112 | /// Get current pre-config. 113 | pub fn config(&self) -> Arc { 114 | self.pre_config.load().clone() 115 | } 116 | /// Get metrics. 117 | pub fn metrics(&self) -> Arc { 118 | self.metrics.clone() 119 | } 120 | /// Set new config. 121 | /// 122 | /// This method will restart watcher task. 123 | /// 124 | /// # Errors 125 | /// Returns error if can't persist config, or can't parse surface config (shouldn't happen). 126 | pub fn set_config(self: Arc, config: PreConfig) -> Result<(), ConfigError> { 127 | let config_ = Config::try_from(config.clone())?; 128 | self.config_manager.save(&config)?; 129 | self.pre_config.store(Arc::new(config)); 130 | self.config.store(Arc::new(config_)); 131 | self.reload(); 132 | Ok(()) 133 | } 134 | /// Reload watcher task to apply new config. 135 | pub fn reload(self: Arc) { 136 | // Create and spawn new watch task. 137 | let new_task = watch_task(Arc::downgrade(&self)); 138 | let handle = tauri::async_runtime::spawn(new_task); 139 | 140 | // Stop old watch task. 141 | let old_handle = mem::replace(&mut *self.watcher_handle.lock(), handle); 142 | old_handle.abort(); 143 | 144 | // Broadcast new config. 145 | self.app 146 | .emit_all("config_changed", self.config()) 147 | .expect("failed to broadcast event"); 148 | } 149 | fn set_scan_status(&self, status: ScanStatus) { 150 | *self.scan_status.write() = status.clone(); 151 | self.app 152 | .emit_all("scan_status_changed", status) 153 | .expect("failed to broadcast event"); 154 | } 155 | pub fn stop_full_scan(&self) { 156 | if let Some(handle) = self.scan_handle.lock().take() { 157 | handle.stop(); 158 | } 159 | self.set_scan_status(ScanStatus::Idle); 160 | } 161 | pub fn scan_status(&self) -> ScanStatus { 162 | self.scan_status.read().clone() 163 | } 164 | pub fn full_scan(self: Arc) { 165 | self.stop_full_scan(); 166 | 167 | let abort = Arc::new(AtomicBool::new(false)); 168 | let found = Arc::new(AtomicUsize::new(0)); 169 | let this = self.clone(); 170 | 171 | let scan_task = { 172 | let abort = abort.clone(); 173 | async move { 174 | let (curr_tx, mut curr_rx) = channel(128); 175 | std::thread::spawn({ 176 | let this = this.clone(); 177 | let found = found.clone(); 178 | move || { 179 | let config = this.config_(); 180 | let walk_config = (*config.walk).clone(); 181 | let support_dump = config.support_dump; 182 | let result = walk_recursive( 183 | walk_config, 184 | support_dump, 185 | curr_tx, 186 | found, 187 | abort.clone(), 188 | ); 189 | if !abort.load(Ordering::Relaxed) { 190 | this.set_scan_status(ScanStatus::Result(result)); 191 | } 192 | } 193 | }); 194 | 195 | while let Some(curr) = curr_rx.recv().await { 196 | this.set_scan_status(ScanStatus::Scanning { 197 | current_path: curr, 198 | found: found.load(Ordering::Relaxed), 199 | }); 200 | } 201 | } 202 | }; 203 | 204 | let handle = ScanHandle { 205 | abort_flag: abort, 206 | task_handle: tauri::async_runtime::spawn(scan_task), 207 | }; 208 | self.scan_handle.lock().replace(handle); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src-tauri/src/lib/properties.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022 LightQuantum. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | 6 | use std::path::{Path, PathBuf}; 7 | use std::sync::Arc; 8 | 9 | use parking_lot::Mutex; 10 | use serde_json::{Map, Value}; 11 | use tauri::{AppHandle, Manager, Runtime}; 12 | use tracing::error; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Store { 16 | data: Arc>>, 17 | path: PathBuf, 18 | } 19 | 20 | fn read_from_path(path: &Path) -> Map { 21 | if path.exists() { 22 | serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap_or_else(|e| { 23 | error!(?e, ".properties file is corrupted, deleting it"); 24 | std::fs::remove_file(path).unwrap(); 25 | Default::default() 26 | }) 27 | } else { 28 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); 29 | Default::default() 30 | } 31 | } 32 | 33 | impl Store { 34 | pub fn new(base: &Path) -> Self { 35 | let path = base.join(".properties"); 36 | let data = read_from_path(&path); 37 | Self { 38 | data: Arc::new(Mutex::new(data)), 39 | path, 40 | } 41 | } 42 | pub fn reload(&self) { 43 | let mut data = self.data.lock(); 44 | *data = read_from_path(&self.path); 45 | } 46 | pub fn get(&self, key: &str) -> Option { 47 | let data = self.data.lock(); 48 | data.get(key).cloned() 49 | } 50 | pub fn set(&self, handle: &AppHandle, key: String, value: Value) { 51 | let mut data = self.data.lock(); 52 | data.insert(key, value); 53 | std::fs::write(&self.path, serde_json::to_vec(&*data).unwrap()).unwrap(); 54 | drop(handle.emit_all("properties_changed", data.clone())); 55 | } 56 | pub fn del(&self, handle: &AppHandle, key: &str) { 57 | let mut data = self.data.lock(); 58 | data.remove(key); 59 | std::fs::write(&self.path, serde_json::to_vec(&*data).unwrap()).unwrap(); 60 | drop(handle.emit_all("properties_changed", data.clone())); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src-tauri/src/lib/skip_cache.rs: -------------------------------------------------------------------------------- 1 | //! Cache facilities used in walker. 2 | 3 | use std::borrow::Borrow; 4 | use std::ops::Deref; 5 | use std::path::{Path, PathBuf}; 6 | use std::sync::Arc; 7 | 8 | use moka::sync::Cache; 9 | 10 | const CACHE_MAX_CAPACITY: u64 = 512; 11 | 12 | /// Cache for skipped directories to avoid redundant syscall. 13 | #[derive(Clone)] 14 | pub struct SkipCache(Arc>); 15 | 16 | impl Default for SkipCache { 17 | fn default() -> Self { 18 | Self(Arc::new(Cache::new(CACHE_MAX_CAPACITY))) 19 | } 20 | } 21 | 22 | impl Deref for SkipCache { 23 | type Target = Cache; 24 | 25 | fn deref(&self) -> &Self::Target { 26 | &self.0 27 | } 28 | } 29 | 30 | /// Custom `Path` wrapper to implement `Borrow` for Arc. 31 | #[repr(transparent)] 32 | #[derive(Debug, Eq, PartialEq, Hash)] 33 | pub struct CachedPath(Path); 34 | 35 | impl From<&Path> for &CachedPath { 36 | fn from(p: &Path) -> Self { 37 | // SAFETY CachedPath is repr(transparent) 38 | unsafe { &*(p as *const Path as *const CachedPath) } 39 | } 40 | } 41 | 42 | impl Borrow for PathBuf { 43 | fn borrow(&self) -> &CachedPath { 44 | self.as_path().into() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src-tauri/src/lib/tmutil.rs: -------------------------------------------------------------------------------- 1 | //! Utils needed to operate on `TimeMachine`. 2 | use std::borrow::Borrow; 3 | use std::collections::HashMap; 4 | use std::ffi::CString; 5 | use std::ops::{Add, AddAssign}; 6 | use std::os::unix::ffi::OsStrExt; 7 | use std::path::{Path, PathBuf}; 8 | use std::ptr; 9 | 10 | use core_foundation::base::{CFTypeRef, TCFType, ToVoid}; 11 | use core_foundation::error::{CFError, CFErrorRef}; 12 | use core_foundation::number::{kCFBooleanFalse, kCFBooleanTrue}; 13 | use core_foundation::string::CFStringRef; 14 | use core_foundation::url; 15 | use core_foundation::url::{kCFURLIsExcludedFromBackupKey, CFURL}; 16 | use serde::{Deserialize, Serialize}; 17 | use tap::TapFallible; 18 | use tracing::{info, warn}; 19 | use ts_rs::TS; 20 | 21 | use crate::error::ApplyError; 22 | 23 | /// Check whether a path is excluded from time machine. 24 | /// 25 | /// # Errors 26 | /// `io::Error` if can't query xattr of given file. 27 | pub fn is_excluded(path: impl AsRef) -> std::io::Result { 28 | let path = path.as_ref(); 29 | Ok( 30 | xattr::get(path, "com.apple.metadata:com_apple_backup_excludeItem") 31 | .tap_err(|e| warn!("Error when querying xattr of file {:?}: {}", path, e))? 32 | .is_some(), 33 | ) 34 | } 35 | 36 | const UF_NODUMP: u32 = 0x0000_0001; 37 | 38 | /// Check whether a path is excluded from DUMP. 39 | /// 40 | /// # Errors 41 | /// `io::Error` if can't stat on given file. 42 | pub fn is_nodump(path: impl AsRef) -> std::io::Result { 43 | let path = CString::new(path.as_ref().as_os_str().as_bytes()).expect("path contains null byte"); 44 | let stat = unsafe { 45 | let mut stat: libc::stat = std::mem::zeroed(); 46 | let ret = libc::stat(path.as_ptr(), &mut stat); 47 | if ret != 0 { 48 | return Err(std::io::Error::last_os_error()); 49 | } 50 | stat 51 | }; 52 | Ok(stat.st_flags & UF_NODUMP != 0) 53 | } 54 | 55 | /// Set NODUMP flag on a path. 56 | /// 57 | /// # Errors 58 | /// `io::Error` if can't stat or chflags on given file. 59 | pub fn set_nodump(path: impl AsRef, value: bool) -> std::io::Result<()> { 60 | let path = CString::new(path.as_ref().as_os_str().as_bytes()).expect("path contains null byte"); 61 | let mut stat = unsafe { 62 | let mut stat: libc::stat = std::mem::zeroed(); 63 | let ret = libc::stat(path.as_ptr(), &mut stat); 64 | if ret != 0 { 65 | return Err(std::io::Error::last_os_error()); 66 | } 67 | stat 68 | }; 69 | if value { 70 | stat.st_flags |= UF_NODUMP; 71 | } else { 72 | stat.st_flags &= !UF_NODUMP; 73 | } 74 | let ret = unsafe { libc::chflags(path.as_ptr(), stat.st_flags) }; 75 | if ret == 0 { 76 | Ok(()) 77 | } else { 78 | Err(std::io::Error::last_os_error()) 79 | } 80 | } 81 | 82 | /// Represents a batch of tmutil modifications. 83 | #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] 84 | #[ts(export, export_to = "../src/bindings/")] 85 | pub struct ExclusionActionBatch { 86 | /// Paths to be added to backup exclusion list. 87 | pub add: Vec, 88 | /// Paths to be removed from backup exclusion list. 89 | pub remove: Vec, 90 | } 91 | 92 | impl ExclusionActionBatch { 93 | /// Return `true` if the batch contains no actions. 94 | #[must_use] 95 | pub fn is_empty(&self) -> bool { 96 | self.add.is_empty() && self.remove.is_empty() 97 | } 98 | /// Return the actions count in the batch. 99 | #[must_use] 100 | pub fn count(&self) -> usize { 101 | self.add.len() + self.remove.len() 102 | } 103 | /// Apply the batch. 104 | /// 105 | /// # Errors 106 | /// Return batched errors if any. 107 | pub fn apply(self, support_dump: bool) -> Result<(), HashMap> { 108 | let errors: HashMap<_, _> = self 109 | .add 110 | .into_iter() 111 | .filter_map(|path| { 112 | info!("Excluding {:?} from backups", path); 113 | ExclusionAction::Add(path.clone()) 114 | .apply(support_dump) 115 | .err() 116 | .map(|e| (path, e)) 117 | }) 118 | .chain(self.remove.into_iter().filter_map(|path| { 119 | info!("Including {:?} in backups", path); 120 | ExclusionAction::Remove(path.clone()) 121 | .apply(support_dump) 122 | .err() 123 | .map(|e| (path, e)) 124 | })) 125 | .collect(); 126 | if errors.is_empty() { 127 | Ok(()) 128 | } else { 129 | Err(errors) 130 | } 131 | } 132 | } 133 | 134 | impl From for ExclusionActionBatch 135 | where 136 | T: Iterator, 137 | { 138 | fn from(it: T) -> Self { 139 | let mut this = Self::default(); 140 | it.for_each(|item| match item { 141 | ExclusionAction::Add(path) => this.add.push(path), 142 | ExclusionAction::Remove(path) => this.remove.push(path), 143 | }); 144 | this 145 | } 146 | } 147 | 148 | impl> Add for ExclusionActionBatch { 149 | type Output = Self; 150 | 151 | fn add(mut self, rhs: T) -> Self::Output { 152 | self.add.extend_from_slice(&rhs.borrow().add); 153 | self.remove.extend_from_slice(&rhs.borrow().remove); 154 | self 155 | } 156 | } 157 | 158 | impl AddAssign for ExclusionActionBatch { 159 | fn add_assign(&mut self, rhs: Self) { 160 | self.add.extend_from_slice(&rhs.add); 161 | self.remove.extend_from_slice(&rhs.remove); 162 | } 163 | } 164 | 165 | /// Represents a tmutil modification. 166 | #[derive(Debug, Clone)] 167 | pub enum ExclusionAction { 168 | /// Add a path to backup exclusion list. 169 | Add(PathBuf), 170 | /// Remove a path to backup exclusion list. 171 | Remove(PathBuf), 172 | } 173 | 174 | impl ExclusionAction { 175 | /// Apply the action. 176 | pub fn apply(self, support_dump: bool) -> Result<(), ApplyError> { 177 | let value = matches!(self, Self::Add(_)); 178 | let objc_value = unsafe { 179 | if value { 180 | kCFBooleanTrue 181 | } else { 182 | kCFBooleanFalse 183 | } 184 | }; 185 | match self { 186 | Self::Add(path) | Self::Remove(path) => { 187 | if let Some(path) = CFURL::from_path(&path, false) { 188 | set_resource_property_for_key( 189 | &path, 190 | unsafe { kCFURLIsExcludedFromBackupKey }, 191 | objc_value.to_void(), 192 | )?; 193 | } else { 194 | return Err(ApplyError::InvalidURL); 195 | } 196 | if support_dump { 197 | set_nodump(&path, value)?; 198 | } 199 | } 200 | } 201 | Ok(()) 202 | } 203 | } 204 | 205 | fn set_resource_property_for_key( 206 | url: &CFURL, 207 | key: CFStringRef, 208 | value: CFTypeRef, 209 | ) -> Result<(), CFError> { 210 | let mut err: CFErrorRef = ptr::null_mut(); 211 | let result = unsafe { 212 | url::CFURLSetResourcePropertyForKey(url.as_concrete_TypeRef(), key, value, &mut err) 213 | }; 214 | if result == 0 { 215 | let err = unsafe { CFError::wrap_under_create_rule(err) }; 216 | Err(err) 217 | } else { 218 | Ok(()) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src-tauri/src/lib/walker.rs: -------------------------------------------------------------------------------- 1 | //! Utils and actors to walk directories recursively (or not) and perform `TimeMachine` operations on demand. 2 | 3 | use std::collections::HashMap; 4 | use std::fs; 5 | use std::path::{Path, PathBuf}; 6 | use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; 7 | use std::sync::Arc; 8 | 9 | use crossbeam::queue::SegQueue; 10 | use itertools::Itertools; 11 | use jwalk::WalkDirGeneric; 12 | use moka::sync::Cache; 13 | use tap::TapFallible; 14 | use tauri::async_runtime::Sender; 15 | use tracing::{debug, warn}; 16 | 17 | use crate::config::{Directory, Rule, WalkConfig}; 18 | use crate::skip_cache::CachedPath; 19 | use crate::tmutil::{is_excluded, is_nodump, ExclusionAction, ExclusionActionBatch}; 20 | 21 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] 22 | enum ExcludeState { 23 | /// The path is currently excluded from backups. 24 | Excluded, 25 | /// The path is currently excluded from backups. 26 | Included, 27 | /// The exclude state of the path is unknown (conflict between TimeMachine and NODUMP). 28 | Inconsistent, 29 | } 30 | 31 | impl ExcludeState { 32 | pub fn is_excluded(&self) -> bool { 33 | matches!(self, Self::Excluded) 34 | } 35 | } 36 | 37 | fn check_f(support_dump: bool) -> fn(&Path) -> std::io::Result { 38 | if support_dump { 39 | |path| { 40 | let excluded = is_excluded(path)?; 41 | let nodump = is_nodump(path)?; 42 | Ok(match (excluded, nodump) { 43 | (true, true) => ExcludeState::Excluded, 44 | (false, false) => ExcludeState::Included, 45 | _ => ExcludeState::Inconsistent, 46 | }) 47 | } 48 | } else { 49 | |path| { 50 | let excluded = is_excluded(path)?; 51 | Ok(if excluded { 52 | ExcludeState::Excluded 53 | } else { 54 | ExcludeState::Included 55 | }) 56 | } 57 | } 58 | } 59 | 60 | /// Walk through a directory with given rules recursively and return an exclusion action plan. 61 | #[allow(clippy::needless_pass_by_value)] 62 | #[must_use] 63 | pub fn walk_recursive( 64 | config: WalkConfig, 65 | support_dump: bool, 66 | curr_tx: Sender, 67 | found: Arc, 68 | abort: Arc, 69 | ) -> ExclusionActionBatch { 70 | let check = check_f(support_dump); 71 | 72 | let batch_queue = Arc::new(SegQueue::new()); 73 | { 74 | let batch_queue = batch_queue.clone(); 75 | let Ok(root) = config.root() else { return ExclusionActionBatch::default() }; 76 | let counter = AtomicUsize::new(0); 77 | WalkDirGeneric::<(_, ())>::new(root) 78 | .root_read_dir_state(config) 79 | .skip_hidden(false) 80 | .process_read_dir({ 81 | let abort = abort.clone(); 82 | move |_, path, config, children| { 83 | // Remove effect-less directories & skips. 84 | config.directories.retain(|directory| { 85 | path.starts_with(&directory.path) || directory.path.starts_with(path) 86 | }); 87 | config.skips.retain(|skip| skip.starts_with(path)); 88 | 89 | if config.directories.is_empty() || abort.load(Ordering::Relaxed) { 90 | // There's no need to go deeper. 91 | for child in children.iter_mut().filter_map(|child| child.as_mut().ok()) { 92 | child.read_children_path = None; 93 | } 94 | return; 95 | } 96 | 97 | if counter 98 | .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |i| { 99 | Some(if i > 1000 { 0 } else { i + 1 }) 100 | }) 101 | .expect("f never returns None") 102 | == 0 103 | { 104 | if let Err(e) = curr_tx.try_send(path.to_path_buf()) { 105 | warn!("Failed to send current path: {}", e); 106 | } 107 | } 108 | 109 | // Acquire excluded state. 110 | let children = children 111 | .iter_mut() 112 | .filter_map(|entry| { 113 | entry 114 | .as_mut() 115 | .tap_err(|e| warn!("Error when scanning dir {:?}: {}", path, e)) 116 | .ok() 117 | }) 118 | .filter_map(|entry| { 119 | let path = entry.path(); 120 | if config.skips.contains(&path) { 121 | // Skip this entry in all preceding procedures and scans. 122 | entry.read_children_path = None; 123 | None 124 | } else { 125 | Some((entry, check(&path).ok()?)) 126 | } 127 | }) 128 | .collect_vec(); 129 | 130 | // Generate diff. 131 | let shallow_list: HashMap<_, _> = children 132 | .iter() 133 | .map(|(path, excluded)| { 134 | (PathBuf::from(path.file_name().to_os_string()), *excluded) 135 | }) 136 | .collect(); 137 | let diff = generate_diff(path, &shallow_list, &*config.directories); 138 | found.fetch_add(diff.count(), Ordering::Relaxed); 139 | 140 | // Exclude already excluded or uncovered children. 141 | for (entry, state) in children { 142 | let path = entry.path(); 143 | if (state.is_excluded() && !diff.remove.contains(&path)) 144 | || diff.add.contains(&path) 145 | { 146 | entry.read_children_path = None; 147 | } 148 | } 149 | batch_queue.push(diff); 150 | } 151 | }) 152 | .into_iter() 153 | .for_each(|_| {}); 154 | } 155 | if abort.load(Ordering::Relaxed) { 156 | // Aborted, the return value is irrelevant. 157 | return ExclusionActionBatch::default(); 158 | } 159 | let mut actions = ExclusionActionBatch::default(); 160 | while let Some(action) = batch_queue.pop() { 161 | actions += action; 162 | } 163 | actions 164 | } 165 | 166 | /// Walk through a directory with given rules non-recursively and return an exclusion action plan. 167 | #[must_use] 168 | pub fn walk_non_recursive( 169 | root: &Path, 170 | config: &WalkConfig, 171 | support_dump: bool, 172 | skip_cache: &Cache, 173 | ) -> ExclusionActionBatch { 174 | let check = check_f(support_dump); 175 | 176 | if skip_cache.get::(root.into()).is_some() { 177 | // Skip cache hit, early exit. 178 | return ExclusionActionBatch::default(); 179 | } 180 | 181 | if config.skips.iter().any(|skip| root.starts_with(skip)) { 182 | // The directory should be skipped. 183 | skip_cache.insert(root.to_path_buf(), ()); 184 | return ExclusionActionBatch::default(); 185 | } 186 | 187 | let mut directories = config 188 | .directories 189 | .iter() 190 | .filter(|directory| root.starts_with(&directory.path) || directory.path.starts_with(root)) 191 | .peekable(); 192 | if directories.peek().is_none() { 193 | // There's no need to scan because no rules is applicable. 194 | skip_cache.insert(root.to_path_buf(), ()); 195 | return ExclusionActionBatch::default(); 196 | } 197 | 198 | if root 199 | .ancestors() 200 | .any(|path| check(path).map(|s| s.is_excluded()).unwrap_or(false)) 201 | { 202 | // One of its parents is excluded. 203 | // Note that we don't put this dir into cache because the exclusion state of ancestors is unknown. 204 | return ExclusionActionBatch::default(); 205 | } 206 | 207 | debug!("Walk through {:?}", root); 208 | match fs::read_dir(root) { 209 | Ok(dir) => { 210 | let shallow_list: HashMap<_, _> = dir 211 | .filter_map(|entry| { 212 | entry 213 | .tap_err(|e| warn!("Error when scanning dir {:?}: {}", root, e)) 214 | .ok() 215 | }) 216 | .filter_map(|entry| { 217 | let path = entry.path(); 218 | if config.skips.contains(&path) { 219 | // Skip this entry in all preceding procedures and scans. 220 | None 221 | } else { 222 | Some(( 223 | PathBuf::from(path.file_name().expect("file name").to_os_string()), 224 | check(&path).ok()?, 225 | )) 226 | } 227 | }) 228 | .collect(); 229 | generate_diff(root, &shallow_list, directories) 230 | } 231 | Err(e) => { 232 | warn!("Error when scanning dir {:?}: {}", root, e); 233 | ExclusionActionBatch::default() 234 | } 235 | } 236 | } 237 | 238 | fn generate_diff<'a, 'b>( 239 | cwd: &'a Path, 240 | shallow_list: &'a HashMap, 241 | directories: impl IntoIterator, 242 | ) -> ExclusionActionBatch { 243 | let candidate_rules: Vec<&Rule> = directories 244 | .into_iter() 245 | .filter(|directory| directory.path.starts_with(cwd) || cwd.starts_with(&directory.path)) 246 | .flat_map(|directory| &directory.rules) 247 | .collect(); 248 | shallow_list 249 | .iter() 250 | .filter_map(|(name, excluded)| { 251 | let expected_excluded = candidate_rules.iter().any(|rule| { 252 | rule.excludes.contains(name) 253 | && (rule.if_exists.is_empty() 254 | || rule 255 | .if_exists 256 | .iter() 257 | .any(|if_exist| shallow_list.contains_key(if_exist.as_path()))) 258 | }); 259 | match (expected_excluded, *excluded) { 260 | (true, ExcludeState::Included | ExcludeState::Inconsistent) => { 261 | Some(ExclusionAction::Add(cwd.join(name))) 262 | } 263 | (false, ExcludeState::Excluded | ExcludeState::Inconsistent) => { 264 | Some(ExclusionAction::Remove(cwd.join(name))) 265 | } 266 | _ => None, 267 | } 268 | }) 269 | .into() 270 | } 271 | -------------------------------------------------------------------------------- /src-tauri/src/lib/watcher.rs: -------------------------------------------------------------------------------- 1 | //! Filesystem watcher. 2 | 3 | use std::io; 4 | use std::sync::Weak; 5 | use std::time::Duration; 6 | 7 | use fsevent_stream::ffi::{kFSEventStreamCreateFlagIgnoreSelf, kFSEventStreamEventIdSinceNow}; 8 | use fsevent_stream::stream::{create_event_stream, EventStreamHandler}; 9 | use futures::StreamExt; 10 | use tracing::{debug, error}; 11 | 12 | use crate::mission::Mission; 13 | use crate::skip_cache::SkipCache; 14 | use crate::walker::walk_non_recursive; 15 | 16 | const EVENT_DELAY: Duration = Duration::from_secs(30); 17 | 18 | struct DropGuard(Option); 19 | 20 | impl DropGuard { 21 | pub const fn new(handler: EventStreamHandler) -> Self { 22 | Self(Some(handler)) 23 | } 24 | } 25 | 26 | impl Drop for DropGuard { 27 | fn drop(&mut self) { 28 | if let Some(mut handler) = self.0.take() { 29 | handler.abort(); 30 | } 31 | } 32 | } 33 | 34 | /// # Errors 35 | /// Returns `io::Error` if fs event stream creation fails. 36 | pub async fn watch_task(mission: Weak) -> io::Result<()> { 37 | let mission = mission.upgrade().ok_or_else(|| { 38 | io::Error::new( 39 | io::ErrorKind::Other, 40 | "mission is dropped before watch task is started", 41 | ) 42 | })?; 43 | let config = mission.config_(); 44 | let metrics = mission.metrics(); 45 | 46 | let paths = config 47 | .walk 48 | .directories 49 | .iter() 50 | .map(|directory| directory.path.as_path()); 51 | let no_include = config.no_include; 52 | let support_dump = config.support_dump; 53 | 54 | let (mut stream, event_handle) = create_event_stream( 55 | paths, 56 | kFSEventStreamEventIdSinceNow, 57 | EVENT_DELAY, 58 | kFSEventStreamCreateFlagIgnoreSelf, 59 | )?; 60 | let _guard = DropGuard::new(event_handle); 61 | 62 | let cache = SkipCache::default(); 63 | while let Some(items) = stream.next().await { 64 | for item in items { 65 | if !item.path.as_os_str().is_empty() { 66 | tauri::async_runtime::spawn_blocking({ 67 | let _path = item.path.clone(); 68 | let walk_config = config.walk.clone(); 69 | let cache = cache.clone(); 70 | let metrics = metrics.clone(); 71 | move || { 72 | let mut batch = 73 | walk_non_recursive(&item.path, &walk_config, support_dump, &cache); 74 | if batch.is_empty() { 75 | return; 76 | } 77 | debug!("Apply batch {:?}", batch); 78 | if no_include { 79 | batch.remove.clear(); 80 | } 81 | metrics.inc_excluded(batch.add.len()); 82 | metrics.inc_included(batch.remove.len()); 83 | if let Some(last_file) = batch.add.last() { 84 | metrics.set_last_excluded(last_file.as_path()); 85 | } 86 | if let Err(errors) = batch.apply(support_dump) { 87 | for (path, e) in errors { 88 | error!("Error when applying on file {}: {}", path.display(), e); 89 | } 90 | } 91 | } 92 | }); 93 | } 94 | } 95 | } 96 | 97 | Ok(()) 98 | } 99 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions, clippy::needless_pass_by_value)] 2 | #![cfg_attr( 3 | all(not(debug_assertions), target_os = "windows"), 4 | windows_subsystem = "windows" 5 | )] 6 | 7 | #[macro_use] 8 | extern crate objc; 9 | 10 | use std::sync::Arc; 11 | 12 | use once_cell::sync::Lazy; 13 | use regex::Regex; 14 | use tap::TapFallible; 15 | use tauri::{ 16 | ActivationPolicy, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, 17 | SystemTrayMenuItem, 18 | }; 19 | use tracing::{error, instrument}; 20 | use tracing_subscriber::layer::SubscriberExt; 21 | use tracing_subscriber::util::SubscriberInitExt; 22 | use window_vibrancy::NSVisualEffectMaterial; 23 | 24 | use tmexclude_lib::{ 25 | ApplyErrors, ConfigManager, ExclusionActionBatch, Metrics, Mission, PreConfig, ScanStatus, 26 | Store, 27 | }; 28 | 29 | use crate::decorations::WindowExt; 30 | use crate::metadata::build_meta; 31 | use crate::plugins::{BackgroundPlugin, EnvironmentPlugin}; 32 | 33 | mod decorations; 34 | mod metadata; 35 | mod plugins; 36 | 37 | #[tauri::command] 38 | #[instrument(skip(mission))] 39 | fn metrics(mission: tauri::State>) -> Arc { 40 | mission.metrics() 41 | } 42 | 43 | #[tauri::command] 44 | #[instrument(skip(mission))] 45 | fn get_config(mission: tauri::State>) -> Arc { 46 | mission.config() 47 | } 48 | 49 | #[tauri::command] 50 | #[instrument(skip_all)] 51 | fn set_config(mission: tauri::State>, config: PreConfig) -> Result<(), String> { 52 | let mission = mission.inner().clone(); 53 | mission.set_config(config).map_err(|e| e.to_string()) 54 | } 55 | 56 | #[tauri::command] 57 | #[instrument(skip(mission))] 58 | fn scan_status(mission: tauri::State>) -> ScanStatus { 59 | mission.scan_status() 60 | } 61 | 62 | #[tauri::command] 63 | #[instrument(skip(mission))] 64 | fn start_full_scan(mission: tauri::State>) { 65 | mission.inner().clone().full_scan() 66 | } 67 | 68 | #[tauri::command] 69 | #[instrument(skip(mission))] 70 | fn stop_full_scan(mission: tauri::State>) { 71 | mission.stop_full_scan(); 72 | } 73 | 74 | #[tauri::command] 75 | #[instrument(skip_all, fields(add = batch.add.len(), remove = batch.remove.len()))] 76 | async fn apply_action_batch( 77 | mission: tauri::State<'_, Arc>, 78 | batch: ExclusionActionBatch, 79 | ) -> Result<(), ApplyErrors> { 80 | let support_dump = mission.inner().config().support_dump; 81 | tauri::async_runtime::spawn_blocking(move || { 82 | let r = batch 83 | .apply(support_dump) 84 | .tap_err(|e| e.values().for_each(|e| error!(?e, "Apply batch failed"))); 85 | ApplyErrors::from(r) 86 | }) 87 | .await 88 | .expect("spawn_blocking failed") 89 | } 90 | 91 | #[tauri::command] 92 | #[instrument(skip(mission))] 93 | fn store_get(mission: tauri::State>, key: &str) -> Option { 94 | mission.store_get(key) 95 | } 96 | 97 | #[tauri::command] 98 | #[instrument(skip(mission))] 99 | fn store_set(mission: tauri::State>, key: String, value: serde_json::Value) { 100 | mission.store_set(key, value) 101 | } 102 | 103 | #[tauri::command] 104 | #[instrument(skip(mission))] 105 | fn store_del(mission: tauri::State>, key: &str) { 106 | mission.store_del(key) 107 | } 108 | 109 | fn system_tray() -> SystemTray { 110 | let preference = CustomMenuItem::new("preference", "Preference"); 111 | let about = CustomMenuItem::new("about", "About"); 112 | let quit = CustomMenuItem::new("quit", "Quit"); 113 | let tray_menu = SystemTrayMenu::new() 114 | .add_item(preference) 115 | .add_item(about) 116 | .add_native_item(SystemTrayMenuItem::Separator) 117 | .add_item(quit); 118 | SystemTray::new().with_menu(tray_menu) 119 | } 120 | 121 | fn main() { 122 | static PATH_RE: Lazy = Lazy::new(|| Regex::new(r#""/.*""#).unwrap()); 123 | let _guard = sentry::init(( 124 | env!("SENTRY_DSN"), 125 | sentry::ClientOptions { 126 | release: Some(build_meta().version.into()), 127 | before_send: Some(Arc::new(|mut ev| { 128 | ev.message = ev 129 | .message 130 | .map(|s| PATH_RE.replace_all(&s, "\"\"").to_string()); 131 | Some(ev) 132 | })), 133 | before_breadcrumb: Some(Arc::new(|mut breadcrumb| { 134 | breadcrumb.message = breadcrumb 135 | .message 136 | .map(|s| PATH_RE.replace_all(&s, "\"\"").to_string()); 137 | Some(breadcrumb) 138 | })), 139 | ..Default::default() 140 | }, 141 | )); 142 | tracing_subscriber::registry() 143 | .with(tracing_subscriber::fmt::layer()) 144 | .with(sentry::integrations::tracing::layer()) 145 | .init(); 146 | 147 | let context = tauri::generate_context!(); 148 | 149 | let config_manager = ConfigManager::new().unwrap(); 150 | tauri::Builder::default() 151 | .system_tray(system_tray()) 152 | .on_system_tray_event(|app, ev| { 153 | if let SystemTrayEvent::MenuItemClick { id, .. } = ev { 154 | match id.as_str() { 155 | "preference" => { 156 | let window = app.get_window("main").unwrap(); 157 | window.show().unwrap(); 158 | window.set_focus().unwrap(); 159 | } 160 | "about" => { 161 | let window = app.get_window("about").unwrap(); 162 | window.show().unwrap(); 163 | window.set_focus().unwrap(); 164 | } 165 | "quit" => { 166 | app.exit(0); 167 | } 168 | _ => {} 169 | } 170 | } 171 | }) 172 | .plugin(BackgroundPlugin) 173 | .plugin(EnvironmentPlugin) 174 | .plugin(plugins::auto_launch::init()) 175 | .invoke_handler(tauri::generate_handler![ 176 | metrics, 177 | get_config, 178 | set_config, 179 | scan_status, 180 | start_full_scan, 181 | stop_full_scan, 182 | apply_action_batch, 183 | build_meta, 184 | store_get, 185 | store_set, 186 | store_del 187 | ]) 188 | .setup(move |app| { 189 | let store = Store::new(&app.path_resolver().app_config_dir().unwrap()); 190 | app.manage( 191 | Mission::new_arc(app.handle(), config_manager, store) 192 | .expect("failed to create mission"), 193 | ); 194 | let main_window = app.get_window("main").unwrap(); 195 | window_vibrancy::apply_vibrancy( 196 | &main_window, 197 | NSVisualEffectMaterial::Sidebar, 198 | None, 199 | None, 200 | ) 201 | .expect("unable to apply vibrancy"); 202 | main_window.set_trafficlights_position(20., 20.); 203 | app.set_activation_policy(ActivationPolicy::Accessory); 204 | Ok(()) 205 | }) 206 | .run(context) 207 | .expect("error while running tauri application"); 208 | } 209 | -------------------------------------------------------------------------------- /src-tauri/src/metadata.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use ts_rs::TS; 3 | 4 | #[derive(Debug, Serialize, TS)] 5 | #[ts(export, export_to = "../src/bindings/")] 6 | pub struct BuildMeta { 7 | pub version: String, 8 | pub timestamp: String, 9 | } 10 | 11 | impl Default for BuildMeta { 12 | fn default() -> Self { 13 | Self { 14 | version: env!("VERGEN_GIT_SEMVER").to_string(), 15 | timestamp: env!("VERGEN_BUILD_TIMESTAMP").to_string(), 16 | } 17 | } 18 | } 19 | 20 | #[tauri::command] 21 | pub fn build_meta() -> BuildMeta { 22 | BuildMeta::default() 23 | } 24 | -------------------------------------------------------------------------------- /src-tauri/src/plugins.rs: -------------------------------------------------------------------------------- 1 | //! Background plugin. 2 | 3 | use tauri::plugin::Plugin; 4 | use tauri::{AppHandle, RunEvent, Window, WindowEvent, Wry}; 5 | 6 | pub struct EnvironmentPlugin; 7 | 8 | impl Plugin for EnvironmentPlugin { 9 | fn name(&self) -> &'static str { 10 | "environment" 11 | } 12 | fn initialization_script(&self) -> Option { 13 | #[cfg(debug_assertions)] 14 | return Some("window.__TAURI__.environment = \"development\"".to_string()); 15 | #[cfg(not(debug_assertions))] 16 | return Some("window.__TAURI__.environment = \"production\"".to_string()); 17 | } 18 | } 19 | 20 | pub struct BackgroundPlugin; 21 | 22 | impl Plugin for BackgroundPlugin { 23 | fn name(&self) -> &'static str { 24 | "background" 25 | } 26 | fn created(&mut self, window: Window) { 27 | window.on_window_event({ 28 | let window = window.clone(); 29 | move |ev| { 30 | if let WindowEvent::CloseRequested { api, .. } = ev { 31 | window.hide().expect("unable to hide window"); 32 | api.prevent_close(); 33 | } 34 | } 35 | }); 36 | } 37 | fn on_event(&mut self, _app: &AppHandle, event: &RunEvent) { 38 | if let RunEvent::ExitRequested { api, .. } = event { 39 | api.prevent_exit(); 40 | } 41 | } 42 | } 43 | 44 | pub mod auto_launch { 45 | use std::{env, ptr}; 46 | 47 | use auto_launch::{AutoLaunch, AutoLaunchBuilder}; 48 | use cocoa::base::id; 49 | use cocoa::foundation::NSInteger; 50 | use objc::runtime::{Class, BOOL, NO}; 51 | use tauri::plugin::{Builder, TauriPlugin}; 52 | use tauri::{Manager, Runtime, State}; 53 | use tracing::{error, instrument}; 54 | 55 | pub fn init() -> TauriPlugin { 56 | Builder::new("auto_launch") 57 | .invoke_handler(tauri::generate_handler![enable, disable, is_enabled]) 58 | .setup(move |app| { 59 | let current_exe = env::current_exe()?; 60 | let auto_launch = AutoLaunchBuilder::new() 61 | .set_app_name(&app.package_info().name) 62 | .set_use_launch_agent(false) 63 | .set_app_path(¤t_exe.canonicalize()?.display().to_string()) 64 | .build() 65 | .map_err(|e| e.to_string())?; 66 | let manager = LaunchManager(auto_launch); 67 | app.manage(manager); 68 | Ok(()) 69 | }) 70 | .build() 71 | } 72 | 73 | #[tauri::command] 74 | #[instrument(skip(manager))] 75 | async fn enable(manager: State<'_, LaunchManager>) -> Result<(), ()> { 76 | if !manager.enable() { 77 | error!("failed to enable auto launch"); 78 | } 79 | Ok(()) 80 | } 81 | 82 | #[tauri::command] 83 | #[instrument(skip(manager))] 84 | async fn disable(manager: State<'_, LaunchManager>) -> Result<(), ()> { 85 | if !manager.disable() { 86 | error!("Failed to disable auto launch"); 87 | } 88 | Ok(()) 89 | } 90 | 91 | #[tauri::command] 92 | #[instrument(skip(manager))] 93 | async fn is_enabled(manager: State<'_, LaunchManager>) -> Result { 94 | Ok(manager.is_enabled()) 95 | } 96 | 97 | struct LaunchManager(AutoLaunch); 98 | 99 | impl LaunchManager { 100 | fn enable(&self) -> bool { 101 | if let Some(cls) = Class::get("SMAppService") { 102 | let service: id = unsafe { msg_send![cls, mainAppService] }; 103 | let result: BOOL = 104 | unsafe { msg_send![service, registerAndReturnError: ptr::null_mut::()] }; 105 | let succ = !matches!(result, NO); 106 | if !succ { 107 | error!("Failed to register app to login items through SMAppService"); 108 | } 109 | succ 110 | } else { 111 | let r = self.0.enable(); 112 | if let Err(ref e) = r { 113 | error!(?e, "Failed to register app to login items"); 114 | } 115 | r.is_ok() 116 | } 117 | } 118 | fn disable(&self) -> bool { 119 | if let Some(cls) = Class::get("SMAppService") { 120 | let service: id = unsafe { msg_send![cls, mainAppService] }; 121 | let result: BOOL = 122 | unsafe { msg_send![service, unregisterAndReturnError: ptr::null_mut::()] }; 123 | let succ = !matches!(result, NO); 124 | if !succ { 125 | error!("Failed to unregister app to login items through SMAppService"); 126 | } 127 | succ 128 | } else { 129 | let r = self.0.disable(); 130 | if let Err(ref e) = r { 131 | error!(?e, "Failed to unregister app to login items"); 132 | } 133 | r.is_ok() 134 | } 135 | } 136 | fn is_enabled(&self) -> bool { 137 | if let Some(cls) = Class::get("SMAppService") { 138 | let service: id = unsafe { msg_send![cls, mainAppService] }; 139 | let r: NSInteger = unsafe { msg_send![service, status] }; 140 | let status = SmAppServiceStatus::from(r); 141 | match status { 142 | SmAppServiceStatus::Enabled => true, 143 | SmAppServiceStatus::NotRegistered => false, 144 | _ => { 145 | error!(?status, "Unexpected status"); 146 | false 147 | } 148 | } 149 | } else { 150 | let r = self.0.is_enabled(); 151 | if let Err(ref e) = r { 152 | error!(?e, "Failed to check status of auto launch"); 153 | } 154 | r.unwrap_or_default() 155 | } 156 | } 157 | } 158 | 159 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 160 | enum SmAppServiceStatus { 161 | NotRegistered = 0, 162 | Enabled = 1, 163 | RequiresApproval = 2, 164 | NotFound = 3, 165 | } 166 | 167 | impl From for SmAppServiceStatus { 168 | fn from(i: NSInteger) -> Self { 169 | match i { 170 | 0 => SmAppServiceStatus::NotRegistered, 171 | 1 => SmAppServiceStatus::Enabled, 172 | 2 => SmAppServiceStatus::RequiresApproval, 173 | 3 => SmAppServiceStatus::NotFound, 174 | _ => unreachable!(), 175 | } 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "pnpm dev", 4 | "beforeBuildCommand": "pnpm build", 5 | "devPath": "http://localhost:1420", 6 | "distDir": "../dist" 7 | }, 8 | "package": { 9 | "productName": "TimeMachine Exclude", 10 | "version": "0.2.2" 11 | }, 12 | "tauri": { 13 | "allowlist": { 14 | "window": { 15 | "startDragging": true, 16 | "show": true 17 | }, 18 | "dialog": { 19 | "open": true 20 | }, 21 | "path": { 22 | "all": true 23 | }, 24 | "shell": { 25 | "open": "^https?://(twitter.com|github.com|gitlab.com)" 26 | } 27 | }, 28 | "systemTray": { 29 | "iconPath": "icons/tray_icon.png", 30 | "iconAsTemplate": true 31 | }, 32 | "bundle": { 33 | "active": true, 34 | "category": "DeveloperTool", 35 | "copyright": "", 36 | "deb": { 37 | "depends": [] 38 | }, 39 | "externalBin": [], 40 | "icon": [ 41 | "icons/32x32.png", 42 | "icons/128x128.png", 43 | "icons/128x128@2x.png", 44 | "icons/icon.icns", 45 | "icons/icon.ico" 46 | ], 47 | "identifier": "me.lightquantum.tmexclude", 48 | "longDescription": "", 49 | "macOS": { 50 | "entitlements": null, 51 | "exceptionDomain": "", 52 | "frameworks": [], 53 | "providerShortName": null, 54 | "signingIdentity": null 55 | }, 56 | "resources": [], 57 | "shortDescription": "", 58 | "targets": "all", 59 | "windows": { 60 | "certificateThumbprint": null, 61 | "digestAlgorithm": "sha256", 62 | "timestampUrl": "" 63 | } 64 | }, 65 | "security": { 66 | "csp": null 67 | }, 68 | "updater": { 69 | "active": true, 70 | "dialog": true, 71 | "endpoints": [ 72 | "https://tmexclude-tauri-update.lightquantum.me/v1/{{target}}/{{arch}}/{{current_version}}" 73 | ], 74 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNEQzREQTVEQzc3QkIyMTUKUldRVnNudkhYZHJFUFhiMkdHaGcwSGE0aUQzRHlsMzFMczNQWldmU3V2czNhVXNxbGdNRW9XRCsK" 75 | }, 76 | "windows": [ 77 | { 78 | "label": "main", 79 | "title": "Preferences", 80 | "url": "/main/stats", 81 | "fullscreen": false, 82 | "resizable": false, 83 | "visible": false, 84 | "height": 600, 85 | "width": 800, 86 | "transparent": true, 87 | "titleBarStyle": "Overlay", 88 | "hiddenTitle": true 89 | }, 90 | { 91 | "label": "about", 92 | "title": "About", 93 | "url": "/about", 94 | "fullscreen": false, 95 | "center": true, 96 | "resizable": false, 97 | "visible": false, 98 | "height": 300, 99 | "width": 430, 100 | "hiddenTitle": true, 101 | "titleBarStyle": "Overlay" 102 | }, 103 | { 104 | "label": "ack", 105 | "title": "Acknowledgements", 106 | "url": "/ack", 107 | "fullscreen": false, 108 | "center": true, 109 | "resizable": false, 110 | "visible": false, 111 | "height": 400, 112 | "width": 600 113 | }, 114 | { 115 | "label": "license", 116 | "title": "License", 117 | "url": "/license", 118 | "fullscreen": false, 119 | "center": true, 120 | "resizable": false, 121 | "visible": false, 122 | "height": 400, 123 | "width": 600 124 | } 125 | ], 126 | "macOSPrivateApi": true 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src-tauri/tests/configs/allow_missing_skip_dir.yaml: -------------------------------------------------------------------------------- 1 | skips: 2 | - tests/mock_dirs/non_exist -------------------------------------------------------------------------------- /src-tauri/tests/configs/broken_dir.yaml: -------------------------------------------------------------------------------- 1 | directories: 2 | - path: tests/mock_dirs/some_file 3 | rules: [ ] -------------------------------------------------------------------------------- /src-tauri/tests/configs/broken_rule.yaml: -------------------------------------------------------------------------------- 1 | directories: 2 | - path: tests/mock_dirs/path_a 3 | rules: [ "a" ] 4 | rules: 5 | b: 6 | excludes: [ "exclude_b" ] -------------------------------------------------------------------------------- /src-tauri/tests/configs/follow_symlink.yaml: -------------------------------------------------------------------------------- 1 | skips: 2 | - tests/symlinks/three 3 | - tests/symlinks/invalid 4 | - tests/symlinks/cyclic_a -------------------------------------------------------------------------------- /src-tauri/tests/configs/inherit_rule.yaml: -------------------------------------------------------------------------------- 1 | directories: 2 | - path: tests/mock_dirs/path_a 3 | rules: [ "main" ] 4 | - path: tests/mock_dirs/path_b 5 | rules: [ "b", "e" ] 6 | rules: 7 | main: [ "a", "b", "c" ] 8 | a: 9 | excludes: [ "a" ] 10 | b: [ "a", "c", "d" ] 11 | c: 12 | excludes: [ "c" ] 13 | d: 14 | excludes: [ "d" ] 15 | e: 16 | excludes: [ "e" ] -------------------------------------------------------------------------------- /src-tauri/tests/configs/inherit_rule_loop.yaml: -------------------------------------------------------------------------------- 1 | directories: 2 | - path: tests/mock_dirs/path_a 3 | rules: [ "a" ] 4 | rules: 5 | a: [ "b" ] 6 | b: [ "c", "d" ] 7 | c: 8 | excludes: [ "c" ] 9 | d: [ "a" ] -------------------------------------------------------------------------------- /src-tauri/tests/configs/missing_dir.yaml: -------------------------------------------------------------------------------- 1 | directories: 2 | - path: tests/mock_dirs/non_exist 3 | rules: [ ] 4 | -------------------------------------------------------------------------------- /src-tauri/tests/configs/simple.yaml: -------------------------------------------------------------------------------- 1 | no-include: true 2 | directories: 3 | - path: tests/mock_dirs/path_a 4 | rules: [ "rule_a", "rule_b" ] 5 | - path: tests/mock_dirs/path_b 6 | rules: [ "rule_b", "rule_d" ] 7 | skips: 8 | - tests/mock_dirs/path_b 9 | rules: 10 | rule_a: 11 | excludes: [ "exclude_a" ] 12 | rule_b: 13 | excludes: [ "exclude_b" ] 14 | rule_c: 15 | excludes: [ "exclude_c" ] 16 | rule_d: 17 | excludes: [ "exclude_d1", "exclude_d2" ] 18 | if-exists: [ "a", "b" ] -------------------------------------------------------------------------------- /src-tauri/tests/read_config.rs: -------------------------------------------------------------------------------- 1 | // use std::fs; 2 | // 3 | // use tempfile::TempDir; 4 | 5 | // #[test] 6 | // fn must_create_default_when_not_initialized() { 7 | // let mock_home = TempDir::new().unwrap(); 8 | // let config_path = mock_home.path().join(".config/tmexclude.yaml"); 9 | // 10 | // assert_cmd::Command::cargo_bin("tmexclude") 11 | // .unwrap() 12 | // .env("HOME", mock_home.path()) 13 | // .arg("read-config") 14 | // .assert() 15 | // .success(); 16 | // 17 | // assert_eq!( 18 | // fs::read_to_string(config_path).unwrap(), 19 | // include_str!("../../config.example.yaml") 20 | // ); 21 | // } 22 | // 23 | // #[test] 24 | // fn must_not_overwrite_default_if_exists() { 25 | // let mock_home = TempDir::new().unwrap(); 26 | // let config_path = mock_home.path().join(".config/tmexclude.yaml"); 27 | // 28 | // fs::create_dir_all(config_path.parent().unwrap()).unwrap(); 29 | // let modified_config = include_str!("../../config.example.yaml").replace("Library", "Library2"); 30 | // fs::write(&config_path, &modified_config).unwrap(); 31 | // 32 | // assert_cmd::Command::cargo_bin("tmexclude") 33 | // .unwrap() 34 | // .env("HOME", mock_home.path()) 35 | // .arg("read-config") 36 | // .assert() 37 | // .success(); 38 | // 39 | // assert_eq!(fs::read_to_string(config_path).unwrap(), modified_config); 40 | // } 41 | // 42 | // #[test] 43 | // fn must_not_create_if_given_exists() { 44 | // let mock_home = TempDir::new().unwrap(); 45 | // let default_config_path = mock_home.path().join(".config/tmexclude.yaml"); 46 | // let config_path = mock_home.path().join("config.yaml"); 47 | // 48 | // fs::write(&config_path, include_str!("../../config.example.yaml")).unwrap(); 49 | // 50 | // assert_cmd::Command::cargo_bin("tmexclude") 51 | // .unwrap() 52 | // .env("HOME", mock_home.path()) 53 | // .arg("-c") 54 | // .arg(config_path) 55 | // .arg("read-config") 56 | // .assert() 57 | // .success(); 58 | // 59 | // assert!(!default_config_path.exists()); 60 | // } 61 | // 62 | // #[test] 63 | // fn must_not_create_if_given_not_exists() { 64 | // let mock_home = TempDir::new().unwrap(); 65 | // let default_config_path = mock_home.path().join(".config/tmexclude.yaml"); 66 | // let config_path = mock_home.path().join("config.yaml"); 67 | // 68 | // assert_cmd::Command::cargo_bin("tmexclude") 69 | // .unwrap() 70 | // .env("HOME", mock_home.path()) 71 | // .arg("-c") 72 | // .arg(config_path) 73 | // .arg("read-config") 74 | // .assert() 75 | // .failure(); 76 | // 77 | // assert!(!default_config_path.exists()); 78 | // } 79 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import {createBrowserRouter, RouterProvider} from "react-router-dom"; 2 | import {SWRConfig} from "swr"; 3 | import {MantineProvider} from "@mantine/core"; 4 | import {RecoilRoot} from "recoil"; 5 | import {useColorScheme} from "@mantine/hooks"; 6 | import {disableMenu} from "./utils"; 7 | import {MainLayout} from "./pages/main/MainLayout"; 8 | import {Stats} from "./pages/main/Stats"; 9 | import {SyncActionBatch} from "./states"; 10 | import {Directories} from "./pages/main/Directories"; 11 | import {General} from "./pages/main/General"; 12 | import {Scan} from "./pages/main/Scan"; 13 | import {Rules} from "./pages/main/Rules"; 14 | import {About} from "./pages/About"; 15 | import {Ack} from "./pages/Ack"; 16 | import {License} from "./pages/License"; 17 | import {Suspense} from "react"; 18 | 19 | const router = createBrowserRouter([ 20 | { 21 | path: "main", 22 | element: , 23 | children: [ 24 | { 25 | path: "stats", 26 | element: 27 | }, 28 | { 29 | path: "directories", 30 | element: 31 | }, 32 | { 33 | path: "general", 34 | element: 35 | }, 36 | { 37 | path: "rules", 38 | element: 39 | }, 40 | { 41 | path: "scan", 42 | element: , 43 | }, 44 | ] 45 | }, 46 | { 47 | path: "about", 48 | element: 49 | }, 50 | { 51 | path: "ack", 52 | element: 53 | }, 54 | { 55 | path: "license", 56 | element: 57 | } 58 | ]); 59 | 60 | export const App = () => { 61 | const preferredColorScheme = useColorScheme(); 62 | 63 | disableMenu(); 64 | return ( 65 | 72 | ({ 101 | root: { 102 | maxHeight: "100%", 103 | borderStyle: "solid", 104 | borderWidth: "1px", 105 | borderRadius: theme.radius.xs, 106 | borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2] 107 | }, 108 | }) 109 | }, 110 | Button: { 111 | styles: (theme) => ({ 112 | root: { 113 | boxShadow: theme.shadows.xs, 114 | } 115 | }) 116 | }, 117 | Navbar: { 118 | styles: { 119 | root: { 120 | zIndex: 250 121 | } 122 | } 123 | }, 124 | Header: { 125 | styles: { 126 | root: { 127 | zIndex: 251 128 | } 129 | } 130 | } 131 | } 132 | }} 133 | > 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /src/assets/tmexclude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhotonQuantum/tmexclude/8a69d907b6837472db597a2bffc1bdebb365d182/src/assets/tmexclude.png -------------------------------------------------------------------------------- /src/bindings/ApplyErrors.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ApplyErrors { errors: Record, } -------------------------------------------------------------------------------- /src/bindings/BuildMeta.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface BuildMeta { version: string, timestamp: string, } -------------------------------------------------------------------------------- /src/bindings/ExclusionActionBatch.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ExclusionActionBatch { add: Array, remove: Array, } -------------------------------------------------------------------------------- /src/bindings/Metrics.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface Metrics { "files-excluded": number, "files-included": number, "last-excluded": string, "last-excluded-time": number, } -------------------------------------------------------------------------------- /src/bindings/PreConfig.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { PreDirectory } from "./PreDirectory"; 3 | import type { PreRule } from "./PreRule"; 4 | 5 | export interface PreConfig { "no-include": boolean, "support-dump": boolean, directories: Array, skips: Array, rules: Record, } -------------------------------------------------------------------------------- /src/bindings/PreDirectory.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface PreDirectory { path: string, rules: Array, } -------------------------------------------------------------------------------- /src/bindings/PreRule.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { Rule } from "./Rule"; 3 | 4 | export type PreRule = Rule | Array; -------------------------------------------------------------------------------- /src/bindings/Rule.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface Rule { excludes: Array, "if-exists": Array, } -------------------------------------------------------------------------------- /src/bindings/ScanStatus.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { ExclusionActionBatch } from "./ExclusionActionBatch"; 3 | 4 | export type ScanStatus = { step: "idle" } | { step: "scanning", content: { current_path: string, found: number, } } | { step: "result", content: ExclusionActionBatch }; -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import {PreConfig} from "./bindings/PreConfig"; 2 | import {ScanStatus} from "./bindings/ScanStatus"; 3 | import {ExclusionActionBatch} from "./bindings/ExclusionActionBatch"; 4 | import {InvokeArgs} from "@tauri-apps/api/tauri"; 5 | 6 | const invoke = async (cmd: string, args?: InvokeArgs) => { 7 | if (typeof window === "undefined") { 8 | return null; 9 | } 10 | const _invoke = await import("@tauri-apps/api").then((api) => api.invoke); 11 | return await _invoke(cmd, args); 12 | }; 13 | 14 | export const enableAutoStart = async () => { 15 | return await invoke('plugin:auto_launch|enable') 16 | } 17 | 18 | export const disableAutoStart = async () => { 19 | return await invoke('plugin:auto_launch|disable') 20 | } 21 | 22 | export const getAutoStart = async () => { 23 | return await invoke('plugin:auto_launch|is_enabled') ?? false; 24 | } 25 | 26 | export const getStore = async (key: string) => { 27 | return await invoke("store_get", {key}); 28 | } 29 | 30 | export const setStore = async (key: string, value: any) => { 31 | return await invoke("store_set", {key, value}); 32 | } 33 | 34 | export const getConfig = async () => { 35 | return await invoke("get_config"); 36 | } 37 | 38 | export const setConfig = async (config: PreConfig) => { 39 | return await invoke("set_config", {config}); 40 | } 41 | 42 | export const scanStatus = async () => { 43 | return await invoke("scan_status") ?? {step: "idle"} as ScanStatus; 44 | } 45 | 46 | export const startFullScan = async () => { 47 | return await invoke("start_full_scan"); 48 | } 49 | 50 | export const stopFullScan = async () => { 51 | return await invoke("stop_full_scan"); 52 | } 53 | 54 | export const applyActionBatch = async (batch: ExclusionActionBatch) => { 55 | return await invoke("apply_action_batch", {batch}); 56 | } -------------------------------------------------------------------------------- /src/components/PathText.tsx: -------------------------------------------------------------------------------- 1 | import {Text, TextProps, Tooltip} from "@mantine/core"; 2 | import {useIsOverflow, useTruncatedPath} from "../utils"; 3 | import {useMergedRef} from "@mantine/hooks"; 4 | 5 | export interface PathTextProps extends TextProps { 6 | keepFirst: number; 7 | keepLast: number; 8 | ref?: any; 9 | withinPortal?: boolean; 10 | path: string; 11 | } 12 | 13 | export const PathText = ({ 14 | keepFirst, 15 | keepLast, 16 | path, 17 | ref, 18 | withinPortal, 19 | ...props 20 | }: PathTextProps) => { 21 | const { 22 | ref: overflowRef, 23 | isOverflow 24 | } = useIsOverflow(); 25 | const mergedRef = useMergedRef(ref, overflowRef); 26 | const [truncated, truncatedPath] = useTruncatedPath(path, keepFirst, keepLast); 27 | return ( 28 | {truncatedPath} 29 | ) 30 | } -------------------------------------------------------------------------------- /src/components/RuleItem.tsx: -------------------------------------------------------------------------------- 1 | import {Accordion, ActionIcon, Group, Menu, MultiSelect, SegmentedControl, Stack, Text, TextInput} from "@mantine/core"; 2 | import {IconDots, IconPencil, IconTrash} from "@tabler/icons"; 3 | import {useSetRecoilState} from "recoil"; 4 | import {perRuleState, rulesState} from "../states"; 5 | import React, {useState} from "react"; 6 | import {PreRule} from "../bindings/PreRule"; 7 | import _ from "lodash"; 8 | import {useTranslation} from "react-i18next"; 9 | 10 | type RuleItemProps = { 11 | name: string, value: PreRule, allPaths: string[], ruleNames: string[], 12 | } 13 | 14 | export const RuleItem = React.memo(({ 15 | name, 16 | value, 17 | allPaths, 18 | ruleNames 19 | }: RuleItemProps) => { 20 | const {t} = useTranslation(); 21 | 22 | const setRules = useSetRecoilState(rulesState); 23 | const setValue = useSetRecoilState(perRuleState(name)); 24 | const [renaming, setRenaming] = useState(false); 25 | const [newName, setNewName] = useState(""); 26 | const [prev, setPrev] = useState(null); 27 | 28 | const startRename = (name: string) => { 29 | setNewName(name); 30 | setRenaming(true); 31 | }; 32 | const validateName = (name: string) => { 33 | return name.length > 0 && !ruleNames.includes(name); 34 | } 35 | const finishRename = () => { 36 | if (newName !== name && validateName(newName)) { 37 | setRules((rules) => { 38 | let newRules = { 39 | ...rules, 40 | [newName.trim()]: rules[name] 41 | }; 42 | delete newRules[name]; 43 | return newRules 44 | }); 45 | return true; 46 | } 47 | return false; 48 | }; 49 | const deleteRule = () => { 50 | setRules((rules) => { 51 | let newRules = {...rules}; 52 | delete newRules[name]; 53 | return newRules 54 | }); 55 | }; 56 | const switchRuleType = (type: "merge" | "concrete") => { 57 | const getRuleType = (rule: PreRule) => Array.isArray(rule) ? "merge" : "concrete"; 58 | if (getRuleType(value) !== type) { 59 | if (prev !== null && getRuleType(prev) === type) { 60 | let prevValue = prev; 61 | setPrev(value); 62 | setValue(prevValue); 63 | } else { 64 | setPrev(value); 65 | if (type === "merge") { 66 | setValue([]); 67 | } else { 68 | setValue({ 69 | excludes: [], 70 | "if-exists": [] 71 | }); 72 | } 73 | } 74 | } 75 | }; 76 | 77 | return ( 78 | 79 | 80 | {renaming ? setNewName(e.currentTarget.value)} 86 | onKeyDown={(e) => { 87 | if (e.key === "Enter") { 88 | if (finishRename()) { 89 | setRenaming(false); 90 | e.preventDefault(); 91 | } 92 | } else if (e.key == "Escape") { 93 | setRenaming(false); 94 | } 95 | }} 96 | onBlur={() => { 97 | finishRename(); 98 | setRenaming(false); 99 | }} 100 | /> : {name}} 101 | 102 | 103 | 104 | 105 | 106 | 107 | } onClick={() => startRename(name)}>{t('rename')} 108 | } 110 | onClick={deleteRule}>{t('delete')} 111 | 112 | 113 | 114 | 115 | 116 | switchRuleType(e as "merge" | "concrete")} 127 | /> 128 | {Array.isArray(value) ? ( k !== name)} 131 | value={value} 132 | onChange={(newMergeRule) => { 133 | setValue(newMergeRule); 134 | }} 135 | placeholder={t("pick_merge_rules")!} 136 | />) : (<> 137 | {t('paths_to_exclude')} 138 | `+ New ${value}`} 140 | data={allPaths.map((v) => ({ 141 | value: v, 142 | label: v 143 | }))} 144 | value={value.excludes} 145 | onChange={(newExcludes) => { 146 | setValue({ 147 | excludes: newExcludes, 148 | "if-exists": value["if-exists"] 149 | }); 150 | }} 151 | /> 152 | 153 | {t('only_if_any_of_these_paths_exists_in_the_same_dire')} 154 | 155 | `+ New ${value}`} 157 | data={allPaths.map((v) => ({ 158 | value: v, 159 | label: v 160 | }))} 161 | value={value["if-exists"]} 162 | onChange={(newIfExists) => { 163 | setValue({ 164 | excludes: value.excludes, 165 | "if-exists": newIfExists 166 | }); 167 | }} 168 | /> 169 | )} 170 | 171 | 172 | ) 173 | }, _.isEqual); -------------------------------------------------------------------------------- /src/components/SelectionTable.tsx: -------------------------------------------------------------------------------- 1 | import {Checkbox, packSx, ScrollArea, ScrollAreaProps, Sx, Table, TextInput} from "@mantine/core"; 2 | import React, {useEffect, useMemo, useState} from "react"; 3 | import {PathText} from "./PathText"; 4 | import {useTableStyles} from "../utils"; 5 | import {useTranslation} from "react-i18next"; 6 | 7 | export interface SelectionTableProps extends Omit { 8 | data: Array, 9 | limit: number, 10 | selection: Array, 11 | onTruncated: (c: number | null) => void, 12 | sx?: Sx | Sx[], 13 | onChange: React.Dispatch>>, 14 | } 15 | 16 | export const SelectionTable = React.memo(({ 17 | data, 18 | selection, 19 | limit, 20 | onTruncated, 21 | onChange, 22 | sx, 23 | ...props 24 | }: SelectionTableProps) => { 25 | const {classes, cx} = useTableStyles(); 26 | const {t} = useTranslation(); 27 | 28 | const allSelected = selection.length === data.length; 29 | const toggleAll = () => { 30 | onChange(allSelected ? [] : data) 31 | } 32 | const toggle = useMemo(() => (item: string) => { 33 | onChange((sel) => sel.includes(item) ? sel.filter(i => i !== item) : [...sel, item]) 34 | }, []); 35 | 36 | const [filter, setFilter] = useState(""); 37 | const filtered = data.filter(i => i.toLowerCase().includes(filter.toLowerCase())); 38 | 39 | useEffect(() => { 40 | onTruncated(filtered.length > limit ? filtered.length : null); 41 | }, [filtered, limit]); 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 56 | 64 | 65 | 66 | 67 | {filtered.slice(0, limit).map(item => { 68 | const selected = selection.includes(item); 69 | return () 70 | })} 71 | 72 |
49 | 0 && !allSelected} 53 | transitionDuration={0} 54 | /> 55 | 57 | setFilter(ev.currentTarget.value)} 61 | placeholder={t("filter")!} 62 | /> 63 |
73 |
74 | ) 75 | }); 76 | 77 | type SelectionRowProps = { 78 | selected: boolean, 79 | item: string, 80 | onToggle: (item: string) => void 81 | } 82 | 83 | const SelectionRow = React.memo(({selected, item, onToggle}: SelectionRowProps) => { 84 | const {classes, cx} = useTableStyles(); 85 | 86 | return ( 87 | 88 | 89 | onToggle(item)} transitionDuration={0}/> 91 | 92 | 93 | 94 | 95 | 96 | ); 97 | }); 98 | 99 | -------------------------------------------------------------------------------- /src/components/TipText.tsx: -------------------------------------------------------------------------------- 1 | import {Text, TextProps, Tooltip} from "@mantine/core"; 2 | import {useIsOverflow} from "../utils"; 3 | import {useMergedRef} from "@mantine/hooks"; 4 | 5 | export interface TipTextProps extends TextProps { 6 | ref?: any; 7 | withinPortal?: boolean 8 | } 9 | 10 | export const TipText = ({ 11 | ref, 12 | children, 13 | withinPortal, 14 | ...props 15 | }: TipTextProps) => { 16 | const { 17 | ref: overflowRef, 18 | isOverflow 19 | } = useIsOverflow(); 20 | const mergedRef = useMergedRef(ref, overflowRef); 21 | return ( 22 | {children} 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/equalSelector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadOnlySelectorFamilyOptions, 3 | ReadOnlySelectorOptions, 4 | ReadWriteSelectorFamilyOptions, 5 | ReadWriteSelectorOptions, 6 | RecoilState, 7 | RecoilValueReadOnly, 8 | selector, 9 | selectorFamily, 10 | SerializableParam 11 | } from "recoil"; 12 | 13 | interface EqualSelectorOptions extends Pick, "key" | "get">, 14 | Partial, "set">> { 15 | equals: (a: T, b: T) => boolean; 16 | } 17 | 18 | export function equalSelector(options: EqualSelectorOptions): RecoilState | RecoilValueReadOnly { 19 | const inner = selector({ 20 | key: `${options.key}_inner`, 21 | get: options.get 22 | }); 23 | 24 | let prior: T | undefined; 25 | 26 | return selector({ 27 | key: options.key, 28 | get: ({get}) => { 29 | const latest = get(inner); 30 | if (prior != null && options.equals(latest, prior)) { 31 | return prior; 32 | } 33 | prior = latest; 34 | return latest as T; 35 | }, ...options.set ? {set: options.set} : {} 36 | }); 37 | } 38 | 39 | interface ReadWriteEqualSelectorFamilyOptions 40 | extends Pick, "key" | "get" | "set"> { 41 | equals: (a: T, b: T) => boolean; 42 | } 43 | 44 | interface ReadOnlyEqualSelectorFamilyOptions 45 | extends Pick, "key" | "get"> { 46 | equals: (a: T, b: T) => boolean; 47 | } 48 | 49 | export function equalSelectorFamily(options: ReadWriteEqualSelectorFamilyOptions): (param: P) => RecoilState; 50 | export function equalSelectorFamily(options: ReadOnlyEqualSelectorFamilyOptions): (param: P) => RecoilValueReadOnly; 51 | 52 | export function equalSelectorFamily(options: ReadOnlyEqualSelectorFamilyOptions | ReadWriteEqualSelectorFamilyOptions): (param: P) => RecoilValueReadOnly | RecoilState { 53 | const inner = selectorFamily({ 54 | key: `${options.key}_inner`, 55 | get: options.get 56 | }); 57 | 58 | const prior = new Map(); 59 | 60 | return selectorFamily({ 61 | key: options.key, 62 | get: (param) => ({get}) => { 63 | const latest = get(inner(param)); 64 | if (prior.has(param) && options.equals(latest, prior.get(param)!)) { 65 | return prior.get(param)!; 66 | } 67 | prior.set(param, latest); 68 | return latest as T; 69 | }, ...'set' in options ? {set: options.set} : {} 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /src/i18n.tsx: -------------------------------------------------------------------------------- 1 | import i18n, {LanguageDetectorAsyncModule, TFunction} from "i18next"; 2 | import {initReactI18next} from "react-i18next"; 3 | import en from "./i18n/en.json"; 4 | import zh_hans from "./i18n/zh_hans.json"; 5 | // @ts-ignore 6 | import zh_CN_strings from "react-timeago/lib/language-strings/zh-CN"; 7 | // @ts-ignore 8 | import buildFormatter from "react-timeago/lib/formatters/buildFormatter"; 9 | import {getStore} from "./commands"; 10 | 11 | export const codeToDisplayName = (languageCode: string, t: TFunction) => { 12 | if (languageCode === "auto") { 13 | return t("lang_auto") 14 | } 15 | const intl_display = new Intl.DisplayNames([languageCode], {type: "language"}); 16 | return intl_display.of(languageCode); 17 | } 18 | 19 | const supported = ["en", "zh-Hans"]; 20 | 21 | export const availableLanguages = (t: TFunction) => (["auto", ...supported].map(code => ({ 22 | value: code, label: codeToDisplayName(code, t) 23 | }))); 24 | 25 | export const zh_CN_formatter = buildFormatter(zh_CN_strings); 26 | 27 | const storeDetector: LanguageDetectorAsyncModule = { 28 | type: "languageDetector", 29 | async: true, 30 | detect: async () => { 31 | const language = await getStore("language") ?? "auto"; 32 | if (language === "auto") { 33 | return navigator.languages 34 | } 35 | return language; 36 | } 37 | } 38 | 39 | i18n 40 | .use(storeDetector) 41 | .use(initReactI18next) 42 | .init({ 43 | fallbackLng: { 44 | "zh-CN": ["zh-Hans", "en"], 45 | default: ["en"] 46 | }, 47 | interpolation: { 48 | escapeValue: false 49 | }, 50 | resources: { 51 | en: { 52 | translation: en 53 | }, 54 | "zh-Hans": { 55 | translation: zh_hans 56 | } 57 | } 58 | }); 59 | 60 | export default i18n; -------------------------------------------------------------------------------- /src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "overview": "Overview", 3 | "statistics": "Statistics", 4 | "settings": "Settings", 5 | "general": "General", 6 | "rules": "Rules", 7 | "directories": "Directories", 8 | "actions": "Actions", 9 | "scan": "Scan", 10 | "contact_me": "Contact Me", 11 | "build": "Build {{version}}, built on {{date}}", 12 | "package": "Package", 13 | "license": "License", 14 | "applying_changes": "Applying changes...", 15 | "setting_file_attributes": "Setting file attributes", 16 | "truncated": "Truncated", 17 | "back": "Back", 18 | "files_to_be_excluded": "Files to be excluded", 19 | "files_to_be_included": "Files to be included", 20 | "showing_n_rows": "Showing 100/{{count}} rows, please refine your search.", 21 | "remove_directory": "Remove Directory", 22 | "directories_to_watch_and_scan": "Directories to watch and scan", 23 | "skip_the_following_paths": "Skip the following paths", 24 | "restart": "Restart", 25 | "apply_complete": "Apply Complete", 26 | "applied": "applied", 27 | "selected_items_has_been_excludedincluded_in_timema": "Selected items has been excluded/included in TimeMachine backups.", 28 | "some_items_failed_to_be_excludedincluded_in_timema": "Some items failed to be excluded/included in TimeMachine backups.", 29 | "show_log": "Show log", 30 | "start_at_login": "Start at Login", 31 | "ignore_included_files": "Ignore excluded files", 32 | "dont_include_files_into_backups_even_if_they_dont": "Don't re-include files into backups even if they don't match the rules.", 33 | "preference": "Preference", 34 | "scanning_system": "Scanning system...", 35 | "found_files_one": "Found {{count}} file", 36 | "found_files_other": "Found {{count}} files", 37 | "apply_log": "Apply Log", 38 | "failed": "Failed", 39 | "hide_log": "Hide log", 40 | "scan_complete": "Scan Complete", 41 | "items_one": "{{count}} item", 42 | "items_other": "{{count}} items", 43 | "selected": "selected", 44 | "view_items": "View items", 45 | "items_found_one": "{{count}} item found", 46 | "items_found_other": "{{count}} items found", 47 | "everything_looks_good_no_files_need_to_be_excluded": "Everything looks good! No files need to be excluded.", 48 | "rename": "Rename", 49 | "delete": "Delete", 50 | "paths_to_exclude": "Paths to exclude", 51 | "only_if_any_of_these_paths_exists_in_the_same_dire": "... only if any of these paths exists in the same directory", 52 | "add_rule": "Add Rule", 53 | "exclude_paths_that_match_these_patterns": "Exclude paths that match these patterns", 54 | "patterns_must_be_applied_by_at_least_one_directory": "Patterns must be applied by at least one directory to take effect.", 55 | "looks_good": "Looks good!", 56 | "timemachine_exclude_is_running": "TimeMachine Exclude is running.", 57 | "files_one": "{{count}} File", 58 | "files_other": "{{count}} Files", 59 | "was_excluded": "was excluded <1/>", 60 | "run_a_manual_scan": "Run a manual scan", 61 | "run_an_initial_full_scan_after_setup": "Run an initial full scan after setup.", 62 | "resync_file_changes_if_incremental_scans_fail": "Re-sync file changes if incremental scans fail.", 63 | "licensed_under": "Licensed under <1>MIT License", 64 | "powered_by": "Powered by <1>open-source software", 65 | "have_been_excluded_from_timemachine_backups": "have been excluded from TimeMachine backups", 66 | "have_been_reincluded_into_timemachine_backups": "have been re-included into TimeMachine backups", 67 | "no_files_have_been_excluded_yet": "no files have been excluded yet", 68 | "rule_name": "Rule name", 69 | "filter": "Filter...", 70 | "apply": "Apply", 71 | "exclude_include_selected_files_from_timemachine_backups": "Exclude/include selected files from TimeMachine backups", 72 | "reset": "Reset", 73 | "save": "Save", 74 | "pick_merge_rules": "Pick all sub-rules to merge", 75 | "merge_rule": "Merge Rule", 76 | "concrete_rule": "Concrete Rule", 77 | "language": "Language:", 78 | "lang_auto": "Use system defaults", 79 | "support_dump_title": "NODUMP flag", 80 | "support_dump_desc": "Add NODUMP flag to excluded files. This flag adds support for DUMP(8) and BorgBackup." 81 | } -------------------------------------------------------------------------------- /src/i18n/zh_hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "overview": "概览", 3 | "statistics": "统计", 4 | "settings": "设置", 5 | "general": "常规", 6 | "rules": "规则", 7 | "directories": "目录", 8 | "actions": "动作", 9 | "scan": "扫描", 10 | "contact_me": "联系我", 11 | "build": "构建 {{version}}, 于 {{date, datetime}} 构建", 12 | "package": "软件包", 13 | "license": "许可", 14 | "applying_changes": "正在应用更改...", 15 | "setting_file_attributes": "正在设置文件属性...", 16 | "truncated": "已截断", 17 | "back": "返回", 18 | "files_to_be_excluded": "要排除的文件", 19 | "files_to_be_included": "要包含的文件", 20 | "showing_n_rows": "仅显示 100/{{count}} 行,请使用过滤器缩小范围", 21 | "remove_directory": "移除目录", 22 | "directories_to_watch_and_scan": "要监视和扫描的目录", 23 | "skip_the_following_paths": "跳过以下路径", 24 | "restart": "重新开始", 25 | "apply_complete": "应用完成", 26 | "applied": "已应用", 27 | "selected_items_has_been_excludedincluded_in_timema": "已将选定的项目排除/包含在 TimeMachine 备份中", 28 | "some_items_failed_to_be_excludedincluded_in_timema": "有些项目无法被排除/包含在 TimeMachine 备份中。", 29 | "show_log": "显示日志", 30 | "start_at_login": "登录时启动", 31 | "ignore_included_files": "忽略已被排除的文件", 32 | "dont_include_files_into_backups_even_if_they_dont": "如果有不符合排除规则的文件已被排除于备份外,不要将这些文件重新包含到备份中。", 33 | "preference": "偏好", 34 | "scanning_system": "正在扫描系统...", 35 | "found_files": "找到 {{count}} 个文件", 36 | "apply_log": "应用日志", 37 | "failed": "失败", 38 | "hide_log": "隐藏日志", 39 | "scan_complete": "扫描完成", 40 | "items": "{{count}} 项", 41 | "selected": "已选中", 42 | "view_items": "查看项目", 43 | "items_found": "找到 {{count}} 项", 44 | "everything_looks_good_no_files_need_to_be_excluded": "看起来不错! 没有文件需要被排除。", 45 | "rename": "重命名", 46 | "delete": "删除", 47 | "paths_to_exclude": "要排除的路径", 48 | "only_if_any_of_these_paths_exists_in_the_same_dire": "... 只有当这些路径中的任何一个存在于同一目录中时,才会排除", 49 | "add_rule": "添加规则", 50 | "exclude_paths_that_match_these_patterns": "排除与这些模式匹配的路径", 51 | "patterns_must_be_applied_by_at_least_one_directory": "模式必须至少由一个目录应用才能生效。", 52 | "looks_good": "看起来不错!", 53 | "timemachine_exclude_is_running": "TimeMachine Exclude 正在运行。", 54 | "files": "{{count}} 个文件", 55 | "was_excluded": "在 <1/> 被排除", 56 | "run_a_manual_scan": "运行手动扫描", 57 | "run_an_initial_full_scan_after_setup": "在安装后运行初始完整扫描", 58 | "resync_file_changes_if_incremental_scans_fail": "如果增量扫描失败,重新同步文件更改", 59 | "licensed_under": "遵循 <1>MIT 许可", 60 | "powered_by": "由 <1>开源软件 驱动", 61 | "have_been_excluded_from_timemachine_backups": "已被排除在 TimeMachine 备份外", 62 | "have_been_reincluded_into_timemachine_backups": "已被重新包含在 TimeMachine 备份中", 63 | "no_files_have_been_excluded_yet": "还没有文件被排除", 64 | "rule_name": "规则名", 65 | "filter": "过滤...", 66 | "apply": "应用更改", 67 | "exclude_include_selected_files_from_timemachine_backups": "从 TimeMachine 备份中排除/包含选定的文件", 68 | "reset": "重置", 69 | "save": "保存", 70 | "pick_merge_rules": "选择要合并的规则", 71 | "merge_rule": "合并规则", 72 | "concrete_rule": "具体规则", 73 | "language": "语言 (Language):", 74 | "lang_auto": "使用系统默认 (Use system defaults)", 75 | "support_dump_title": "NODUMP 标记", 76 | "support_dump_desc": "给排除的文件添加 NODUMP 标记。启用这个选项将提供 DUMP(8) 和 BorgBackup 支持。" 77 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TimeMachine Exclude 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import {createRoot} from "react-dom/client"; 2 | import {StrictMode} from "react"; 3 | import {App} from "./App"; 4 | import "./i18n"; 5 | 6 | const container = document.getElementById('root'); 7 | const root = createRoot(container!); 8 | root.render( 9 | 10 | 11 | 12 | ) -------------------------------------------------------------------------------- /src/licenses.ts: -------------------------------------------------------------------------------- 1 | // noinspection AllyPlainJsInspection 2 | 3 | export type License = { 4 | name: string; 5 | version?: string; 6 | repository: string | null; 7 | license: string; 8 | }; 9 | export const cargoLicenses: Array = [ 10 | { 11 | "name": "arc-swap", 12 | "repository": "https://github.com/vorner/arc-swap", 13 | "license": "Apache-2.0 OR MIT", 14 | "version": "1.5.1" 15 | }, 16 | { 17 | "name": "assert_cmd", 18 | "repository": "https://github.com/assert-rs/assert_cmd.git", 19 | "license": "Apache-2.0 OR MIT", 20 | "version": "2.0.7" 21 | }, 22 | { 23 | "name": "auto-launch", 24 | "repository": "https://github.com/zzzgydi/auto-launch.git", 25 | "license": "MIT", 26 | "version": "0.4.0" 27 | }, 28 | { 29 | "name": "cocoa", 30 | "repository": "https://github.com/servo/core-foundation-rs", 31 | "license": "Apache-2.0 OR MIT", 32 | "version": "0.24.1" 33 | }, 34 | { 35 | "name": "core-foundation", 36 | "repository": "https://github.com/servo/core-foundation-rs", 37 | "license": "Apache-2.0 OR MIT", 38 | "version": "0.9.3" 39 | }, 40 | { 41 | "name": "crossbeam", 42 | "repository": "https://github.com/crossbeam-rs/crossbeam", 43 | "license": "Apache-2.0 OR MIT", 44 | "version": "0.8.2" 45 | }, 46 | { 47 | "name": "directories", 48 | "repository": "https://github.com/soc/directories-rs", 49 | "license": "Apache-2.0 OR MIT", 50 | "version": "4.0.1" 51 | }, 52 | { 53 | "name": "eyre", 54 | "repository": "https://github.com/yaahc/eyre", 55 | "license": "Apache-2.0 OR MIT", 56 | "version": "0.6.8" 57 | }, 58 | { 59 | "name": "fsevent-stream", 60 | "repository": "https://github.com/PhotonQuantum/fsevent-stream", 61 | "license": "MIT", 62 | "version": "0.2.3" 63 | }, 64 | { 65 | "name": "futures", 66 | "repository": "https://github.com/rust-lang/futures-rs", 67 | "license": "Apache-2.0 OR MIT", 68 | "version": "0.3.25" 69 | }, 70 | { 71 | "name": "itertools", 72 | "repository": "https://github.com/rust-itertools/itertools", 73 | "license": "Apache-2.0 OR MIT", 74 | "version": "0.10.5" 75 | }, 76 | { 77 | "name": "jwalk", 78 | "repository": "https://github.com/jessegrosjean/jwalk", 79 | "license": "MIT", 80 | "version": "0.6.0" 81 | }, 82 | { 83 | "name": "maplit", 84 | "repository": "https://github.com/bluss/maplit", 85 | "license": "Apache-2.0 OR MIT", 86 | "version": "1.0.2" 87 | }, 88 | { 89 | "name": "moka", 90 | "repository": "https://github.com/moka-rs/moka", 91 | "license": "Apache-2.0 OR MIT", 92 | "version": "0.9.6" 93 | }, 94 | { 95 | "name": "objc", 96 | "repository": "http://github.com/SSheldon/rust-objc", 97 | "license": "MIT", 98 | "version": "0.2.7" 99 | }, 100 | { 101 | "name": "once_cell", 102 | "repository": "https://github.com/matklad/once_cell", 103 | "license": "Apache-2.0 OR MIT", 104 | "version": "1.16.0" 105 | }, 106 | { 107 | "name": "parking_lot", 108 | "repository": "https://github.com/Amanieu/parking_lot", 109 | "license": "Apache-2.0 OR MIT", 110 | "version": "0.12.1" 111 | }, 112 | { 113 | "name": "regex", 114 | "repository": "https://github.com/rust-lang/regex", 115 | "license": "Apache-2.0 OR MIT", 116 | "version": "1.7.0" 117 | }, 118 | { 119 | "name": "sentry", 120 | "repository": "https://github.com/getsentry/sentry-rust", 121 | "license": "Apache-2.0", 122 | "version": "0.29.1" 123 | }, 124 | { 125 | "name": "serde", 126 | "repository": "https://github.com/serde-rs/serde", 127 | "license": "Apache-2.0 OR MIT", 128 | "version": "1.0.149" 129 | }, 130 | { 131 | "name": "serde_json", 132 | "repository": "https://github.com/serde-rs/json", 133 | "license": "Apache-2.0 OR MIT", 134 | "version": "1.0.89" 135 | }, 136 | { 137 | "name": "serde_yaml", 138 | "repository": "https://github.com/dtolnay/serde-yaml", 139 | "license": "Apache-2.0 OR MIT", 140 | "version": "0.9.14" 141 | }, 142 | { 143 | "name": "shellexpand", 144 | "repository": "https://gitlab.com/ijackson/rust-shellexpand", 145 | "license": "Apache-2.0 OR MIT", 146 | "version": "3.0.0" 147 | }, 148 | { 149 | "name": "tap", 150 | "repository": "https://github.com/myrrlyn/tap", 151 | "license": "MIT", 152 | "version": "1.0.1" 153 | }, 154 | { 155 | "name": "tauri", 156 | "repository": "https://github.com/tauri-apps/tauri", 157 | "license": "Apache-2.0 OR MIT", 158 | "version": "1.2.1" 159 | }, 160 | { 161 | "name": "tauri-build", 162 | "repository": "https://github.com/tauri-apps/tauri/tree/dev/core/tauri-build", 163 | "license": "Apache-2.0 OR MIT", 164 | "version": "1.2.1" 165 | }, 166 | { 167 | "name": "tempfile", 168 | "repository": "https://github.com/Stebalien/tempfile", 169 | "license": "Apache-2.0 OR MIT", 170 | "version": "3.3.0" 171 | }, 172 | { 173 | "name": "thiserror", 174 | "repository": "https://github.com/dtolnay/thiserror", 175 | "license": "Apache-2.0 OR MIT", 176 | "version": "1.0.37" 177 | }, 178 | { 179 | "name": "tracing", 180 | "repository": "https://github.com/tokio-rs/tracing", 181 | "license": "MIT", 182 | "version": "0.1.37" 183 | }, 184 | { 185 | "name": "tracing-subscriber", 186 | "repository": "https://github.com/tokio-rs/tracing", 187 | "license": "MIT", 188 | "version": "0.3.16" 189 | }, 190 | { 191 | "name": "ts-rs", 192 | "repository": "https://github.com/Aleph-Alpha/ts-rs", 193 | "license": "MIT", 194 | "version": "6.2.0" 195 | }, 196 | { 197 | "name": "vergen", 198 | "repository": "https://github.com/rustyhorde/vergen", 199 | "license": "Apache-2.0 OR MIT", 200 | "version": "7.4.3" 201 | }, 202 | { 203 | "name": "window-vibrancy", 204 | "repository": "https://github.com/tauri-apps/tauri-plugin-vibrancy", 205 | "license": "Apache-2.0 OR MIT", 206 | "version": "0.3.2" 207 | }, 208 | { 209 | "name": "xattr", 210 | "repository": "https://github.com/Stebalien/xattr", 211 | "license": "Apache-2.0 OR MIT", 212 | "version": "0.2.3" 213 | } 214 | ]; 215 | export const npmLicenses: Array = [ 216 | { 217 | "name": "@emotion/react@11.10.5", 218 | "repository": "https://github.com/emotion-js/emotion/tree/main/packages/react", 219 | "license": "MIT" 220 | }, 221 | { 222 | "name": "@emotion/server@11.10.0", 223 | "repository": "https://github.com/emotion-js/emotion/tree/main/packages/server", 224 | "license": "MIT" 225 | }, 226 | { 227 | "name": "@emotion/styled@11.10.5", 228 | "repository": "https://github.com/emotion-js/emotion/tree/main/packages/styled", 229 | "license": "MIT" 230 | }, 231 | { 232 | "name": "@mantine/core@5.9.2", 233 | "repository": "https://github.com/mantinedev/mantine", 234 | "license": "MIT" 235 | }, 236 | { 237 | "name": "@mantine/hooks@5.9.2", 238 | "repository": "https://github.com/mantinedev/mantine", 239 | "license": "MIT" 240 | }, 241 | { 242 | "name": "@mantine/notifications@5.9.2", 243 | "repository": "https://github.com/mantinedev/mantine", 244 | "license": "MIT" 245 | }, 246 | { 247 | "name": "@mantine/utils@5.9.2", 248 | "repository": "https://github.com/mantinedev/mantine", 249 | "license": "MIT" 250 | }, 251 | { 252 | "name": "@parcel/config-default@2.8.1", 253 | "repository": "https://github.com/parcel-bundler/parcel", 254 | "license": "MIT" 255 | }, 256 | { 257 | "name": "@parcel/transformer-inline-string@2.8.1", 258 | "repository": "https://github.com/parcel-bundler/parcel", 259 | "license": "MIT" 260 | }, 261 | { 262 | "name": "@tabler/icons@1.116.1", 263 | "repository": "https://github.com/tabler/tabler-icons", 264 | "license": "MIT" 265 | }, 266 | { 267 | "name": "@tauri-apps/api@1.2.0", 268 | "repository": "https://github.com/tauri-apps/tauri", 269 | "license": "Apache-2.0 OR MIT" 270 | }, 271 | { 272 | "name": "@tauri-apps/cli@1.2.1", 273 | "repository": "https://github.com/tauri-apps/tauri", 274 | "license": "Apache-2.0 OR MIT" 275 | }, 276 | { 277 | "name": "@types/lodash@4.14.191", 278 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 279 | "license": "MIT" 280 | }, 281 | { 282 | "name": "@types/node@18.11.11", 283 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 284 | "license": "MIT" 285 | }, 286 | { 287 | "name": "@types/react-dom@18.0.9", 288 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 289 | "license": "MIT" 290 | }, 291 | { 292 | "name": "@types/react-router-dom@5.3.3", 293 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 294 | "license": "MIT" 295 | }, 296 | { 297 | "name": "@types/react-timeago@4.1.3", 298 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 299 | "license": "MIT" 300 | }, 301 | { 302 | "name": "@types/react@18.0.26", 303 | "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped", 304 | "license": "MIT" 305 | }, 306 | { 307 | "name": "framer-motion@7.6.19", 308 | "repository": "https://github.com/framer/motion", 309 | "license": "MIT" 310 | }, 311 | { 312 | "name": "i18next@22.4.2", 313 | "repository": "https://github.com/i18next/i18next", 314 | "license": "MIT" 315 | }, 316 | { 317 | "name": "lodash@4.17.21", 318 | "repository": "https://github.com/lodash/lodash", 319 | "license": "MIT" 320 | }, 321 | { 322 | "name": "parcel@2.8.1", 323 | "repository": "https://github.com/parcel-bundler/parcel", 324 | "license": "MIT" 325 | }, 326 | { 327 | "name": "process@0.11.10", 328 | "repository": "https://github.com/shtylman/node-process", 329 | "license": "MIT" 330 | }, 331 | { 332 | "name": "react-dom@18.2.0", 333 | "repository": "https://github.com/facebook/react", 334 | "license": "MIT" 335 | }, 336 | { 337 | "name": "react-i18next@12.1.1", 338 | "repository": "https://github.com/i18next/react-i18next", 339 | "license": "MIT" 340 | }, 341 | { 342 | "name": "react-router-dom@6.4.5", 343 | "repository": "https://github.com/remix-run/react-router", 344 | "license": "MIT" 345 | }, 346 | { 347 | "name": "react-timeago@7.1.0", 348 | "repository": "https://github.com/naman34/react-timeago", 349 | "license": "MIT" 350 | }, 351 | { 352 | "name": "react@18.2.0", 353 | "repository": "https://github.com/facebook/react", 354 | "license": "MIT" 355 | }, 356 | { 357 | "name": "recoil@0.7.6", 358 | "repository": "https://github.com/facebookexperimental/Recoil", 359 | "license": "MIT" 360 | }, 361 | { 362 | "name": "swr@1.3.0", 363 | "repository": "https://github.com/vercel/swr", 364 | "license": "MIT" 365 | }, 366 | { 367 | "name": "typescript@4.9.4", 368 | "repository": "https://github.com/Microsoft/TypeScript", 369 | "license": "Apache-2.0" 370 | } 371 | ]; 372 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import {evDrag, swrFetcher} from "../utils"; 3 | import {BuildMeta} from "../bindings/BuildMeta"; 4 | import {Box, Container, Divider, Group, Image, Stack, Text, Title} from "@mantine/core"; 5 | // @ts-ignore 6 | import icon from "../assets/tmexclude.png"; 7 | import {Trans, useTranslation} from "react-i18next"; 8 | 9 | export const About = () => { 10 | const {t} = useTranslation(); 11 | 12 | const {data} = useSWR("build_meta", swrFetcher); 13 | const date = new Date(data?.timestamp ?? 0); 14 | const buildYear = date.getUTCFullYear(); 15 | 16 | const openAck = async () => { 17 | const WebviewWindow = await import("@tauri-apps/api/window").then(window => window.WebviewWindow); 18 | const target = WebviewWindow.getByLabel("ack")!; 19 | await target.show(); 20 | } 21 | 22 | const openLicense = async () => { 23 | const WebviewWindow = await import("@tauri-apps/api/window").then(window => window.WebviewWindow); 24 | const target = WebviewWindow.getByLabel("license")!; 25 | await target.show(); 26 | } 27 | 28 | const A = (props: { href: string, children: React.ReactNode }) => ( 29 | 30 | ) 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 38 | 39 | TimeMachine Exclude 40 | {t('build', {version: data?.version ?? "unknown", date})} 41 | 42 | 43 | 44 | Github: 45 | https://github.com/PhotonQuantum/tmexclude 46 | 47 | 48 | 49 | {t('contact_me')} 50 | 51 | Github - @PhotonQuantum
52 | Twitter - @LightQuantumhah 53 |
54 | 55 | 56 | 57 | Licensed under MIT License 59 |
60 | 61 | Powered by open-source software 63 |
64 | Copyright © {buildYear} LightQuantum 65 |
66 |
67 |
68 |
69 | 70 | ) 71 | } -------------------------------------------------------------------------------- /src/pages/Ack.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Container, Group, ScrollArea, Table, Tabs, Text} from "@mantine/core"; 2 | import {cargoLicenses, License, npmLicenses} from "../licenses"; 3 | import {useViewportSize} from "@mantine/hooks"; 4 | import {useTranslation} from "react-i18next"; 5 | 6 | const A = (props: { href: string, children: React.ReactNode }) => ( 7 | 8 | ) 9 | 10 | const DepTab = ({deps}: { deps: Array }) => { 11 | const {t} = useTranslation(); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | { 24 | deps.map(({name, license, repository, version}) => ( 25 | 26 | 37 | 38 | 39 | )) 40 | } 41 | 42 |
{t('package')}{t('license')}
27 | 28 | { 29 | repository !== null ? 30 | {name} 31 | : 32 | {name} 33 | } 34 | {version && {version}} 35 | 36 | {license}
43 |
44 | ) 45 | } 46 | 47 | export const Ack = () => { 48 | const {height: vh} = useViewportSize(); 49 | return ( 50 | 51 | 52 | 57 | 58 | Cargo 59 | NPM 60 | 61 | 62 | 63 | 64 | 65 | { 66 | return { 67 | name: name.split("@").slice(0, -1).join("@"), 68 | version: name.split("@").slice(-1), 69 | ...fields 70 | } as License 71 | } 72 | )}/> 73 | 74 | 75 | 76 | 77 | ) 78 | } -------------------------------------------------------------------------------- /src/pages/License.tsx: -------------------------------------------------------------------------------- 1 | import {Container, ScrollArea, Textarea} from "@mantine/core"; 2 | // @ts-ignore 3 | import license from "../../LICENSE.txt"; 4 | import {useViewportSize} from "@mantine/hooks"; 5 | 6 | export const License = () => { 7 | const {height: vh} = useViewportSize(); 8 | return ( 9 | 10 | 11 |