├── .clippy.toml ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yaml │ └── feature-request.yaml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint-and-test.yaml │ └── release-gui.yaml ├── .gitignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── biome.jsonc ├── crates ├── bluetooth │ ├── Cargo.toml │ ├── readme.md │ └── src │ │ ├── categories │ │ ├── category.rs │ │ ├── errors.rs │ │ ├── mod.rs │ │ └── sub_category.rs │ │ ├── device │ │ ├── device_info.rs │ │ ├── mod.rs │ │ └── windows │ │ │ ├── address_parser.rs │ │ │ ├── device_info │ │ │ ├── buffer │ │ │ │ ├── errors.rs │ │ │ │ └── mod.rs │ │ │ ├── device_instance.rs │ │ │ ├── device_property.rs │ │ │ └── mod.rs │ │ │ ├── device_searcher.rs │ │ │ ├── inspect │ │ │ ├── errors.rs │ │ │ ├── impls.rs │ │ │ └── mod.rs │ │ │ ├── mod.rs │ │ │ └── watch.rs │ │ ├── errors.rs │ │ ├── lib.rs │ │ └── utils.rs └── timer │ ├── Cargo.toml │ └── src │ └── lib.rs ├── cspell.jsonc ├── gui ├── backend │ ├── Cargo.toml │ ├── build.rs │ ├── capabilities │ │ ├── desktop.json │ │ └── main.json │ ├── fonts │ │ ├── DejaVuSans │ │ │ ├── LICENSE │ │ │ └── ttf │ │ │ │ └── DejaVuSans.ttf │ │ └── readme.md │ ├── 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 │ │ ├── battery │ │ │ ├── battery-0.png │ │ │ ├── battery-10.png │ │ │ ├── battery-100.png │ │ │ ├── battery-20.png │ │ │ ├── battery-30.png │ │ │ ├── battery-40.png │ │ │ ├── battery-50.png │ │ │ ├── battery-60.png │ │ │ ├── battery-70.png │ │ │ ├── battery-80.png │ │ │ ├── battery-90.png │ │ │ └── battery.drawio │ │ ├── battery_power_off │ │ │ ├── battery-0.png │ │ │ ├── battery-10.png │ │ │ ├── battery-100.png │ │ │ ├── battery-20.png │ │ │ ├── battery-30.png │ │ │ ├── battery-40.png │ │ │ ├── battery-50.png │ │ │ ├── battery-60.png │ │ │ ├── battery-70.png │ │ │ ├── battery-80.png │ │ │ └── battery-90.png │ │ ├── icon.drawio │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ ├── readme.md │ ├── src │ │ ├── cmd │ │ │ ├── config.rs │ │ │ ├── device_watcher.rs │ │ │ ├── interval.rs │ │ │ ├── mod.rs │ │ │ ├── supports │ │ │ │ ├── icon.rs │ │ │ │ ├── mod.rs │ │ │ │ └── notify.rs │ │ │ └── system_tray.rs │ │ ├── error.rs │ │ ├── log.rs │ │ ├── main.rs │ │ └── setup │ │ │ ├── mod.rs │ │ │ ├── tray_menu.rs │ │ │ └── window_event.rs │ └── tauri.conf.json └── frontend │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── next.config.js │ ├── readme.md │ ├── src │ ├── @types │ │ └── monaco-vim.d.ts │ ├── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── settings │ │ │ └── page.tsx │ ├── components │ │ ├── atoms │ │ │ ├── CircularWithLabel │ │ │ │ └── index.tsx │ │ │ └── Help │ │ │ │ ├── Help.test.tsx │ │ │ │ ├── Help.tsx │ │ │ │ └── index.tsx │ │ ├── hooks │ │ │ ├── useDebounce │ │ │ │ ├── index.ts │ │ │ │ ├── useDebounce.test.ts │ │ │ │ └── useDebounce.ts │ │ │ ├── useInjectCss │ │ │ │ ├── index.ts │ │ │ │ ├── useInjectCss.test.tsx │ │ │ │ └── useInjectCss.ts │ │ │ ├── useInjectJs │ │ │ │ ├── index.ts │ │ │ │ ├── useInjectJs.test.tsx │ │ │ │ └── useInjectJs.ts │ │ │ ├── useRelativeTime.ts │ │ │ ├── useStorageState │ │ │ │ ├── index.ts │ │ │ │ ├── useStorageState.test.ts │ │ │ │ └── useStorageState.ts │ │ │ └── useTranslation.ts │ │ ├── layout │ │ │ ├── ClientLayout │ │ │ │ ├── ClientLayout.tsx │ │ │ │ └── index.tsx │ │ │ └── readme.md │ │ ├── meta │ │ │ ├── font.ts │ │ │ └── meta.ts │ │ ├── molecules │ │ │ ├── Button │ │ │ │ ├── Button.tsx │ │ │ │ └── index.tsx │ │ │ ├── ButtonWithToolTip │ │ │ │ ├── ButtonWithTooltip.tsx │ │ │ │ └── index.tsx │ │ │ ├── ImportLangButton │ │ │ │ ├── ImportLangButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── InputField │ │ │ │ ├── InputField.tsx │ │ │ │ └── index.tsx │ │ │ ├── JsAutoRunButton │ │ │ │ ├── JsAutoRunButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── LogDirButton │ │ │ │ ├── LogDirButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── LogFileButton │ │ │ │ ├── LogFileButton.tsx │ │ │ │ └── index.tsx │ │ │ └── SelectWithLabel │ │ │ │ ├── SelectWithLabel.test.tsx │ │ │ │ ├── SelectWithLabel.tsx │ │ │ │ └── index.tsx │ │ ├── organisms │ │ │ ├── BackupButton │ │ │ │ ├── BackupButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── BackupExportButton │ │ │ │ ├── BackupExportButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── BackupImportButton │ │ │ │ ├── BackupImportButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── BackupMenuDialog │ │ │ │ ├── BackupMenuDialog.tsx │ │ │ │ ├── CacheItem.tsx │ │ │ │ ├── CacheValueItem.tsx │ │ │ │ ├── CheckBoxControls.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── useCheckBoxState.ts │ │ │ ├── BluetoothGrid │ │ │ │ ├── DeviceCard.tsx │ │ │ │ ├── DeviceCards.tsx │ │ │ │ ├── DevicesProvider.tsx │ │ │ │ ├── RestartButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── CodeEditorTab │ │ │ │ ├── CodeEditorTab.tsx │ │ │ │ ├── EditorInitializer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── CssList │ │ │ │ ├── CssList.tsx │ │ │ │ └── index.tsx │ │ │ ├── EditorList │ │ │ │ ├── EditorList.tsx │ │ │ │ └── index.tsx │ │ │ ├── FixedNav │ │ │ │ ├── FixedNav.tsx │ │ │ │ └── index.tsx │ │ │ ├── I18nList │ │ │ │ ├── I18nList.tsx │ │ │ │ └── index.tsx │ │ │ ├── IconTypeList │ │ │ │ ├── IconTypeList.tsx │ │ │ │ └── index.tsx │ │ │ ├── LogLevelList │ │ │ │ ├── LogLevelList.tsx │ │ │ │ └── index.tsx │ │ │ ├── MonacoEditor │ │ │ │ ├── MonacoEditor.tsx │ │ │ │ ├── atom_onedark_pro.ts │ │ │ │ ├── index.tsx │ │ │ │ └── vim_key_bindings.ts │ │ │ ├── MonitorConfig │ │ │ │ ├── AutoStartSwitch.tsx │ │ │ │ ├── ConfigFields.tsx │ │ │ │ ├── MonitorConfigButton.tsx │ │ │ │ ├── MonitorConfigDialog.tsx │ │ │ │ └── NumericField.tsx │ │ │ ├── NotifyList │ │ │ │ ├── MaxSnackField.tsx │ │ │ │ ├── NotifyList.tsx │ │ │ │ └── index.tsx │ │ │ ├── PageNavigation │ │ │ │ ├── PageNavigation.tsx │ │ │ │ └── index.tsx │ │ │ ├── TabPositionList │ │ │ │ ├── TabPositionList.tsx │ │ │ │ └── index.tsx │ │ │ ├── Tabs │ │ │ │ ├── Tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── schema.ts │ │ │ └── Updater │ │ │ │ ├── NavigationUpdater.tsx │ │ │ │ ├── UpdaterDialog.tsx │ │ │ │ └── useUpdater.ts │ │ ├── providers │ │ │ ├── CssProvider.tsx │ │ │ ├── EditorModeProvider.tsx │ │ │ ├── JsProvider.tsx │ │ │ ├── LogLevelProvider.tsx │ │ │ ├── NotifyProvider.tsx │ │ │ ├── TabProvider.tsx │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── Loading.tsx │ │ │ ├── Settings │ │ │ ├── Settings.tsx │ │ │ └── index.tsx │ │ │ └── Top │ │ │ ├── Top.tsx │ │ │ └── index.tsx │ ├── lib │ │ ├── css │ │ │ └── index.ts │ │ ├── editor-mode │ │ │ └── index.ts │ │ ├── i18n │ │ │ └── index.ts │ │ ├── notify │ │ │ ├── config.test.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── object-utils │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── storage │ │ │ ├── cacheKeys.ts │ │ │ ├── index.ts │ │ │ ├── schemaStorage.ts │ │ │ ├── storage.test.ts │ │ │ ├── storage.ts │ │ │ └── types.ts │ │ └── zod │ │ │ ├── json-validation.ts │ │ │ └── schema-utils.ts │ └── services │ │ ├── api │ │ ├── backup.ts │ │ ├── bluetooth_config.ts │ │ ├── bluetooth_finder.ts │ │ ├── device_listener.ts │ │ ├── dialog.ts │ │ ├── fs.ts │ │ ├── lang.ts │ │ ├── log.ts │ │ ├── patch.ts │ │ ├── shell.ts │ │ ├── sys_tray.ts │ │ └── window.ts │ │ └── readme.md │ ├── tsconfig.json │ └── vitest.setup.mts ├── locales ├── en-US.json ├── en.json ├── ja-JP.json ├── ko-KR.json └── readme.md ├── package-lock.json ├── package.json ├── readme.md ├── tools └── version_up.cjs └── vitest.config.ts /.clippy.toml: -------------------------------------------------------------------------------- 1 | # This file is a special exception configuration file for `clippy`, the Rust linter. 2 | # See below for details. 3 | # - https://doc.rust-lang.org/clippy/lint_configuration.html 4 | # - https://github.com/rust-lang/rust-clippy?tab=readme-ov-file#configuration 5 | 6 | allow-expect-in-tests = true 7 | allow-panic-in-tests = true 8 | allow-unwrap-in-tests = true # https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.rs] 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: File a bug report 4 | title: '[Bug]: ' 5 | labels: ['bug'] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: dropdown 13 | id: version 14 | attributes: 15 | label: Version 16 | description: What version of our software are you running? 17 | options: 18 | - 0.1.0 19 | - 0.2.0 20 | - 0.3.0 21 | - 0.3.1 22 | - 0.4.0 23 | - 0.4.1 24 | - 0.4.2 25 | - 0.4.3 26 | - 0.5.0 27 | - 0.5.1 28 | - 0.5.2 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: unexpected-behavior 34 | attributes: 35 | label: Unexpected behavior here 36 | description: May I ask you to tell us about the unexpected behavior? 37 | # yamllint disable-line rule:line-length 38 | placeholder: 'Example: logger is not worked.' 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: expected-behavior 43 | attributes: 44 | label: Expected behavior 45 | description: May I ask you to tell us about the behavior you expect? 46 | # yamllint disable-line rule:line-length 47 | placeholder: 'Example: Reflects the level specification of the logger in the GUI.' 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: logs 52 | attributes: 53 | label: Relevant log output 54 | # yamllint disable-line rule:line-length 55 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 56 | render: Shell 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | description: File a feature request 4 | title: '[Feature]: ' 5 | labels: ['enhancement'] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this feature request. 12 | - type: textarea 13 | id: expected-behavior 14 | attributes: 15 | label: Expected behavior 16 | description: May I ask you to tell us about the behavior you expect? 17 | placeholder: 'Example: Add progress bar in the GUI.' 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: other 22 | attributes: 23 | label: other 24 | description: About everything else. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # New Features 2 | 3 | # Changes and Fixes 4 | 5 | # Refactors 6 | -------------------------------------------------------------------------------- /.github/workflows/release-gui.yaml: -------------------------------------------------------------------------------- 1 | name: Release GUI 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | release: 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | # platform: [macos-latest, ubuntu-20.04, windows-latest] 15 | platform: [windows-latest] 16 | runs-on: ${{ matrix.platform }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4.2.2 20 | 21 | - name: Install dependencies (ubuntu only) 22 | if: matrix.platform == 'ubuntu-20.04' 23 | # You can remove libayatana-appindicator3-dev if you don't use the system tray feature. 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev 27 | 28 | - name: Rust setup 29 | uses: dtolnay/rust-toolchain@stable 30 | 31 | - name: Rust cache 32 | uses: Swatinem/rust-cache@v2.7.7 33 | with: 34 | prefix-key: cargo-${{ matrix.platform }} 35 | 36 | - name: Sync node version and setup cache 37 | uses: actions/setup-node@v4.1.0 38 | with: 39 | node-version: 'lts/*' 40 | cache: 'npm' 41 | 42 | - name: Node.js cache 43 | uses: actions/cache@v4.2.0 44 | with: 45 | path: ${{ github.workspace }}/dar2oar_gui/frontend/.next/cache 46 | # Generate a new cache whenever packages or source files change. 47 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('dar2oar_gui/frontend/src/**/*.[jt]s', 'dar2oar_gui/frontend/src/**/*.[jt]sx') }} 48 | restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- 49 | - name: Install frontend dependencies 50 | run: npm ci 51 | 52 | - name: Update CHANGELOG 53 | id: changelog 54 | uses: requarks/changelog-action@v1.10.2 55 | with: 56 | token: ${{ github.token }} 57 | tag: ${{ github.ref_name }} 58 | 59 | - name: Create Github Release 60 | uses: tauri-apps/tauri-action@action-v0.5.18 61 | 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} # To use updater 65 | TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' # To use updater 66 | with: 67 | releaseName: 'Bluetooth Battery Monitor v__VERSION__' 68 | releaseBody: ${{ steps.changelog.outputs.changes }} 69 | tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags. 70 | prerelease: false 71 | includeUpdaterJson: true # build latest.json(For updater) 72 | 73 | - name: Commit CHANGELOG.md 74 | uses: stefanzweifel/git-auto-commit-action@v5.1.0 75 | with: 76 | branch: main 77 | commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]' 78 | file_pattern: CHANGELOG.md 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # next.js 5 | .next/ 6 | .swc 7 | out/ 8 | 9 | # production 10 | build 11 | gen/ 12 | 13 | # misc 14 | .DS_Store 15 | *.pem 16 | test/data/* 17 | !test/data/*.md 18 | *.log 19 | 20 | # Rust build cache 21 | **/target 22 | 23 | secrets/ 24 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | //! NOTE: If this file is not in the root directory, VSCode's prettier extension will not reflect this setting. 2 | //! Use prettier because biome is fast but does not yet support yaml formatting. 3 | // @ts-check 4 | 5 | /** @type {import('prettier').Options} */ 6 | export default { 7 | semi: true, 8 | singleQuote: true, 9 | printWidth: 120, 10 | }; 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "editorconfig.editorconfig", 5 | "jayfong.generate-index", 6 | "lokalise.i18n-ally", 7 | "rust-lang.rust-analyzer", 8 | "tamasfe.even-better-toml", 9 | "vitest.explorer" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript][javascriptreact][typescript][typescriptreact]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[json][jsonc]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[yaml]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "editor.codeActionsOnSave": { 12 | "quickfix.biome": "explicit", 13 | "source.fixAll.eslint": "explicit", 14 | "source.fixAll.sortJSON": "never", 15 | "source.organizeImports.biome": "explicit" 16 | }, 17 | "evenBetterToml.formatter.alignComments": true, 18 | // "rust-analyzer.checkOnSave": false, 19 | // "editor.formatOnSave": false 20 | "files.exclude": { 21 | ".swc": true, 22 | "**/node_modules": true // optional 23 | }, 24 | "files.watcherExclude": { 25 | "**/.git/objects/**": true, 26 | "**/node_modules/**": true, 27 | "**/target/**": true 28 | }, 29 | "i18n-ally.keystyle": "nested", 30 | "rust-analyzer.check.command": "clippy" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SARDONYX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "extends": [], 4 | "files": { 5 | "ignore": [ 6 | "**/gen", 7 | ".next", 8 | "cspell.jsonc", 9 | "monaco-vim.d.ts", //Automatic generation by devtool and LM, so any is used 10 | "node_modules", 11 | "out", 12 | "schema.json", 13 | "target" 14 | ], 15 | "ignoreUnknown": true 16 | }, 17 | "formatter": { 18 | "enabled": true, 19 | "formatWithErrors": true, 20 | "indentStyle": "space", 21 | "indentWidth": 2, 22 | "lineWidth": 120 23 | }, 24 | "javascript": { 25 | "formatter": { 26 | "arrowParentheses": "always", 27 | "jsxQuoteStyle": "single", 28 | "quoteStyle": "single", 29 | "semicolons": "always", 30 | "trailingCommas": "all" 31 | } 32 | }, 33 | "json": { 34 | "formatter": { 35 | "enabled": true, 36 | "indentStyle": "space" 37 | }, 38 | "parser": { 39 | "allowComments": true 40 | } 41 | }, 42 | "linter": { 43 | "ignore": ["./tools/version_up.js"], 44 | "rules": { 45 | "all": true, 46 | "correctness": { 47 | "noUndeclaredDependencies": "off", 48 | "useImportExtensions": "off" 49 | }, 50 | "nursery": { 51 | "all": false 52 | }, 53 | "performance": { 54 | "noBarrelFile": "off", 55 | "noReExportAll": "off" 56 | }, 57 | "style": { 58 | "noDefaultExport": "off" 59 | }, 60 | "suspicious": { 61 | "noConsoleLog": "off", // Configuration to be removed in due course. 62 | "noReactSpecificProps": "off" // It's for Solid.js, so turn it off in React. 63 | } 64 | } 65 | }, 66 | "organizeImports": { 67 | "enabled": true 68 | }, 69 | "overrides": [ 70 | { 71 | "include": ["./gui/frontend/src/components/**/*"], 72 | "linter": { 73 | "rules": { 74 | "style": { 75 | "useFilenamingConvention": "off" // Because we want to use PascalCase for the React component file name. 76 | } 77 | } 78 | } 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /crates/bluetooth/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bluetooth" 3 | version.workspace = true 4 | description = "Bluetooth" 5 | authors = ["SARDONYX-sard"] 6 | readme = "./readme.md" 7 | license = "MIT OR Apache-2.0" 8 | repository.workspace = true 9 | edition = "2021" 10 | rust-version = "1.80" 11 | keywords.workspace = true 12 | categories = ["bluetooth"] 13 | 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | [features] 17 | default = ["serde", "tracing"] 18 | tracing = ["dep:tracing"] 19 | serde = ["dep:serde", "dep:serde_with", "dashmap/serde"] 20 | 21 | [dependencies] 22 | chrono = { workspace = true, features = ["serde"] } 23 | dashmap = { workspace = true } 24 | num-derive = "0.4.2" 25 | num-traits = "0.2.19" 26 | parse-display = { workspace = true } # Display derive 27 | serde = { workspace = true, optional = true } # Implement (De)Serializer 28 | serde_with = { version = "3.12.0", optional = true } 29 | snafu = { workspace = true } 30 | tracing = { workspace = true, optional = true } # Logger 31 | winnow = { workspace = true } 32 | 33 | [target.'cfg(windows)'.dependencies] 34 | windows-collections = "0.2.0" 35 | windows-future = "0.2.0" 36 | windows = { version = "0.61.1", features = [ 37 | "Devices_Bluetooth_Rfcomm", 38 | "Devices_Enumeration", 39 | "Foundation_Collections", 40 | "System", 41 | "Wdk_Devices", 42 | "Wdk_Devices_Bluetooth", 43 | "Win32_Devices_Bluetooth", 44 | "Win32_Devices_DeviceAndDriverInstallation", 45 | "Win32_Devices_Properties", 46 | "Win32_Foundation", 47 | "Win32_Networking_WinSock", 48 | "Win32_System_Diagnostics_Debug", 49 | "Win32_System_Registry", 50 | "Win32_System_Rpc", 51 | "Win32_System_Threading", 52 | "Win32_UI_WindowsAndMessaging", 53 | ] } 54 | 55 | 56 | [dev-dependencies] 57 | pretty_assertions = { workspace = true } 58 | quick_tracing = { workspace = true } 59 | serde_json = { workspace = true } 60 | 61 | [lints] 62 | workspace = true 63 | -------------------------------------------------------------------------------- /crates/bluetooth/readme.md: -------------------------------------------------------------------------------- 1 | # Bluetooth 2 | -------------------------------------------------------------------------------- /crates/bluetooth/src/categories/errors.rs: -------------------------------------------------------------------------------- 1 | use super::sub_category::{MajorCategory, SubCategory4, SubCategory5}; 2 | 3 | #[derive(Debug, snafu::Snafu)] 4 | #[snafu(visibility(pub))] 5 | pub enum CategoryError { 6 | /// Failed to cast major category as u32: {major} 7 | FailedToCastMajor { major: MajorCategory }, 8 | /// Failed to cast sub category4 as u32: {sub} 9 | FailedToCastCategory4 { sub: SubCategory4 }, 10 | /// Failed to cast sub category5 as u32: {sub} 11 | FailedToCastCategory5 { sub: SubCategory5 }, 12 | } 13 | -------------------------------------------------------------------------------- /crates/bluetooth/src/categories/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod category; 2 | pub mod errors; 3 | pub mod sub_category; 4 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/device_info.rs: -------------------------------------------------------------------------------- 1 | use crate::categories::category::Category; 2 | use chrono::{Datelike as _, Timelike as _}; 3 | use dashmap::DashMap; 4 | 5 | /// key: bluetooth address 6 | /// value: bluetooth device information 7 | pub type Devices = DashMap; 8 | 9 | /// Bluetooth battery info 10 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 11 | #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] 12 | pub struct BluetoothDeviceInfo { 13 | /// e.g. `BTHENUM\\{0000111E-0000-1000-8000-00805F9B34FB}_LOCALMFG&005D...` 14 | pub instance_id: String, 15 | /// e.g. `E500Pro Hands-Free AG` 16 | pub friendly_name: String, 17 | /// e.g. 80(%) 18 | pub battery_level: u8, 19 | /// e.g. "00112233aabb" 20 | pub address: u64, 21 | pub category: Category, 22 | /// Is this device connected? 23 | pub is_connected: bool, 24 | /// `{year}/{month}/{day} {hour}:{minute}:{second}` 25 | pub last_used: LocalTime, 26 | pub last_updated: LocalTime, 27 | 28 | /// device id 29 | pub device_instance: u32, 30 | } 31 | 32 | /// Local time 33 | #[cfg_attr( 34 | feature = "serde", 35 | derive(serde_with::DeserializeFromStr, serde_with::SerializeDisplay,) 36 | )] 37 | #[derive( 38 | Debug, 39 | Clone, 40 | Default, 41 | PartialEq, 42 | Eq, 43 | PartialOrd, 44 | Ord, 45 | Hash, 46 | parse_display::Display, 47 | parse_display::FromStr, 48 | )] 49 | #[display("{year}/{month}/{day} {hour}:{minute}:{second}")] 50 | pub struct LocalTime { 51 | pub year: u16, 52 | pub month: u16, 53 | pub day: u16, 54 | pub hour: u16, 55 | pub minute: u16, 56 | pub second: u16, 57 | } 58 | 59 | impl LocalTime { 60 | /// Create a new system time 61 | pub fn now() -> Self { 62 | let now = chrono::Local::now(); 63 | Self { 64 | year: now.year() as u16, 65 | month: now.month() as u16, 66 | day: now.day() as u16, 67 | hour: now.hour() as u16, 68 | minute: now.minute() as u16, 69 | second: now.second() as u16, 70 | } 71 | } 72 | 73 | pub fn from_utc(utc_time: &chrono::DateTime) -> Self { 74 | let time = utc_time.with_timezone(&chrono::Local); 75 | Self { 76 | year: time.year() as u16, 77 | month: time.month() as u16, 78 | day: time.day() as u16, 79 | hour: time.hour() as u16, 80 | minute: time.minute() as u16, 81 | second: time.second() as u16, 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_info; 2 | #[cfg(target_os = "windows")] 3 | pub mod windows; 4 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/address_parser.rs: -------------------------------------------------------------------------------- 1 | /// Convert address(`de:ad:be:ee:ee:ef`) of string (e.g., `Bluetooth#Bluetooth00:00:00:ff:ff:00-de:ad:be:ee:ee:ef`) into a [u64]. 2 | pub fn id_to_address(id: &mut &str) -> winnow::PResult { 3 | use winnow::prelude::Parser as _; 4 | 5 | let input = id; 6 | let prefix = "Bluetooth#Bluetooth"; 7 | let _ = (prefix, hex_address, '-').parse_next(input)?; 8 | 9 | // Convert address string (e.g., "00:00:00:ff:ff:00") into a u64. 10 | let address = hex_address.parse_next(input)?; 11 | let combined = ((address.0 as u64) << 40) 12 | | ((address.1 as u64) << 32) 13 | | ((address.2 as u64) << 24) 14 | | ((address.3 as u64) << 16) 15 | | ((address.4 as u64) << 8) 16 | | (address.5 as u64); 17 | Ok(combined) 18 | } 19 | 20 | fn hex_primary(input: &mut &str) -> winnow::PResult { 21 | use winnow::token::take_while; 22 | use winnow::Parser; 23 | 24 | take_while(2, |c: char| c.is_ascii_hexdigit()) 25 | .try_map(|input| u8::from_str_radix(input, 16)) 26 | .parse_next(input) 27 | } 28 | 29 | /// Parse hex address e.g. `de:ad:be:ee:ee:ef` 30 | fn hex_address(input: &mut &str) -> winnow::PResult<(u8, u8, u8, u8, u8, u8)> { 31 | use winnow::seq; 32 | use winnow::Parser as _; 33 | 34 | seq! { 35 | hex_primary, 36 | _: ':', 37 | hex_primary, 38 | _: ':', 39 | hex_primary, 40 | _: ':', 41 | 42 | hex_primary, 43 | _: ':', 44 | hex_primary, 45 | _: ':', 46 | hex_primary, 47 | } 48 | .parse_next(input) 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::*; 54 | use winnow::Parser as _; 55 | 56 | #[test] 57 | fn test_id_to_address() { 58 | let id = "Bluetooth#Bluetooth00:00:00:ff:ff:00-de:ad:be:ee:ee:ef"; 59 | let address = id_to_address 60 | .parse(id) 61 | .unwrap_or_else(|err| panic!("{err}")); 62 | assert_eq!(address, 0xdeadbeeeeeef); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/device_info/buffer/errors.rs: -------------------------------------------------------------------------------- 1 | use snafu::Snafu; 2 | use windows::core::Error; 3 | 4 | use crate::device::windows::device_info::device_property::DevPropType; 5 | 6 | /// Custom error type using snafu 7 | #[derive(Debug, Snafu)] 8 | pub enum DevicePropertyError { 9 | #[snafu(display("Failed to retrieve device property: {}", source))] 10 | DevicePropertyError { source: Error }, 11 | 12 | #[snafu(display( 13 | "Expected device property type {}, but got {}", 14 | DevPropType::from_u32(*expected).map_or("Unknown", |t|t.as_str()), 15 | DevPropType::from_u32(*actual).map_or("Unknown", |t|t.as_str()), 16 | ))] 17 | TypeError { actual: u32, expected: u32 }, 18 | } 19 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/device_info/device_instance.rs: -------------------------------------------------------------------------------- 1 | use crate::device::windows::device_info::BluetoothDeviceInfoError; 2 | use windows::{ 3 | core::Error, 4 | Win32::{ 5 | Devices::{ 6 | DeviceAndDriverInstallation::{CM_Get_DevNode_PropertyW, CR_SUCCESS}, 7 | Properties::DEVPROPTYPE, 8 | }, 9 | Foundation::DEVPROPKEY, 10 | }, 11 | }; 12 | 13 | use super::buffer::DeviceProperty; 14 | 15 | /// Access handler for device information with 32-bit ptr. 16 | #[repr(transparent)] 17 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] 18 | pub struct DeviceInstance(pub u32); 19 | 20 | impl DeviceInstance { 21 | /// Create a new device instance 22 | pub const fn new(value: u32) -> Self { 23 | Self(value) 24 | } 25 | 26 | /// Create a null ptr device instance 27 | pub const fn empty() -> Self { 28 | Self(0) 29 | } 30 | 31 | /// Retrieves the property for a device instance 32 | pub fn get_device_property( 33 | &self, 34 | property_key: &DEVPROPKEY, 35 | ) -> Result 36 | where 37 | T: DeviceProperty, 38 | { 39 | let mut property_type: DEVPROPTYPE = DEVPROPTYPE(0); 40 | let mut buffer = T::new_buffer(); 41 | let mut buffer_size: u32 = core::mem::size_of_val(&buffer) as u32; 42 | let buf_ptr: *mut ::Buffer = &mut buffer; 43 | 44 | unsafe { 45 | let ret = CM_Get_DevNode_PropertyW( 46 | self.0, 47 | property_key, 48 | &mut property_type, 49 | Some(buf_ptr.cast()), 50 | &mut buffer_size, 51 | 0, 52 | ); 53 | 54 | if ret != CR_SUCCESS { 55 | return Err(BluetoothDeviceInfoError::DevicePropertyError { 56 | key: *property_key, 57 | source: Error::from_win32(), 58 | }); 59 | } 60 | 61 | Ok(T::from_buffer(buffer, property_type)?) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/inspect/errors.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, snafu::Snafu)] 2 | pub enum RevealError { 3 | /// Failed to cast key '{key}' to the expected type '{expected_type}' 4 | TypeCastError { key: String, expected_type: String }, 5 | /// Unknown type for key '{key}': {unknown_type} 6 | UnknownTypeError { key: String, unknown_type: String }, 7 | 8 | #[snafu(transparent)] 9 | #[cfg(target_os = "windows")] 10 | Error { source: windows::core::Error }, 11 | } 12 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/inspect/impls.rs: -------------------------------------------------------------------------------- 1 | use crate::device::device_info::LocalTime; 2 | use windows::core::{IInspectable, Interface as _, HSTRING}; 3 | use windows::Foundation::IReference; 4 | 5 | pub trait RevealValue: Sized { 6 | /// Reveals the value from an IInspectable. 7 | /// 8 | /// # Errors 9 | /// Returns an error if the value cannot be cast to the expected type. 10 | fn reveal(value: &IInspectable) -> windows::core::Result; 11 | } 12 | 13 | impl RevealValue for bool { 14 | fn reveal(value: &IInspectable) -> windows::core::Result { 15 | value.cast::>()?.Value() 16 | } 17 | } 18 | 19 | impl RevealValue for String { 20 | fn reveal(value: &IInspectable) -> windows::core::Result { 21 | Ok(value.cast::>()?.Value()?.to_string()) 22 | } 23 | } 24 | 25 | impl RevealValue for u8 { 26 | fn reveal(value: &IInspectable) -> windows::core::Result { 27 | value.cast::>()?.Value() 28 | } 29 | } 30 | 31 | impl RevealValue for LocalTime { 32 | fn reveal(value: &IInspectable) -> windows::core::Result { 33 | let val = value 34 | .cast::>()? 35 | .Value()?; 36 | let utc_time = windows_datetime_to_chrono(val.UniversalTime); 37 | 38 | utc_time.map_or_else( 39 | || { 40 | Err(windows::core::Error::new( 41 | windows::core::HRESULT::from_win32(87), // Invalid parameter 42 | "Invalid LocalTime value", 43 | )) 44 | }, 45 | |time| Ok(Self::from_utc(&time)), 46 | ) 47 | } 48 | } 49 | 50 | fn windows_datetime_to_chrono(universal_time: i64) -> Option> { 51 | use chrono::TimeZone as _; 52 | 53 | // Windows FILETIME epoch (1601-01-01) to Unix epoch (1970-01-01) in 100ns units 54 | const EPOCH_DIFFERENCE_100NS: i64 = 11_644_473_600 * 10_000_000; 55 | // Adjust to Unix epoch 56 | let unix_time_100ns = universal_time - EPOCH_DIFFERENCE_100NS; 57 | // Convert 100ns to seconds and nanoseconds 58 | let seconds = unix_time_100ns / 10_000_000; 59 | let nanoseconds = (unix_time_100ns % 10_000_000) * 100; 60 | // Create chrono::DateTime 61 | chrono::Utc 62 | .timestamp_opt(seconds, nanoseconds as u32) 63 | .latest() 64 | } 65 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/inspect/mod.rs: -------------------------------------------------------------------------------- 1 | mod errors; 2 | mod impls; 3 | 4 | use windows::core::{IInspectable, HSTRING}; 5 | use windows_collections::IKeyValuePair; 6 | 7 | pub use errors::RevealError; 8 | pub use impls::RevealValue; 9 | 10 | /// print property key and value. 11 | /// 12 | /// # Errors 13 | /// If failed to type cast 14 | pub fn reveal_value(prop: IKeyValuePair) -> Result 15 | where 16 | T: RevealValue, 17 | { 18 | let key = prop.Key()?; 19 | let value = prop.Value()?; 20 | 21 | let runtime_class_name = value.GetRuntimeClassName()?.to_string(); 22 | match runtime_class_name.as_str() { 23 | "Windows.Foundation.IReference`1" => T::reveal(&value).map_or_else( 24 | |_| { 25 | Err(RevealError::TypeCastError { 26 | key: key.to_string(), 27 | expected_type: "Boolean".to_string(), 28 | }) 29 | }, 30 | Ok, 31 | ), 32 | "Windows.Foundation.IReference`1" => T::reveal(&value).map_or_else( 33 | |_| { 34 | Err(RevealError::TypeCastError { 35 | key: key.to_string(), 36 | expected_type: "String".to_string(), 37 | }) 38 | }, 39 | Ok, 40 | ), 41 | "Windows.Foundation.IReference`1" => T::reveal(&value).map_or_else( 42 | |_| { 43 | Err(RevealError::TypeCastError { 44 | key: key.to_string(), 45 | expected_type: "UInt8".to_string(), 46 | }) 47 | }, 48 | Ok, 49 | ), 50 | "Windows.Foundation.IReference`1" => T::reveal(&value) 51 | .map_or_else( 52 | |_| { 53 | Err(RevealError::TypeCastError { 54 | key: key.to_string(), 55 | expected_type: "DateTime".to_string(), 56 | }) 57 | }, 58 | Ok, 59 | ), 60 | unknown => Err(RevealError::UnknownTypeError { 61 | key: key.to_string(), 62 | unknown_type: unknown.to_string(), 63 | }), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /crates/bluetooth/src/device/windows/mod.rs: -------------------------------------------------------------------------------- 1 | mod address_parser; 2 | pub mod device_info; 3 | mod device_searcher; 4 | pub mod inspect; 5 | pub mod watch; 6 | -------------------------------------------------------------------------------- /crates/bluetooth/src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Error types for Converter 2 | 3 | use crate::categories::sub_category::{MajorCategory, SubCategory4, SubCategory5}; 4 | 5 | /// Each variant of the enum represents a specific type of error, 6 | /// such as error messages or other relevant information. 7 | #[derive(Debug, snafu::Snafu)] 8 | #[snafu(visibility(pub))] 9 | pub enum BluetoothError { 10 | #[snafu(display("Failed to cast major category as u32: {major}"))] 11 | FailedToCastMajorCatError { major: MajorCategory }, 12 | #[snafu(display("Failed to cast sub category4 as u32: {sub}"))] 13 | FailedToCastCat4Error { sub: SubCategory4 }, 14 | #[snafu(display("Failed to cast sub category5 as u32: {sub}"))] 15 | FailedToCastCat5Error { sub: SubCategory5 }, 16 | 17 | #[snafu(display("Failed to parse: {err}"))] 18 | WinnowError { 19 | err: winnow::error::ErrMode, 20 | }, 21 | 22 | #[snafu(transparent)] 23 | ParseIntError { source: core::num::ParseIntError }, 24 | #[snafu(transparent)] 25 | IoError { source: std::io::Error }, 26 | 27 | #[cfg(target_os = "windows")] 28 | #[snafu(transparent)] 29 | BluetoothError { 30 | source: crate::device::windows::device_info::BluetoothDeviceInfoError, 31 | }, 32 | #[snafu(transparent)] 33 | #[cfg(target_os = "windows")] 34 | Error { source: windows::core::Error }, 35 | } 36 | 37 | /// A specialized [Result] type 38 | pub type Result = core::result::Result; 39 | -------------------------------------------------------------------------------- /crates/bluetooth/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[doc = include_str!("../readme.md")] 2 | mod categories; 3 | pub mod device; 4 | pub mod errors; 5 | pub mod utils; 6 | 7 | pub use device::device_info::BluetoothDeviceInfo; 8 | -------------------------------------------------------------------------------- /crates/bluetooth/src/utils.rs: -------------------------------------------------------------------------------- 1 | /// play sound notifications using the Windows API. 2 | /// 3 | /// # Main use 4 | /// This auxiliary function is intended to be used as a notification sound for battery level. 5 | /// 6 | /// # Errors 7 | /// Returns an error if the sound notification fails to play. 8 | pub fn play_asterisk() -> windows::core::Result<()> { 9 | use windows::Win32::System::Diagnostics::Debug::MessageBeep; 10 | use windows::Win32::UI::WindowsAndMessaging::MB_ICONASTERISK; 11 | 12 | unsafe { MessageBeep(MB_ICONASTERISK) } 13 | } 14 | 15 | /// Open the Bluetooth settings menu in Windows. 16 | /// 17 | /// - See: [Launch the Windows Settings app](https://learn.microsoft.com/en-us/windows/apps/develop/launch/launch-settings-app) 18 | /// 19 | /// # Errors 20 | /// Returns an error if the settings fails to open. 21 | pub async fn open_bluetooth_menu() -> windows::core::Result { 22 | use windows::core::h; 23 | use windows::Foundation::Uri; 24 | use windows::System::Launcher; 25 | 26 | let uri = Uri::CreateUri(h!("ms-settings:bluetooth"))?; 27 | Launcher::LaunchUriAsync(&uri)?.await 28 | } 29 | -------------------------------------------------------------------------------- /crates/timer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timer" 3 | version = "0.4.3" 4 | description = "JavaScript like timer." 5 | authors = ["SARDONYX-sard"] 6 | readme = "./readme.md" 7 | license = "MIT OR Apache-2.0" 8 | repository.workspace = true 9 | edition = "2021" 10 | rust-version = "1.70" 11 | keywords.workspace = true 12 | categories = ["time"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | [dependencies] 16 | dashmap = { workspace = true } # Concurrent Hashmap 17 | once_cell = { workspace = true } 18 | snafu = { workspace = true } # Define errors 19 | tokio = { workspace = true, features = ["time", "sync"] } 20 | 21 | 22 | [dev-dependencies] 23 | pretty_assertions = { workspace = true } 24 | quick_tracing = { workspace = true } 25 | tokio = { workspace = true, features = ["time", "macros", "rt"] } 26 | tracing = { workspace = true } # Logger 27 | 28 | [lints] 29 | workspace = true 30 | -------------------------------------------------------------------------------- /cspell.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "BTHENUM", 4 | "chrono", 5 | "Classof", 6 | "CONFIGRET", 7 | "dashmap", 8 | "DEVNODE", 9 | "DEVPKEY", 10 | "DEVPROP", 11 | "DEVPROPKEY", 12 | "DEVPROPTYPE", 13 | "FILETIME", 14 | "fmtid", 15 | "HKEY", 16 | "HRESULT", 17 | "HSTRING", 18 | "impls", 19 | "Inspectable", 20 | "LOCALMFG", 21 | "PCSTR", 22 | "PCWSTR", 23 | "PKEY", 24 | "repr", 25 | "serde", 26 | "tauri", 27 | "Uncategorized", 28 | "unlisten" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /gui/backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bluetooth_battery_monitor" 3 | version.workspace = true 4 | description = "Bluetooth battery monitor GUI" 5 | authors = ["SARDONYX-sard"] 6 | readme = "./readme.md" 7 | license = "MIT OR Apache-2.0" 8 | repository.workspace = true 9 | edition = "2021" 10 | rust-version = "1.80" 11 | keywords.workspace = true 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | [build-dependencies] 15 | tauri-build = { version = "2.0.5", features = [] } 16 | 17 | [dependencies] 18 | chrono = { workspace = true } 19 | once_cell = { workspace = true } 20 | parse-display = { workspace = true } # Display derive 21 | rayon = { workspace = true } 22 | serde = { workspace = true } # Implement (De)Serializer 23 | serde_json = { workspace = true } # To avoid generate_context error. 24 | snafu = { workspace = true } # Implement error types 25 | tokio = { workspace = true } 26 | tracing = { workspace = true } 27 | tracing-subscriber = { workspace = true } 28 | 29 | # Icon 30 | ab_glyph = "0.2.29" 31 | image = "0.25" 32 | imageproc = "0.25" 33 | 34 | # GUI: https://github.com/tauri-apps/plugins-workspace 35 | tauri = { version = "2.2.2", features = [ 36 | "devtools", 37 | "image-ico", 38 | "image-png", 39 | "tray-icon", 40 | ] } 41 | tauri-plugin-autostart = "2.2.0" 42 | tauri-plugin-dialog = "2.2.0" 43 | tauri-plugin-fs = "2.2.0" 44 | tauri-plugin-notification = "2.2.1" 45 | tauri-plugin-process = "2" 46 | tauri-plugin-shell = "2.2.0" 47 | tauri-plugin-window-state = "2.2.0" 48 | 49 | 50 | # workspace members 51 | bluetooth = { workspace = true, features = ["tracing"] } 52 | timer = { workspace = true } 53 | 54 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 55 | tauri-plugin-updater = "2" 56 | 57 | [dev-dependencies] 58 | pretty_assertions = "1.4.0" 59 | temp-dir = "0.1.13" 60 | tracing-appender = "0.2.3" 61 | 62 | [features] 63 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. 64 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. 65 | # DO NOT REMOVE!! 66 | custom-protocol = ["tauri/custom-protocol"] 67 | 68 | [lints] 69 | workspace = true 70 | -------------------------------------------------------------------------------- /gui/backend/build.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::cargo_common_metadata)] 2 | 3 | fn main() { 4 | tauri_build::build(); 5 | } 6 | -------------------------------------------------------------------------------- /gui/backend/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "desktop-capability", 4 | "platforms": ["macOS", "windows", "linux"], 5 | "windows": ["main"], 6 | "permissions": ["updater:allow-check", "updater:allow-download-and-install", "updater:default"] 7 | } 8 | -------------------------------------------------------------------------------- /gui/backend/capabilities/main.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "main-capability", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "autostart:allow-is-enabled", 8 | "autostart:default", 9 | "core:app:allow-app-show", 10 | "core:app:allow-name", 11 | "core:event:allow-listen", 12 | "core:event:allow-unlisten", 13 | "core:path:allow-resolve-directory", 14 | "core:window:allow-show", 15 | "dialog:allow-open", 16 | "dialog:allow-save", 17 | "fs:allow-read-text-file", 18 | "fs:allow-resource-read-recursive", 19 | "notification:default", 20 | "process:allow-restart", 21 | "shell:allow-open" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /gui/backend/fonts/DejaVuSans/ttf/DejaVuSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/fonts/DejaVuSans/ttf/DejaVuSans.ttf -------------------------------------------------------------------------------- /gui/backend/fonts/readme.md: -------------------------------------------------------------------------------- 1 | # Fonts 2 | 3 | - DejaVuSans(v2.37): [link](https://dejavu-fonts.github.io) 4 | -------------------------------------------------------------------------------- /gui/backend/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/128x128.png -------------------------------------------------------------------------------- /gui/backend/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/128x128@2x.png -------------------------------------------------------------------------------- /gui/backend/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/32x32.png -------------------------------------------------------------------------------- /gui/backend/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /gui/backend/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/StoreLogo.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-0.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-10.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-100.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-20.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-30.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-40.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-50.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-60.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-70.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-80.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery-90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery/battery-90.png -------------------------------------------------------------------------------- /gui/backend/icons/battery/battery.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-0.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-10.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-100.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-20.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-30.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-40.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-50.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-60.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-70.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-80.png -------------------------------------------------------------------------------- /gui/backend/icons/battery_power_off/battery-90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/battery_power_off/battery-90.png -------------------------------------------------------------------------------- /gui/backend/icons/icon.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /gui/backend/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/icon.icns -------------------------------------------------------------------------------- /gui/backend/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/icon.ico -------------------------------------------------------------------------------- /gui/backend/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/backend/icons/icon.png -------------------------------------------------------------------------------- /gui/backend/readme.md: -------------------------------------------------------------------------------- 1 | # GUI Backend 2 | -------------------------------------------------------------------------------- /gui/backend/src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | pub mod supports; 3 | mod system_tray; 4 | 5 | pub(super) mod device_watcher; 6 | pub(super) mod interval; 7 | 8 | use crate::err_log_to_string; 9 | use tauri::{Builder, Wry}; 10 | use tokio::fs; 11 | 12 | #[tauri::command] 13 | pub(crate) async fn change_log_level(log_level: Option<&str>) -> Result<(), String> { 14 | tracing::trace!("Selected log level: {:?}", log_level); 15 | err_log_to_string!(crate::log::change_level(log_level.unwrap_or("error"))) 16 | } 17 | 18 | /// Define our own `writeTextFile` api for tauri, 19 | /// because there was a bug that contents were not written properly 20 | /// (there was a case that the order of some data in contents was switched). 21 | #[tauri::command] 22 | pub(crate) async fn write_file(path: &str, content: &str) -> Result<(), String> { 23 | err_log_to_string!(fs::write(path, content).await) 24 | } 25 | 26 | pub(crate) trait CommandsRegister { 27 | /// Implements custom commands. 28 | fn impl_commands(self) -> Self; 29 | } 30 | 31 | impl CommandsRegister for Builder { 32 | fn impl_commands(self) -> Self { 33 | self.invoke_handler(tauri::generate_handler![ 34 | change_log_level, 35 | config::read_config, 36 | config::write_config, 37 | device_watcher::get_devices, 38 | device_watcher::restart_device_watcher, 39 | interval::restart_interval, 40 | system_tray::default_tray, 41 | system_tray::update_tray, 42 | write_file, 43 | ]) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /gui/backend/src/cmd/supports/icon.rs: -------------------------------------------------------------------------------- 1 | use ab_glyph::{FontRef, PxScale}; 2 | use image::{Rgba, RgbaImage}; 3 | use imageproc::drawing::draw_text_mut; 4 | use tauri::image::Image; 5 | 6 | /// Get background color and text color based on the battery level 7 | const fn get_colors(battery_level: u64, is_connected: bool) -> (Rgba, Rgba) { 8 | const GRAY: Rgba = Rgba([89, 89, 89, 255]); 9 | const RED: Rgba = Rgba([255, 0, 0, 255]); 10 | const WHITE: Rgba = Rgba([255, 255, 255, 255]); 11 | const BLUE: Rgba = Rgba([0, 128, 255, 255]); 12 | const BLACK: Rgba = Rgba([0, 0, 0, 255]); 13 | const YELLOW: Rgba = Rgba([255, 255, 0, 255]); 14 | 15 | if !is_connected { 16 | return (GRAY, WHITE); 17 | } 18 | 19 | match battery_level { 20 | 0..=10 => (RED, WHITE), 21 | 11..=30 => (YELLOW, BLACK), 22 | 31.. => (BLUE, WHITE), 23 | } 24 | } 25 | 26 | /// Create a battery icon image 27 | pub(crate) fn create_battery_image( 28 | width: u32, 29 | height: u32, 30 | battery_level: u64, 31 | is_connected: bool, 32 | ) -> Image<'static> { 33 | // Get the background color and text color 34 | let (background_color, text_color) = get_colors(battery_level, is_connected); 35 | 36 | // Create an image buffer for the icon 37 | let mut img = RgbaImage::from_pixel(width, height, background_color); 38 | 39 | // Load the font (use a system TTF file if necessary) 40 | let font_data = include_bytes!("../../../fonts/DejaVuSans/ttf/DejaVuSans.ttf"); 41 | #[allow(clippy::expect_used)] 42 | let font = FontRef::try_from_slice(font_data).expect("Error loading font"); 43 | 44 | // Set font size and text position 45 | let scale = PxScale { 46 | x: width as f32 * 0.80, // Scale relative to icon size 47 | y: height as f32 * 0.80, 48 | }; 49 | let text = format!("{}", battery_level); // Convert battery level to string 50 | 51 | // Calculate text position to center it 52 | let text_width = (scale.x * text.len() as f32 * 0.5) as i32; // Approximate text width 53 | let text_x = (width as i32 - text_width) / 2; 54 | let text_y = (height as i32 - (scale.y as i32)) / 2; 55 | 56 | draw_text_mut(&mut img, text_color, text_x, text_y, scale, &font, &text); 57 | 58 | // Convert the buffer into a `tauri::image::Image` 59 | let rgba = img.into_raw(); 60 | Image::new_owned(rgba, width, height) 61 | } 62 | -------------------------------------------------------------------------------- /gui/backend/src/cmd/supports/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod icon; 2 | pub(super) mod notify; 3 | -------------------------------------------------------------------------------- /gui/backend/src/cmd/supports/notify.rs: -------------------------------------------------------------------------------- 1 | use tauri::AppHandle; 2 | use tauri_plugin_notification::NotificationExt as _; 3 | 4 | /// # Info 5 | /// Write to the log when an error occurs. 6 | /// 7 | /// # Ref 8 | /// - https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/notification 9 | /// - https://zenn.dev/8beeeaaat/scraps/211b820f5c14d7 10 | pub fn notify(app: &AppHandle, message: &str) -> Result<(), tauri_plugin_notification::Error> { 11 | // See: [[bug] No notification sound on Windows](https://github.com/tauri-apps/tauri/issues/6652) 12 | #[cfg(windows)] 13 | if let Err(err) = bluetooth::utils::play_asterisk() { 14 | tracing::error!("Failed to play sound: {err}"); 15 | } 16 | 17 | app.notification() 18 | .builder() 19 | .title("[Bluetooth Battery Monitor]") 20 | .body(message) 21 | .show() 22 | } 23 | -------------------------------------------------------------------------------- /gui/backend/src/error.rs: -------------------------------------------------------------------------------- 1 | //! errors of `This crate` 2 | use std::{io, path::PathBuf}; 3 | 4 | /// GUI Error 5 | #[derive(Debug, snafu::Snafu)] 6 | #[snafu(visibility(pub))] 7 | pub enum Error { 8 | /// Failed to read file from 9 | #[snafu(display("{source}: {}", path.display()))] 10 | FailedReadFile { source: io::Error, path: PathBuf }, 11 | 12 | /// Standard io error 13 | #[snafu(transparent)] 14 | FailedIo { source: io::Error }, 15 | 16 | /// Not found resource dir 17 | NotFoundResourceDir { source: tauri::Error }, 18 | 19 | /// Failed to reinitialize device watcher. 20 | FailedInitDeviceWatcher, 21 | 22 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 23 | // Logger 24 | /// Not found log dir 25 | NotFoundLogDir { source: tauri::Error }, 26 | 27 | /// Failed to initialize logger. 28 | FailedInitLog, 29 | 30 | /// Uninitialized logger. 31 | UninitializedLogger, 32 | 33 | /// Tracing log error 34 | #[snafu(transparent)] 35 | FailedSetTracing { 36 | source: tracing::subscriber::SetGlobalDefaultError, 37 | }, 38 | 39 | /// Tracing subscriber reload error 40 | #[snafu(transparent)] 41 | FailedReloadTracingSub { 42 | source: tracing_subscriber::reload::Error, 43 | }, 44 | 45 | #[snafu(transparent)] 46 | Tauri { source: tauri::Error }, 47 | } 48 | 49 | /// `Result` for this crate. 50 | pub type Result = core::result::Result; 51 | -------------------------------------------------------------------------------- /gui/backend/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | mod cmd; 5 | mod error; 6 | mod log; 7 | mod setup; 8 | 9 | use cmd::CommandsRegister as _; 10 | use setup::SetupsRegister as _; 11 | 12 | fn main() { 13 | #[allow(clippy::large_stack_frames)] 14 | if let Err(err) = tauri::Builder::default() 15 | .plugin(tauri_plugin_autostart::init( 16 | tauri_plugin_autostart::MacosLauncher::LaunchAgent, 17 | None, 18 | )) 19 | .plugin(build_window_state_plugin_with_hide()) 20 | .plugin(tauri_plugin_dialog::init()) 21 | .plugin(tauri_plugin_fs::init()) 22 | .plugin(tauri_plugin_notification::init()) 23 | .plugin(tauri_plugin_process::init()) 24 | .plugin(tauri_plugin_shell::init()) 25 | .plugin(tauri_plugin_updater::Builder::new().build()) 26 | .impl_setup() 27 | .impl_commands() 28 | .run(tauri::generate_context!()) 29 | { 30 | tracing::error!("Error: {err}"); 31 | std::process::exit(1); 32 | } 33 | } 34 | 35 | /// Avoid auto show(To avoid white flash screen) 36 | /// - ref: https://github.com/tauri-apps/plugins-workspace/issues/344 37 | fn build_window_state_plugin_with_hide() -> tauri::plugin::TauriPlugin { 38 | use tauri_plugin_window_state::{Builder, StateFlags}; 39 | 40 | Builder::default() 41 | .with_state_flags(StateFlags::all() & !StateFlags::VISIBLE) 42 | .build() 43 | } 44 | 45 | /// Result -> Log & toString 46 | #[macro_export] 47 | macro_rules! err_log_to_string { 48 | ($exp:expr) => { 49 | $exp.map_err(|err| { 50 | tracing::error!("{err}"); 51 | err.to_string() 52 | }) 53 | }; 54 | } 55 | 56 | #[macro_export] 57 | macro_rules! err_log { 58 | ($exp:expr) => { 59 | if let Err(err) = $exp { 60 | tracing::error!("{err}"); 61 | } 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /gui/backend/src/setup/mod.rs: -------------------------------------------------------------------------------- 1 | mod tray_menu; 2 | mod window_event; 3 | 4 | use self::tray_menu::new_tray_menu; 5 | use self::window_event::window_event; 6 | use crate::{ 7 | cmd::{device_watcher::restart_device_watcher_inner, interval::restart_interval}, 8 | err_log, 9 | }; 10 | use tauri::{Builder, Manager, Wry}; 11 | pub use tray_menu::TRAY_ICON; 12 | 13 | pub(crate) trait SetupsRegister { 14 | /// Implements custom setup. 15 | fn impl_setup(self) -> Self; 16 | } 17 | 18 | impl SetupsRegister for Builder { 19 | fn impl_setup(self) -> Self { 20 | self.setup(|app| { 21 | crate::log::init(app)?; 22 | 23 | let app = app.app_handle(); 24 | new_tray_menu(app)?; 25 | let app = app.clone(); 26 | tauri::async_runtime::spawn(async move { 27 | let app = app; 28 | err_log!(restart_device_watcher_inner(&app).await); 29 | restart_interval(app).await; 30 | }); 31 | 32 | Ok(()) 33 | }) 34 | .on_window_event(window_event) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /gui/backend/src/setup/window_event.rs: -------------------------------------------------------------------------------- 1 | use crate::err_log; 2 | use tauri::{Runtime, Window, WindowEvent}; 3 | 4 | /// To prevent exit application by X button. 5 | /// 6 | /// # Note 7 | /// This signature is defined by a function that takes a function pointer as an argument and cannot return an error. 8 | /// Therefore, it should be logged by the logger. 9 | pub fn window_event(window: &Window, event: &WindowEvent) { 10 | if let tauri::WindowEvent::CloseRequested { api, .. } = event { 11 | err_log!(window.hide()); 12 | api.prevent_close(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /gui/backend/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", 3 | "app": { 4 | "security": { 5 | "csp": null, 6 | "pattern": { 7 | "use": "brownfield" 8 | } 9 | }, 10 | "windows": [ 11 | { 12 | "fullscreen": false, 13 | "height": 920, 14 | "resizable": true, 15 | "theme": "Dark", 16 | "title": "Bluetooth Battery Monitor", 17 | "transparent": true, 18 | "visible": false, 19 | "width": 1030, 20 | "windowEffects": { 21 | "effects": ["micaDark"] 22 | } 23 | } 24 | ], 25 | "withGlobalTauri": true 26 | }, 27 | "build": { 28 | "beforeBuildCommand": "npm run build:front", 29 | "beforeDevCommand": "npm run dev:front", 30 | "devUrl": "http://localhost:3000", 31 | "features": [], 32 | "frontendDist": "../frontend/out" 33 | }, 34 | "bundle": { 35 | "active": true, 36 | "category": "DeveloperTool", 37 | "copyright": "SARDONYX-sard", 38 | "createUpdaterArtifacts": true, 39 | "externalBin": [], 40 | "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], 41 | "licenseFile": "../../LICENSE-MIT", 42 | "longDescription": "", 43 | "macOS": { 44 | "entitlements": null, 45 | "exceptionDomain": "", 46 | "frameworks": [], 47 | "providerShortName": null, 48 | "signingIdentity": null 49 | }, 50 | "resources": [], 51 | "shortDescription": "", 52 | "targets": "all", 53 | "windows": { 54 | "certificateThumbprint": null, 55 | "digestAlgorithm": "sha256", 56 | "timestampUrl": "" 57 | } 58 | }, 59 | "identifier": "SARDONYX.bluetooth-battery-monitor", 60 | "plugins": { 61 | "updater": { 62 | "endpoints": ["https://github.com/SARDONYX-sard/bluetooth-battery-monitor/releases/latest/download/latest.json"], 63 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhDMjAwN0NBMDg3QjgzRQpSV1ErdUllZ2ZBRENDQmI3UGdBTllQemY5dFl4aUZiSVdlZDIxL1lOSzVEdlNuazdsMDRlQTFicwo=", 64 | "windows": { 65 | "installMode": "passive" 66 | } 67 | } 68 | }, 69 | "productName": "bluetooth_battery_monitor", 70 | "version": "../../package.json" 71 | } 72 | -------------------------------------------------------------------------------- /gui/frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // For compatibility with Biome (fast linter&fmt made by Rust), the following settings are made 2 | // to compensate for missing functions of Biome with eslint. 3 | 4 | // @ts-check 5 | 6 | /** @typedef {import('eslint').ESLint.ConfigData} ConfigData */ 7 | 8 | /** @type {ConfigData} */ 9 | module.exports = { 10 | extends: ['next/core-web-vitals'], 11 | settings: { 12 | // NOTE: eslint cannot resolve aliases by `@` without the following two settings 13 | // See:https://github.com/import-js/eslint-plugin-import/issues/2765#issuecomment-1701641064 14 | next: { 15 | rootDir: __dirname, 16 | }, 17 | 'import/resolver': { 18 | typescript: { 19 | project: __dirname, 20 | }, 21 | }, 22 | }, 23 | 24 | // `next/core-web-vitals` of `eslint-config-next` has sort import and `sort-props` functions, so use them. 25 | rules: { 26 | 'import/order': [ 27 | 'warn', 28 | { 29 | alphabetize: { 30 | caseInsensitive: true, 31 | order: 'asc', 32 | }, 33 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], 34 | 'newlines-between': 'always', 35 | pathGroupsExcludedImportTypes: ['builtin'], 36 | pathGroups: [ 37 | { pattern: '@/**', group: 'internal', position: 'before' }, 38 | // styles 39 | // treat group as index because we want it to be last 40 | { pattern: '@/**.css', group: 'index', position: 'before' }, 41 | { pattern: '@/**.json', group: 'index', position: 'before' }, 42 | ], 43 | }, 44 | ], 45 | 'react/jsx-sort-props': 'warn', 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /gui/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env*.local 28 | 29 | # vercel 30 | .vercel 31 | # typescript 32 | *.tsbuildinfo 33 | next-env.d.ts 34 | 35 | test/ 36 | -------------------------------------------------------------------------------- /gui/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | distDir: './out', 4 | output: 'export', 5 | reactStrictMode: true, 6 | experimental: { 7 | reactCompiler: true, 8 | }, 9 | }; 10 | 11 | export default nextConfig; 12 | -------------------------------------------------------------------------------- /gui/frontend/readme.md: -------------------------------------------------------------------------------- 1 | # GUI Frontend 2 | -------------------------------------------------------------------------------- /gui/frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /gui/frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-mono: 3 | ui-monospace, Menlo, Monaco, "Cascadia·Mono", "Segoe·UI·Mono", "Roboto·Mono", "Oxygen·Mono", 4 | "Ubuntu·Monospace", "Source·Code·Pro", "Fira·Mono", "Droid·Sans·Mono", "Courier·New", monospace; 5 | } 6 | 7 | @media (prefers-color-scheme: dark) { 8 | html { 9 | color-scheme: dark; 10 | } 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | padding: 0; 16 | margin: 0; 17 | } 18 | 19 | html, 20 | body { 21 | max-width: 100vw; 22 | min-height: 100vh; 23 | overflow-x: hidden; 24 | background-color: #121212; 25 | } 26 | 27 | a { 28 | color: inherit; 29 | text-decoration: none; 30 | } 31 | -------------------------------------------------------------------------------- /gui/frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export { metadata } from '@/components/meta/meta'; 2 | import ClientLayout from '@/components/layout/ClientLayout'; 3 | import { inter } from '@/components/meta/font'; 4 | 5 | import type { ReactNode } from 'react'; 6 | 7 | import '@/app/globals.css'; 8 | 9 | type Props = Readonly<{ 10 | children: ReactNode; 11 | }>; 12 | export default function RootLayout({ children }: Props) { 13 | return ( 14 | 15 | 16 | {children} 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /gui/frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Top } from '@/components/templates/Top'; 2 | 3 | /** 4 | * Root page (URL: /). 5 | */ 6 | export default function Page() { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /gui/frontend/src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Settings } from '@/components/templates/Settings'; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /gui/frontend/src/components/atoms/CircularWithLabel/index.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import CircularProgress, { type CircularProgressProps } from '@mui/material/CircularProgress'; 3 | import Typography from '@mui/material/Typography'; 4 | 5 | /** 6 | * https://mui.com/material-ui/react-progress/#circular-with-label 7 | */ 8 | export function CircularProgressWithLabel(props: CircularProgressProps & { progColor?: string; value: number }) { 9 | const { progColor, value, ...rest } = props; 10 | 11 | const style = { color: progColor }; 12 | 13 | return ( 14 | 15 | 16 | 28 | {`${Math.round(value)}%`} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /gui/frontend/src/components/atoms/Help/Help.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | 4 | import { Help } from './Help'; 5 | 6 | describe('Help Component', () => { 7 | const version = '1.0.0'; 8 | 9 | beforeEach(() => { 10 | render(); 11 | }); 12 | 13 | it('should render the version correctly', () => { 14 | expect(screen.getByText(`Version: ${version}`)).toBeInTheDocument(); 15 | }); 16 | 17 | it('should apply the correct styles to the Box component', () => { 18 | const boxElement = screen.getByText(`Version: ${version}`).parentElement; 19 | // console.log(prettyDOM(boxElement)); // Show HTML for debug 20 | 21 | expect(boxElement).toHaveStyle({ 22 | display: 'flex', 23 | alignItems: 'center', 24 | flexDirection: 'column', 25 | height: '100%', 26 | justifyContent: 'space-evenly', 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /gui/frontend/src/components/atoms/Help/Help.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button } from '@mui/material'; 2 | 3 | import type { ComponentPropsWithoutRef } from 'react'; 4 | 5 | type Props = { 6 | version: string; 7 | } & ComponentPropsWithoutRef; 8 | export const Help = ({ version, ...props }: Props) => { 9 | return ( 10 | 19 |
Version: {version}
20 |
21 | Source:{' '} 22 | 25 |
26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /gui/frontend/src/components/atoms/Help/index.tsx: -------------------------------------------------------------------------------- 1 | export { Help } from './Help'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useDebounce/index.ts: -------------------------------------------------------------------------------- 1 | export { useDebounce } from './useDebounce'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useDebounce/useDebounce.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | 4 | import { useDebounce } from './useDebounce'; 5 | 6 | // NOTE: See below for how to test with Timer. 7 | // -ref: https://vitest.dev/guide/mocking#example 8 | 9 | describe('useDebounce', () => { 10 | beforeEach(() => { 11 | vi.useFakeTimers(); // Setup functions required to use `advanceTimersByTime`. 12 | }); 13 | 14 | afterEach(() => { 15 | vi.useRealTimers(); // restoring date after each test run 16 | }); 17 | 18 | it('should return the initial value immediately', () => { 19 | const { result } = renderHook(() => useDebounce('test', 500)); 20 | expect(result.current).toBe('test'); 21 | }); 22 | 23 | it('should debounce the value', () => { 24 | const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { 25 | initialProps: { value: 'initial', delay: 500 }, 26 | }); 27 | 28 | expect(result.current).toBe('initial'); 29 | 30 | rerender({ value: 'updated', delay: 500 }); 31 | 32 | // The value should not change immediately 33 | expect(result.current).toBe('initial'); 34 | 35 | act(() => { 36 | vi.advanceTimersByTime(500); 37 | }); 38 | 39 | expect(result.current).toBe('updated'); 40 | }); 41 | 42 | it('should reset the timer when the value changes quickly', () => { 43 | const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { 44 | initialProps: { value: 'initial', delay: 500 }, 45 | }); 46 | 47 | expect(result.current).toBe('initial'); 48 | 49 | // Update the value multiple times 50 | rerender({ value: 'first update', delay: 500 }); 51 | act(() => { 52 | vi.advanceTimersByTime(200); 53 | }); 54 | rerender({ value: 'second update', delay: 500 }); 55 | act(() => { 56 | vi.advanceTimersByTime(200); 57 | }); 58 | 59 | // The value should still be the initial value 60 | expect(result.current).toBe('initial'); 61 | 62 | // Fast-forward time by 500ms 63 | act(() => { 64 | vi.advanceTimersByTime(500); 65 | }); 66 | 67 | // Now the value should update to the latest one 68 | expect(result.current).toBe('second update'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useDebounce/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Custom React hook to debounce a value. The value updates after the specified delay time. 5 | * 6 | * @template T The type of the value to debounce. 7 | * @param value The value to debounce. 8 | * @param delay The debounce delay in milliseconds. 9 | * @returns The debounced value. 10 | * 11 | * @example 12 | * const [search, setSearch] = useState(''); 13 | * const debouncedSearch = useDebounce(search, 500); 14 | * 15 | * useEffect(() => { 16 | * // Perform a search API call with debouncedSearch. 17 | * }, [debouncedSearch]); 18 | */ 19 | export function useDebounce(value: T, delay: number): T { 20 | const [debouncedValue, setDebouncedValue] = useState(value); 21 | 22 | useEffect(() => { 23 | const handler = setTimeout(() => { 24 | setDebouncedValue(value); 25 | }, delay); 26 | 27 | return () => { 28 | clearTimeout(handler); 29 | }; 30 | }, [value, delay]); 31 | 32 | return debouncedValue; 33 | } 34 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectCss/index.ts: -------------------------------------------------------------------------------- 1 | export { useInjectCss } from './useInjectCss'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectCss/useInjectCss.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | 4 | import { CSS } from '@/lib/css'; 5 | 6 | import { useInjectCss } from './useInjectCss'; 7 | 8 | describe('useInjectCss hook', () => { 9 | beforeEach(() => { 10 | document.head.innerHTML = ''; // Clear document.head before each test 11 | }); 12 | 13 | it('should inject CSS into the document on mount', () => { 14 | CSS.preset.set('1'); 15 | const { result } = renderHook(() => useInjectCss()); 16 | 17 | const styleElement = document.getElementById(CSS.css.id); 18 | expect(styleElement).not.toBeNull(); 19 | 20 | expect(result.current.preset).toBe('1'); 21 | expect(result.current.css).toBe(CSS.css.get('1')); 22 | }); 23 | 24 | it('should update CSS when setCss is called', () => { 25 | CSS.preset.set('0'); 26 | CSS.css.set('body { background: black; }'); 27 | const { result } = renderHook(() => useInjectCss()); 28 | 29 | act(() => { 30 | result.current.setCss('body { color: red; }'); 31 | }); 32 | 33 | const styleElement = document.getElementById(CSS.css.id); 34 | expect(styleElement?.innerHTML).toBe('body { color: red; }'); 35 | }); 36 | 37 | it('should update preset when setPreset is called', () => { 38 | const { result } = renderHook(() => useInjectCss()); 39 | 40 | act(() => result.current.setPreset('1')); 41 | 42 | expect(result.current.preset).toBe('1'); 43 | }); 44 | 45 | it('should remove the style element on unmount', () => { 46 | const { unmount } = renderHook(() => useInjectCss()); 47 | 48 | const styleElement = document.getElementById(CSS.css.id); 49 | expect(styleElement).not.toBeNull(); 50 | 51 | // Unmount the hook and check that the style element is removed 52 | unmount(); 53 | expect(document.getElementById(CSS.css.id)).toBeNull(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectCss/useInjectCss.ts: -------------------------------------------------------------------------------- 1 | import { useInsertionEffect, useRef, useState } from 'react'; 2 | 3 | import { CSS, type CssPresets } from '@/lib/css'; 4 | import { NOTIFY } from '@/lib/notify'; 5 | 6 | /** 7 | * Inject CSS dynamically on the client side. 8 | */ 9 | export function useInjectCss() { 10 | const [preset, setPreset] = useState(CSS.preset.get()); 11 | const [css, setCss] = useState(CSS.css.get(preset)); 12 | const style = useRef(null); 13 | 14 | const setPresetHook = (value: CssPresets) => { 15 | setPreset(value); 16 | CSS.preset.set(value); 17 | }; 18 | 19 | const setHook = (value?: string) => { 20 | setCss(value ?? ''); 21 | }; 22 | 23 | // NOTE: Frequent style recalculation is inevitable, but this hook can solve the delay problem caused by style injection lifecycle discrepancies. 24 | // - See: [useInsertionEffect](https://react.dev/reference/react/useInsertionEffect) 25 | useInsertionEffect(() => { 26 | const styleElement = document.createElement('style'); 27 | 28 | if (!style.current) { 29 | styleElement.id = CSS.css.id; // Assign ID so that user can edit 30 | styleElement.innerHTML = css; 31 | style.current = styleElement; 32 | NOTIFY.try(() => document.head.appendChild(styleElement)); 33 | } 34 | 35 | return () => { 36 | style.current?.remove(); 37 | style.current = null; 38 | }; 39 | }, [css]); 40 | 41 | return { preset, setPreset: setPresetHook, css, setCss: setHook } as const; 42 | } 43 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectJs/index.ts: -------------------------------------------------------------------------------- 1 | export { useInjectJs } from './useInjectJs'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectJs/useInjectJs.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | 4 | import { JsProvider } from '@/components/providers/JsProvider'; 5 | import { STORAGE } from '@/lib/storage'; 6 | import { HIDDEN_CACHE_OBJ, PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 7 | 8 | import { useInjectJs } from '../useInjectJs'; 9 | 10 | const InnerComponent = () => { 11 | useInjectJs(); 12 | return
Test Component
; 13 | }; 14 | 15 | const TestComponent = () => { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | const enableExecJs = () => STORAGE.setHidden(HIDDEN_CACHE_OBJ.runScript, 'true'); 24 | 25 | // Test suite 26 | describe('useInjectScript', () => { 27 | beforeEach(() => { 28 | localStorage.clear(); 29 | }); 30 | 31 | it('should inject the script when `run-script` is true', () => { 32 | const jsCode = 'console.log("Test script loaded")'; 33 | STORAGE.set(PUB_CACHE_OBJ.customJs, jsCode); 34 | enableExecJs(); 35 | 36 | const { unmount } = render(); 37 | 38 | expect(document.body.querySelector('script')).toBeInTheDocument(); 39 | expect(document.body.querySelector('script')?.innerHTML).toBe(jsCode); 40 | 41 | unmount(); 42 | 43 | // Cleanup check 44 | expect(document.body.querySelector('script')).not.toBeInTheDocument(); 45 | }); 46 | 47 | it('should not inject the script if `run-script` is not true', () => { 48 | const { unmount } = render(); 49 | expect(document.body.querySelector('script')).not.toBeInTheDocument(); 50 | 51 | unmount(); 52 | }); 53 | 54 | it('should not inject the script again if already run', () => { 55 | enableExecJs(); 56 | 57 | const { unmount } = render(); 58 | unmount(); 59 | 60 | render(); 61 | 62 | expect(document.body.querySelector('script')).toBeInTheDocument(); 63 | expect(document.body.querySelectorAll('script').length).toBe(1); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useInjectJs/useInjectJs.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { useJsContext } from '@/components/providers/JsProvider'; 4 | 5 | /** 6 | * Inject JavaScript 7 | * By calling this hook on a page-by-page basis, js is executed at each page transition. 8 | * 9 | * # Note 10 | * If we load it with `layout.tsx`, it doesn't apply for some reason. 11 | */ 12 | export function useInjectJs() { 13 | const { js, setJs, runScript } = useJsContext(); 14 | const script = useRef(null); 15 | // # HACK: To avoid double call `useEffect` 16 | // If there is no cleanup function (during development), double mounting will not occur. 17 | // 18 | // However, since we want to perform cleanup and pass the test, we set a separate flag and 19 | // do not cleanup the ref of the flag to achieve the purpose. 20 | const isMounted = useRef(false); 21 | 22 | useEffect(() => { 23 | if (!runScript || isMounted.current || script.current) { 24 | return; // Skip if already run 25 | } 26 | isMounted.current = true; 27 | 28 | const scriptElement = document.createElement('script'); 29 | scriptElement.innerHTML = js; 30 | document.body.appendChild(scriptElement); // Throw `DOMException` 31 | script.current = scriptElement; 32 | 33 | return () => { 34 | script.current?.remove(); 35 | script.current = null; 36 | }; 37 | }, [js, runScript]); 38 | 39 | return { js, setJs } as const; 40 | } 41 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useRelativeTime.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from '@/components/hooks/useTranslation'; 2 | 3 | export const useRelativeTime = (date: string) => { 4 | const { t } = useTranslation(); 5 | 6 | const relativeTime = () => { 7 | const now = new Date(); 8 | const then = new Date(date); 9 | const diff = now.getTime() - then.getTime(); // milliseconds diff 10 | const seconds = Math.floor(diff / 1000); 11 | const minutes = Math.floor(seconds / 60); 12 | const hours = Math.floor(minutes / 60); 13 | const days = Math.floor(hours / 24); 14 | 15 | if (days > 0) { 16 | return `${days}${t('relativeTime.days')}`; 17 | } 18 | if (hours > 0) { 19 | return `${hours}${t('relativeTime.hours')}`; 20 | } 21 | if (minutes > 0) { 22 | return `${minutes}${t('relativeTime.minutes')}`; 23 | } 24 | return `${seconds}${t('relativeTime.seconds')}`; 25 | }; 26 | 27 | return relativeTime(); 28 | }; 29 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useStorageState/index.ts: -------------------------------------------------------------------------------- 1 | export { useStorageState } from './useStorageState'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useStorageState/useStorageState.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | import { z } from 'zod'; 4 | 5 | import { type Cache, STORAGE } from '@/lib/storage'; 6 | 7 | import { useStorageState } from './useStorageState'; 8 | 9 | const mockKey = 'log-level' satisfies keyof Cache; 10 | 11 | // Define a basic Zod schema for validation 12 | const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error']); 13 | 14 | describe('useStorageState', () => { 15 | beforeEach(() => { 16 | localStorage.clear(); 17 | }); 18 | 19 | it('should initialize with fallback state if no STORAGE value exists', () => { 20 | const { result } = renderHook(() => useStorageState(mockKey, logLevelSchema.catch('info'))); 21 | 22 | expect(result.current[0]).toBe('info'); 23 | }); 24 | 25 | it('should initialize with STORAGE value if it exists and is valid', () => { 26 | STORAGE.set(mockKey, JSON.stringify('debug')); 27 | 28 | const { result } = renderHook(() => useStorageState(mockKey, logLevelSchema.catch('info'))); 29 | 30 | expect(result.current[0]).toBe('debug'); 31 | }); 32 | 33 | it('should save a new value to STORAGE when updated', () => { 34 | const { result } = renderHook(() => useStorageState(mockKey, logLevelSchema.catch('info'))); 35 | 36 | act(() => { 37 | result.current[1]('warn'); 38 | }); 39 | 40 | expect(result.current[0]).toBe('warn'); 41 | expect(STORAGE.get(mockKey)).toBe(JSON.stringify('warn')); 42 | }); 43 | 44 | it('should not update STORAGE if the value is the same as the current value', () => { 45 | STORAGE.set(mockKey, JSON.stringify('info')); 46 | 47 | const { result } = renderHook(() => useStorageState(mockKey, logLevelSchema.catch('info'))); 48 | 49 | act(() => { 50 | result.current[1]('info'); 51 | }); 52 | 53 | expect(STORAGE.get(mockKey)).toBe(JSON.stringify('info')); 54 | }); 55 | 56 | it('should handle objects correctly', () => { 57 | const objectSchema = z.object({ theme: z.string() }).catch({ theme: 'light' }); 58 | const { result } = renderHook(() => useStorageState(mockKey, objectSchema)); 59 | 60 | act(() => { 61 | result.current[1]({ theme: 'dark' }); 62 | }); 63 | 64 | expect(result.current[0]).toEqual({ theme: 'dark' }); 65 | expect(STORAGE.get(mockKey)).toBe(JSON.stringify({ theme: 'dark' })); 66 | }); 67 | 68 | it('should gracefully handle invalid JSON in STORAGE', () => { 69 | STORAGE.set(mockKey, 'invalidJSON'); 70 | 71 | const { result } = renderHook(() => useStorageState(mockKey, logLevelSchema.catch('info'))); 72 | 73 | // Should fall back to the default value provided in the schema 74 | expect(result.current[0]).toBe('info'); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useStorageState/useStorageState.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; 2 | 3 | import type { CacheKeyWithHide } from '@/lib/storage'; 4 | import { stringToJsonSchema } from '@/lib/zod/json-validation'; 5 | 6 | import type { ZodCatch, ZodType } from 'zod'; 7 | 8 | /** 9 | * A custom React hook that syncs state with `localStorage`. 10 | * 11 | * This hook behaves like `useState`, but persists the state in `localStorage`. 12 | * It validates the state using a Zod schema and ensures type safety. If the `catch` method is not used in the schema, 13 | * it can lead to unexpected errors (panics). 14 | * 15 | * @template T - The type of the state value. 16 | * @param {string} key - The key to store the value in `localStorage`. 17 | * @param {z.ZodType} schema - The Zod schema to validate and parse the state value. 18 | * 19 | * @returns {[T, (newValue: T) => void]} A stateful value and a function to update it, which also updates `localStorage`. 20 | * 21 | * @example 22 | * // Using a string enum with a fallback using `.catch()`: 23 | * const [option, setOption] = useStorageState('option', z.enum(['a', 'b', 'c']).catch('a')); 24 | * 25 | * // Using an object schema with a fallback using `.catch()`: 26 | * const [settings, setSettings] = useStorageState('settings', z.object({ theme: z.string() }).catch({ theme: 'light' })); 27 | * 28 | * @description 29 | * # Note 30 | * In schema, `.catch()` must always be called. Otherwise, an unhandled error will occur. 31 | */ 32 | export function useStorageState, U = unknown>( 33 | key: CacheKeyWithHide, 34 | schema: ZodCatch, 35 | ): StateTuple { 36 | const [value, setValue] = useState(getCacheValue(key, schema)); 37 | 38 | useEffect(() => { 39 | localStorage.setItem(key, JSON.stringify(value)); 40 | }, [value, key]); 41 | 42 | return [value, setValue] as const; 43 | } 44 | 45 | /** Return value of useState with ZodCatch */ 46 | export type StateTuple, U = unknown> = [T['_output'], Dispatch>]; 47 | 48 | /** Helper function to retrieve the cache value and parse it with Zod schema, applying fallback with catch and default */ 49 | const getCacheValue = , U = unknown>(key: CacheKeyWithHide, schema: ZodCatch): T['_output'] => { 50 | return stringToJsonSchema.catch(null).pipe(schema).parse(localStorage.getItem(key)); 51 | }; 52 | -------------------------------------------------------------------------------- /gui/frontend/src/components/hooks/useTranslation.ts: -------------------------------------------------------------------------------- 1 | import { type FallbackNs, type UseTranslationOptions, useTranslation as useI18n } from 'react-i18next'; 2 | 3 | import type { I18nKeys } from '@/lib/i18n'; 4 | 5 | import type { FlatNamespace, KeyPrefix } from 'i18next'; 6 | 7 | type $Tuple = readonly [T?, ...T[]]; 8 | 9 | type UseTranslation = < 10 | Ns extends FlatNamespace | $Tuple | undefined = undefined, 11 | KeyPre extends KeyPrefix> = undefined, 12 | >( 13 | ns?: Ns, 14 | options?: UseTranslationOptions, 15 | ) => { 16 | t: (key: T) => string; 17 | }; 18 | 19 | // - ref: https://www.i18next.com/overview/typescript 20 | 21 | /** 22 | * useTranslation(react-i18next) Wrapper for type completion of translation keys. 23 | */ 24 | export const useTranslation: UseTranslation = (ns, options) => { 25 | const { t } = useI18n(ns, options); 26 | return { 27 | /** Get translation key. */ 28 | t: (key) => t(key), 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /gui/frontend/src/components/layout/ClientLayout/ClientLayout.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Luma 2 | // SPDX-License-Identifier: MIT or Apache-2.0 3 | import { PageNavigation } from '@/components/organisms/PageNavigation'; 4 | import { GlobalProvider } from '@/components/providers'; 5 | import { LANG } from '@/lib/i18n'; 6 | import { LOG } from '@/services/api/log'; 7 | 8 | import type { ReactNode } from 'react'; 9 | 10 | LANG.init(); 11 | LOG.changeLevel(LOG.get()); 12 | 13 | type Props = Readonly<{ 14 | children: ReactNode; 15 | }>; 16 | 17 | const ClientLayout = ({ children }: Props) => { 18 | return ( 19 | 20 | {children} 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default ClientLayout; 27 | -------------------------------------------------------------------------------- /gui/frontend/src/components/layout/ClientLayout/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; // If this directive is not present on each page, a build error will occur. 2 | 3 | // NOTE: From next15, { ssr: false } can only be called by the Client component. 4 | import dynamic from 'next/dynamic'; 5 | 6 | import Loading from '@/components/templates/Loading'; 7 | 8 | import type { ReactNode } from 'react'; 9 | 10 | const DynClientLayout = dynamic(() => import('@/components/layout/ClientLayout/ClientLayout'), { 11 | loading: () => , 12 | ssr: false, 13 | }); 14 | 15 | type Props = Readonly<{ 16 | children: ReactNode; 17 | }>; 18 | 19 | const ClientLayout = ({ children }: Props) => { 20 | return {children}; 21 | }; 22 | 23 | export default ClientLayout; 24 | -------------------------------------------------------------------------------- /gui/frontend/src/components/layout/readme.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/frontend/src/components/layout/readme.md -------------------------------------------------------------------------------- /gui/frontend/src/components/meta/font.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google'; 2 | 3 | export const inter = Inter({ subsets: ['latin'] }); 4 | -------------------------------------------------------------------------------- /gui/frontend/src/components/meta/meta.ts: -------------------------------------------------------------------------------- 1 | import packageJson from '@/../../package.json'; 2 | 3 | import type { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: packageJson.name, 7 | description: packageJson.description, 8 | }; 9 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import FolderOpenIcon from '@mui/icons-material/FolderOpen'; 2 | import { type ButtonProps, Button as Button_, type SxProps, type Theme } from '@mui/material'; 3 | 4 | import { useTranslation } from '@/components/hooks/useTranslation'; 5 | 6 | type Props = ButtonProps; 7 | 8 | const defaultStyle = { 9 | marginTop: '9px', 10 | width: '150px', 11 | height: '55px', 12 | } as const satisfies SxProps; 13 | 14 | export function Button({ sx, ...props }: Props) { 15 | const { t } = useTranslation(); 16 | 17 | return ( 18 | } sx={{ ...defaultStyle, ...sx }} type='button' variant='outlined' {...props}> 19 | {t('select-btn')} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/Button/index.tsx: -------------------------------------------------------------------------------- 1 | export { Button } from './Button'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/ButtonWithToolTip/ButtonWithTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Button, type ButtonProps, Tooltip } from '@mui/material'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | 4 | import type { ReactNode } from 'react'; 5 | 6 | type Props = { 7 | buttonName: ReactNode; 8 | tooltipTitle?: ReactNode; 9 | icon?: ReactNode; 10 | minWidth?: number; 11 | minScreenWidth?: number; 12 | } & ButtonProps; 13 | 14 | export const ButtonWithToolTip = ({ 15 | buttonName, 16 | tooltipTitle, 17 | icon, 18 | minWidth = 97, 19 | minScreenWidth = 740, // Set default value for minimum screen width 20 | ...props 21 | }: Props) => { 22 | const buttonRef = useRef(null); 23 | const [canShowText, setCanShowText] = useState(true); // Default to showing icon 24 | 25 | useEffect(() => { 26 | const updateShowText = () => { 27 | if (buttonRef.current) { 28 | setCanShowText(window.innerWidth > minScreenWidth); 29 | } 30 | }; 31 | 32 | updateShowText(); 33 | window.addEventListener('resize', updateShowText); 34 | 35 | return () => window.removeEventListener('resize', updateShowText); 36 | }, [minScreenWidth]); 37 | 38 | return ( 39 | 40 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/ButtonWithToolTip/index.tsx: -------------------------------------------------------------------------------- 1 | export { ButtonWithToolTip } from './ButtonWithTooltip'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/ImportLangButton/ImportLangButton.tsx: -------------------------------------------------------------------------------- 1 | import { FileOpen } from '@mui/icons-material'; 2 | import { Button, Tooltip } from '@mui/material'; 3 | import { useCallback } from 'react'; 4 | 5 | import { useTranslation } from '@/components/hooks/useTranslation'; 6 | import { NOTIFY } from '@/lib/notify'; 7 | import { STORAGE } from '@/lib/storage'; 8 | import { importLang } from '@/services/api/lang'; 9 | 10 | export const ImportLangButton = () => { 11 | const { t } = useTranslation(); 12 | 13 | const title = ( 14 | <> 15 |

{t('import-lang-tooltip')}

16 |

{t('import-lang-tooltip2')}

17 | 18 | ); 19 | 20 | const handleClick = useCallback(() => { 21 | NOTIFY.asyncTry(async () => { 22 | const contents = await importLang(); 23 | if (contents) { 24 | JSON.parse(contents); // Parse test 25 | STORAGE.set('custom-translation-dict', contents); 26 | STORAGE.set('locale', 'custom'); 27 | window.location.reload(); // To enable 28 | } 29 | }); 30 | }, []); 31 | 32 | return ( 33 | 34 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/ImportLangButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { ImportLangButton } from './ImportLangButton'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/molecules/InputField/InputField.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import TextField from '@mui/material/TextField'; 3 | import { type ComponentPropsWithRef, type ReactNode, useId } from 'react'; 4 | 5 | import { Button } from '@/components/molecules/Button'; 6 | 7 | type Props = { 8 | label: string; 9 | icon: ReactNode; 10 | path: string; 11 | setPath: (path: string) => void; 12 | placeholder?: string; 13 | } & ComponentPropsWithRef; 14 | 15 | export function InputField({ label, icon, path, setPath, placeholder, ...props }: Props) { 16 | const id = useId(); 17 | 18 | return ( 19 | :not(style)': { m: 1 } }}> 20 | 21 | {icon} 22 | setPath(target.value)} 26 | placeholder={placeholder} 27 | sx={{ width: '100%', paddingRight: '10px' }} 28 | value={path} 29 | variant='standard' 30 | /> 31 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { BackupButton } from './BackupButton'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupExportButton/BackupExportButton.tsx: -------------------------------------------------------------------------------- 1 | import FileDownloadIcon from '@mui/icons-material/FileDownload'; 2 | import { useState } from 'react'; 3 | 4 | import { useTranslation } from '@/components/hooks/useTranslation'; 5 | import { BackupButton } from '@/components/organisms/BackupButton'; 6 | import type { DialogClickHandler } from '@/components/organisms/BackupMenuDialog'; 7 | import { NOTIFY } from '@/lib/notify'; 8 | import { STORAGE } from '@/lib/storage'; 9 | import { BACKUP } from '@/services/api/backup'; 10 | 11 | export const BackupExportButton = () => { 12 | const { t } = useTranslation(); 13 | const [open, setOpen] = useState(false); 14 | 15 | const handleClick: DialogClickHandler = (checkedKeys) => { 16 | NOTIFY.asyncTry(async () => { 17 | if (await BACKUP.export(STORAGE.getByKeys(checkedKeys))) { 18 | NOTIFY.success(t('backup-export-success')); 19 | setOpen(false); 20 | } 21 | }); 22 | }; 23 | 24 | return ( 25 | setOpen(true)} 30 | open={open} 31 | setOpen={setOpen} 32 | startIcon={} 33 | title={t('backup-export-dialog-title')} 34 | tooltipTitle={t('backup-export-tooltip')} 35 | /> 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupExportButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { BackupExportButton } from './BackupExportButton'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupImportButton/BackupImportButton.tsx: -------------------------------------------------------------------------------- 1 | import FileOpen from '@mui/icons-material/FileOpen'; 2 | import {} from '@mui/material'; 3 | import { useState } from 'react'; 4 | 5 | import { useTranslation } from '@/components/hooks/useTranslation'; 6 | import { BackupButton } from '@/components/organisms/BackupButton'; 7 | import type { DialogClickHandler } from '@/components/organisms/BackupMenuDialog'; 8 | import { NOTIFY } from '@/lib/notify'; 9 | import { type Cache, STORAGE } from '@/lib/storage'; 10 | import { BACKUP } from '@/services/api/backup'; 11 | 12 | export const BackupImportButton = () => { 13 | const { t } = useTranslation(); 14 | const [settings, setSettings] = useState({}); 15 | const [open, setOpen] = useState(false); 16 | 17 | const handleClick = () => { 18 | NOTIFY.asyncTry(async () => { 19 | const newSettings = await BACKUP.import(); 20 | if (newSettings) { 21 | setSettings(newSettings); 22 | setOpen(true); 23 | } 24 | }); 25 | }; 26 | 27 | const handleDialogClick: DialogClickHandler = (checkedKeys) => { 28 | for (const key of checkedKeys) { 29 | const value = settings[key]; 30 | if (value) { 31 | STORAGE.set(key, value); 32 | } 33 | } 34 | 35 | window.location.reload(); // To enable 36 | }; 37 | 38 | return ( 39 | } 47 | title={t('backup-import-dialog-title')} 48 | tooltipTitle={t('backup-import-tooltip')} 49 | /> 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupImportButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { BackupImportButton } from './BackupImportButton'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/BackupMenuDialog/CacheItem.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, Divider, ListItem, ListItemButton, ListItemIcon, Typography } from '@mui/material'; 2 | 3 | import type { ComponentPropsWithRef, ReactNode } from 'react'; 4 | 5 | type Props = { 6 | title: string; 7 | value?: ReactNode; 8 | selected: boolean; 9 | onToggle: ComponentPropsWithRef['onClick']; 10 | }; 11 | 12 | export const CacheItem = ({ title, value, selected, onToggle }: Props) => { 13 | const labelId = `checkbox-list-label-${title}`; 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | 30 | 31 | {title} 32 | 33 | {value} {/* Also supports cases where value contains
 tags */}
34 |         
35 |       
36 |       
37 |     
38 |   );
39 | };
40 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BackupMenuDialog/CacheValueItem.tsx:
--------------------------------------------------------------------------------
 1 | import { MonacoEditor } from '@/components/organisms/MonacoEditor/MonacoEditor';
 2 | import { useEditorModeContext } from '@/components/providers/EditorModeProvider';
 3 | import { stringToJsonSchema } from '@/lib/zod/json-validation';
 4 | 
 5 | import type React from 'react';
 6 | 
 7 | // Function to calculate the height based on the number of lines in the value
 8 | const calculateHeight = (value: string, lineHeight = 20, minHeight = 40, maxHeight = 500): string => {
 9 |   const lines = value.split('\n').length;
10 |   const height = Math.min(Math.max(lines * lineHeight, minHeight), maxHeight);
11 |   return `${height}px`;
12 | };
13 | 
14 | type Props = {
15 |   cacheKey: string;
16 |   value: string;
17 | };
18 | 
19 | export const CacheValueItem: React.FC = ({ cacheKey, value }: Props) => {
20 |   const { editorMode } = useEditorModeContext();
21 |   const language = getLanguageByKey(cacheKey);
22 | 
23 |   const fmtValue = (() => {
24 |     const json = stringToJsonSchema.safeParse(value);
25 |     if (json.success) {
26 |       return JSON.stringify(json.data, null, 2);
27 |     }
28 |     return value;
29 |   })();
30 |   const editorHeight = calculateHeight(fmtValue);
31 | 
32 |   return (
33 |     
47 |   );
48 | };
49 | 
50 | // Helper function to determine the language based on the key
51 | const getLanguageByKey = (key: string) => {
52 |   switch (key) {
53 |     case 'custom-js':
54 |       return 'javascript';
55 |     case 'custom-css':
56 |       return 'css';
57 |     default:
58 |       return 'json';
59 |   }
60 | };
61 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BackupMenuDialog/CheckBoxControls.tsx:
--------------------------------------------------------------------------------
 1 | import { Box, Checkbox, FormControlLabel } from '@mui/material';
 2 | 
 3 | import { useTranslation } from '@/components/hooks/useTranslation';
 4 | 
 5 | import type { MouseEventHandler } from 'react';
 6 | 
 7 | type Props = {
 8 |   isAllChecked: boolean;
 9 |   isPubAllChecked: boolean;
10 |   onAllCheck: MouseEventHandler;
11 |   onPubCheck: MouseEventHandler;
12 | };
13 | 
14 | export const CheckBoxControls = ({ isAllChecked, isPubAllChecked, onAllCheck, onPubCheck }: Props) => {
15 |   const { t } = useTranslation();
16 |   return (
17 |     
18 |       }
21 |         label={t('backup-dialog-all-checked-label')}
22 |       />
23 |       }
26 |         label={t('backup-dialog-pub-checked-label')}
27 |       />
28 |     
29 |   );
30 | };
31 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BackupMenuDialog/index.tsx:
--------------------------------------------------------------------------------
1 | export { BackupMenuDialog } from './BackupMenuDialog';
2 | export type { DialogClickHandler, BackupMenuDialogProps } from './BackupMenuDialog';
3 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BackupMenuDialog/useCheckBoxState.ts:
--------------------------------------------------------------------------------
 1 | import { useState } from 'react';
 2 | 
 3 | import { OBJECT } from '@/lib/object-utils';
 4 | import { type Cache, PUB_CACHE_KEYS } from '@/lib/storage';
 5 | 
 6 | export const useCheckBoxState = (cacheItems: Cache) => {
 7 |   const [isAllChecked, setIsAllChecked] = useState(false);
 8 |   const [isPubAllChecked, setIsPublicAllChecked] = useState(false);
 9 |   const [checked, setSelectedItems] = useState([]);
10 | 
11 |   const handleToggleItem = (selectedKey: keyof Cache) => () => {
12 |     setSelectedItems((prev) => {
13 |       if (prev.includes(selectedKey)) {
14 |         return prev.filter((key) => key !== selectedKey);
15 |       }
16 |       return [...prev, selectedKey];
17 |     });
18 |     setIsAllChecked(false);
19 |     setIsPublicAllChecked(false);
20 |   };
21 | 
22 |   const handleCheckAll = () => {
23 |     setIsPublicAllChecked(false);
24 |     setIsAllChecked((prev) => {
25 |       const newIsAllChecked = !prev;
26 |       setSelectedItems(newIsAllChecked ? OBJECT.keys(cacheItems) : []);
27 |       return newIsAllChecked;
28 |     });
29 |   };
30 | 
31 |   const handleCheckPubAll = () => {
32 |     setIsAllChecked(false);
33 |     setIsPublicAllChecked((prev) => {
34 |       const newIsPublicChecked = !prev;
35 |       setSelectedItems(newIsPublicChecked ? PUB_CACHE_KEYS : []);
36 |       return newIsPublicChecked;
37 |     });
38 |   };
39 | 
40 |   return {
41 |     isAllChecked,
42 |     isPubAllChecked,
43 |     checked,
44 |     handleToggleItem,
45 |     handleCheckAll,
46 |     handleCheckPubAll,
47 |   };
48 | };
49 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BluetoothGrid/DeviceCards.tsx:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { Box, Skeleton, Stack } from '@mui/material';
 4 | import { useEffect } from 'react';
 5 | 
 6 | import { DeviceCard } from '@/components/organisms/BluetoothGrid/DeviceCard';
 7 | import { useLogLevelContext } from '@/components/providers/LogLevelProvider';
 8 | import { NOTIFY } from '@/lib/notify';
 9 | import { OBJECT } from '@/lib/object-utils';
10 | import { getDevices } from '@/services/api/bluetooth_finder';
11 | import { LOG } from '@/services/api/log';
12 | 
13 | import { useDevicesContext } from './DevicesProvider';
14 | 
15 | export const DeviceCards = () => {
16 |   const { devices, setDevices, config } = useDevicesContext();
17 |   const { logLevel } = useLogLevelContext();
18 | 
19 |   useEffect(() => {
20 |     (async () => {
21 |       await LOG.changeLevel(logLevel);
22 |     })();
23 |   }, [logLevel]);
24 | 
25 |   useEffect(() => {
26 |     NOTIFY.asyncTry(async () => {
27 |       setDevices(await getDevices());
28 |     });
29 |   }, [setDevices]);
30 | 
31 |   return (
32 |     
42 |       {devices ? (
43 |         OBJECT.entries(devices)
44 |           .sort(([_, a], [__, b]) => {
45 |             // sort by is_connected first (true first)
46 |             if (a.is_connected !== b.is_connected) {
47 |               return a.is_connected ? -1 : 1;
48 |             }
49 | 
50 |             return new Date(b.last_used).getTime() - new Date(a.last_used).getTime(); // sort in ascending order based on last_used
51 |           })
52 |           .map(([address, dev]) => {
53 |             return ;
54 |           })
55 |       ) : (
56 |         
57 |       )}
58 |     
59 |   );
60 | };
61 | 
62 | const LoadingSkeletons = () => (
63 |   
64 |     {Array.from({ length: 5 }).map((_, index) => (
65 |       // biome-ignore lint/suspicious/noArrayIndexKey: 
66 |       
67 |     ))}
68 |   
69 | );
70 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BluetoothGrid/RestartButton.tsx:
--------------------------------------------------------------------------------
 1 | import SyncIcon from '@mui/icons-material/Sync';
 2 | import { useCallback } from 'react';
 3 | 
 4 | import { useTranslation } from '@/components/hooks/useTranslation';
 5 | import { ButtonWithToolTip } from '@/components/molecules/ButtonWithToolTip';
 6 | import { NOTIFY } from '@/lib/notify';
 7 | import { getDevices, restartDeviceWatcher, restartInterval } from '@/services/api/bluetooth_finder';
 8 | 
 9 | import { useDevicesContext } from './DevicesProvider';
10 | 
11 | /**
12 |  * Update bluetooth information
13 |  *
14 |  * Icon ref
15 |  * - https://mui.com/material-ui/material-icons/
16 |  */
17 | export function RestartButton() {
18 |   const { t } = useTranslation();
19 |   const { setDevices, setLoading, loading } = useDevicesContext();
20 | 
21 |   const restartHandler = useCallback(() => {
22 |     (async () => {
23 |       try {
24 |         setLoading(true);
25 |         await restartInterval();
26 |         await restartDeviceWatcher(); // set loading icon(by backend) & set device icon
27 |         setDevices(await getDevices());
28 |         setLoading(false);
29 |       } catch (err) {
30 |         NOTIFY.error(`${err}`);
31 |       }
32 |     })();
33 |   }, [setDevices, setLoading]);
34 | 
35 |   return (
36 |     }
39 |       loading={loading}
40 |       loadingPosition='end'
41 |       onClick={restartHandler}
42 |       tooltipTitle={t('restart-tooltip')}
43 |       type='submit'
44 |       variant='outlined'
45 |     />
46 |   );
47 | }
48 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/BluetoothGrid/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './DeviceCards';
2 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/CodeEditorTab/CodeEditorTab.tsx:
--------------------------------------------------------------------------------
 1 | import { TabContext, TabList, type TabListProps } from '@mui/lab';
 2 | import { Box, type SxProps, Tab, type Theme } from '@mui/material';
 3 | 
 4 | import { useStorageState } from '@/components/hooks/useStorageState/useStorageState';
 5 | import { useTranslation } from '@/components/hooks/useTranslation';
 6 | import { useCssContext } from '@/components/providers/CssProvider';
 7 | import { useJsContext } from '@/components/providers/JsProvider';
 8 | import { CSS } from '@/lib/css';
 9 | import { PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys';
10 | 
11 | import { type EditorInfo, EditorInitializer } from './EditorInitializer';
12 | import { schema } from './schema';
13 | 
14 | export const CodeEditorTab = () => {
15 |   const { js, setJs } = useJsContext();
16 |   const { css, setCss, setPreset } = useCssContext();
17 |   const { t } = useTranslation();
18 | 
19 |   const [value, setValue] = useStorageState(PUB_CACHE_OBJ.editorTabSelect, schema);
20 |   const handleTabChange: TabListProps['onChange'] = (_, newValue) => {
21 |     setValue(newValue);
22 |   };
23 | 
24 |   const editorValues = {
25 |     css: {
26 |       value: css,
27 |       fileName: 'style.css',
28 |       label: t('custom-css-label'),
29 |       language: 'css',
30 |       onChange: (value) => {
31 |         setCss(value);
32 |         CSS.css.set(value ?? '');
33 |         setPreset('0');
34 |       },
35 |     },
36 | 
37 |     javascript: {
38 |       value: js,
39 |       fileName: 'script.js',
40 |       label: t('custom-js-label'),
41 |       language: 'javascript',
42 |       onChange: setJs,
43 |     },
44 |   } as const satisfies EditorInfo;
45 | 
46 |   const editorValue = editorValues[value];
47 |   const labelSx = { textTransform: 'capitalize' } as const satisfies SxProps;
48 | 
49 |   return (
50 |     
51 |       
52 |         
53 |           
54 |           
55 |         
56 |       
57 | 
58 |       
65 |     
66 |   );
67 | };
68 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/CodeEditorTab/EditorInitializer.tsx:
--------------------------------------------------------------------------------
 1 | import { Typography } from '@mui/material';
 2 | 
 3 | import { MonacoEditor } from '@/components/organisms/MonacoEditor';
 4 | import { useEditorModeContext } from '@/components/providers/EditorModeProvider';
 5 | 
 6 | import type { ComponentPropsWithoutRef } from 'react';
 7 | 
 8 | export type EditorInfo = {
 9 |   css: Props;
10 |   javascript: Props;
11 | };
12 | 
13 | /** https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#multi-model-editor */
14 | type Props = {
15 |   value: string;
16 |   /** NOTE: If this is not changed, it is considered the same file change and the change history will be mixed. */
17 |   fileName: string;
18 |   label: string;
19 |   language: string;
20 |   onChange: ComponentPropsWithoutRef['onChange'];
21 | };
22 | 
23 | export const EditorInitializer = ({ value, fileName, label, language, onChange }: Props) => {
24 |   const { editorMode } = useEditorModeContext();
25 |   const isVimMode = editorMode === 'vim';
26 | 
27 |   return (
28 |     <>
29 |       
30 |         {label}
31 |       
32 |       
46 |     
47 |   );
48 | };
49 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/CodeEditorTab/index.ts:
--------------------------------------------------------------------------------
1 | export { CodeEditorTab } from './CodeEditorTab';
2 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/CodeEditorTab/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | 
3 | export const schema = z.enum(['javascript', 'css']).catch('javascript');
4 | 


--------------------------------------------------------------------------------
/gui/frontend/src/components/organisms/CssList/CssList.tsx:
--------------------------------------------------------------------------------
 1 | import { type SelectChangeEvent, Tooltip } from '@mui/material';
 2 | 
 3 | import { useTranslation } from '@/components/hooks/useTranslation';
 4 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel';
 5 | import { useCssContext } from '@/components/providers/CssProvider';
 6 | import { CSS } from '@/lib/css';
 7 | 
 8 | export const CssList = () => {
 9 |   const { t } = useTranslation();
10 |   const { preset, setPreset, setCss } = useCssContext();
11 | 
12 |   const handleChange = (e: SelectChangeEvent) => {
13 |     const presetN = CSS.normalize(e.target.value);
14 |     setPreset(presetN);
15 |     setCss(CSS.css.get(presetN));
16 |   };
17 | 
18 |   const title = (
19 |     <>
20 |       

{t('css-preset-list-tooltip')}

21 |

{t('css-preset-list-tooltip2')}

22 | 23 | ); 24 | 25 | return ( 26 | 27 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/CssList/index.tsx: -------------------------------------------------------------------------------- 1 | export { CssList } from './CssList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/EditorList/EditorList.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentPropsWithRef, useCallback } from 'react'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 5 | import { useEditorModeContext } from '@/components/providers/EditorModeProvider'; 6 | import { EDITOR_MODE } from '@/lib/editor-mode'; 7 | 8 | type OnChangeHandler = Exclude['onChange'], undefined>; 9 | 10 | export const EditorList = () => { 11 | const { t } = useTranslation(); 12 | const { editorMode: mode, setEditorMode } = useEditorModeContext(); 13 | 14 | const handleOnChange = useCallback( 15 | ({ target }) => { 16 | setEditorMode(EDITOR_MODE.normalize(target.value)); 17 | }, 18 | [setEditorMode], 19 | ); 20 | 21 | const menuItems = [ 22 | { value: 'default', label: 'Default' }, 23 | { value: 'vim', label: 'Vim' }, 24 | ] as const; 25 | 26 | return ( 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/EditorList/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SARDONYX-sard/bluetooth-battery-monitor/a742160125a1dc97adea68cd4fd235bc59764dfe/gui/frontend/src/components/organisms/EditorList/index.tsx -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/FixedNav/FixedNav.tsx: -------------------------------------------------------------------------------- 1 | import { Box, type SxProps, type Theme } from '@mui/material'; 2 | 3 | import { LogDirButton } from '@/components/molecules/LogDirButton'; 4 | import { LogFileButton } from '@/components/molecules/LogFileButton'; 5 | import { RestartButton } from '@/components/organisms/BluetoothGrid/RestartButton'; 6 | import { LogLevelList } from '@/components/organisms/LogLevelList'; 7 | import { MonitorConfigButton } from '@/components/organisms/MonitorConfig/MonitorConfigButton'; 8 | 9 | const sx: SxProps = { 10 | position: 'fixed', 11 | bottom: 50, 12 | width: '100%', 13 | display: 'flex', 14 | alignItems: 'center', 15 | padding: '10px', 16 | justifyContent: 'space-between', 17 | backgroundColor: '#252525d8', 18 | }; 19 | 20 | const MenuPadding = () =>
; 21 | 22 | export const FixedNav = () => { 23 | return ( 24 | <> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/FixedNav/index.tsx: -------------------------------------------------------------------------------- 1 | export { FixedNav } from './FixedNav'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/I18nList/I18nList.tsx: -------------------------------------------------------------------------------- 1 | import { changeLanguage } from 'i18next'; 2 | import { useState } from 'react'; 3 | 4 | import { useTranslation } from '@/components/hooks/useTranslation'; 5 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 6 | import { LANG } from '@/lib/i18n'; 7 | 8 | import type { SelectChangeEvent } from '@mui/material/Select'; 9 | 10 | export const I18nList = () => { 11 | const [lang, setLang] = useState(LANG.get()); 12 | const { t } = useTranslation(); 13 | 14 | const handleChange = async ({ target }: SelectChangeEvent) => { 15 | const newLocale = LANG.normalize(target.value); 16 | await changeLanguage(newLocale); 17 | 18 | const cacheLocale = target.value === 'auto' ? 'auto' : newLocale; 19 | setLang(cacheLocale); 20 | LANG.set(cacheLocale); 21 | }; 22 | 23 | // Sort by Alphabet 24 | const menuItems = [ 25 | { value: 'auto', label: t('lang-preset-auto') }, 26 | { value: 'en-US', label: 'English(US)' }, 27 | { value: 'ja-JP', label: 'Japanese' }, 28 | { value: 'ko-KR', label: 'Korean' }, 29 | { value: 'custom', label: t('lang-preset-custom') }, 30 | ] as const; 31 | 32 | return ; 33 | }; 34 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/I18nList/index.tsx: -------------------------------------------------------------------------------- 1 | export { I18nList } from './I18nList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/IconTypeList/IconTypeList.tsx: -------------------------------------------------------------------------------- 1 | import { type SelectChangeEvent, Tooltip } from '@mui/material'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 5 | import type { IconType } from '@/services/api/sys_tray'; 6 | 7 | export type IconTypeListProps = { 8 | iconType: IconType; 9 | handleIconType: (e: SelectChangeEvent) => void; 10 | }; 11 | 12 | export const IconTypeList = ({ iconType, handleIconType }: IconTypeListProps) => { 13 | const { t } = useTranslation(); 14 | 15 | // Sort by Alphabet 16 | const menuItems = [ 17 | { value: 'circle', label: t('icon-type.circle') }, 18 | { value: 'number_box', label: t('icon-type.number-box') }, 19 | ] as const; 20 | 21 | return ( 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/IconTypeList/index.tsx: -------------------------------------------------------------------------------- 1 | export { IconTypeList } from './IconTypeList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/LogLevelList/LogLevelList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 5 | import { NOTIFY } from '@/lib/notify'; 6 | import { LOG } from '@/services/api/log'; 7 | 8 | import { useLogLevelContext } from '../../providers/LogLevelProvider'; 9 | 10 | import type { SelectChangeEvent } from '@mui/material'; 11 | 12 | export const LogLevelList = () => { 13 | const { logLevel, setLogLevel } = useLogLevelContext(); 14 | 15 | const handleOnChange = useCallback( 16 | async ({ target }: SelectChangeEvent) => { 17 | const newLogLevel = LOG.normalize(target.value); 18 | setLogLevel(newLogLevel); 19 | await NOTIFY.asyncTry(async () => await LOG.changeLevel(newLogLevel)); 20 | }, 21 | [setLogLevel], 22 | ); 23 | 24 | const menuItems = [ 25 | { value: 'trace', label: 'Trace' }, 26 | { value: 'debug', label: 'Debug' }, 27 | { value: 'info', label: 'Info' }, 28 | { value: 'warn', label: 'Warning' }, 29 | { value: 'error', label: 'Error' }, 30 | ] as const; 31 | 32 | return ( 33 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/LogLevelList/index.tsx: -------------------------------------------------------------------------------- 1 | export { LogLevelList } from './LogLevelList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonacoEditor/index.tsx: -------------------------------------------------------------------------------- 1 | export { MonacoEditor } from './MonacoEditor'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonacoEditor/vim_key_bindings.ts: -------------------------------------------------------------------------------- 1 | import type { MonacoEditor, VimModeRef, VimStatusRef } from '@/components/organisms/MonacoEditor/MonacoEditor'; 2 | 3 | import type MonacoVim from 'monaco-vim'; 4 | import type { Vim } from 'monaco-vim'; 5 | 6 | type DefineVimExCommand = { 7 | actionId: string; 8 | editor: MonacoEditor; 9 | /** - `actionId: 'editor.action.jumpToBracket'` => `exCommand: 'jumpToBracket'` */ 10 | exCommand?: string; 11 | key: string; 12 | mode?: 'normal' | 'insert' | 'visual'; 13 | vim: Vim; 14 | }; 15 | 16 | const defineVimExCommand = ({ vim, exCommand, editor, actionId, key, mode }: DefineVimExCommand) => { 17 | const cmd = exCommand ?? actionId.split('.').at(-1) ?? actionId; 18 | vim.defineEx(cmd, cmd, () => { 19 | editor.getAction(actionId)?.run(); 20 | }); 21 | vim.map(key, `:${cmd}`, mode ?? 'normal'); 22 | }; 23 | 24 | const setCustomVimKeyConfig = (editor: MonacoEditor, vim: Vim) => { 25 | for (const key of ['jj', 'jk', 'kj'] as const) { 26 | vim.map(key, '', 'insert'); 27 | } 28 | 29 | const vimExCommands = [ 30 | { actionId: 'editor.action.jumpToBracket', key: '%' }, 31 | { actionId: 'editor.action.openLink', key: 'gx' }, 32 | { actionId: 'editor.action.revealDefinition', key: 'gd' }, 33 | { actionId: 'editor.action.showDefinitionPreviewHover', key: 'KK' }, // For some reason, it doesn't work. 34 | { actionId: 'editor.action.showHover', key: 'K' }, 35 | ] as const satisfies Omit[]; 36 | 37 | for (const command of vimExCommands) { 38 | defineVimExCommand({ ...command, vim, editor }); 39 | } 40 | }; 41 | 42 | type VimKeyLoader = (props: { editor: MonacoEditor; vimModeRef: VimModeRef; vimStatusRef: VimStatusRef }) => void; 43 | export const loadVimKeyBindings: VimKeyLoader = ({ editor, vimModeRef, vimStatusRef }) => { 44 | // @ts-ignore 45 | window.require.config({ 46 | paths: { 47 | 'monaco-vim': 'https://unpkg.com/monaco-vim/dist/monaco-vim', 48 | }, 49 | }); 50 | // @ts-ignore 51 | window.require(['monaco-vim'], (monacoVim: typeof MonacoVim) => { 52 | const { Vim } = monacoVim.VimMode; 53 | setCustomVimKeyConfig(editor, Vim); 54 | 55 | if (vimStatusRef.current) { 56 | vimModeRef.current = monacoVim.initVimMode(editor, vimStatusRef.current); 57 | } 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonitorConfig/AutoStartSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControlLabel, Skeleton, Tooltip } from '@mui/material'; 2 | import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | 5 | import { useTranslation } from '@/components/hooks/useTranslation'; 6 | import { NOTIFY } from '@/lib/notify'; 7 | 8 | const TooltipPlacement = 'top'; 9 | 10 | export const AutoStartSwitch = () => { 11 | const { t } = useTranslation(); 12 | const [isAutoStart, setIsAutoStart] = useState(null); 13 | 14 | useEffect(() => { 15 | (async () => { 16 | await NOTIFY.asyncTry(async () => setIsAutoStart(await isEnabled())); 17 | })(); 18 | }, []); 19 | 20 | const handleAutoStart = useCallback(async () => { 21 | const newIsAuto = !isAutoStart; 22 | setIsAutoStart(newIsAuto); 23 | 24 | if (newIsAuto) { 25 | await enable(); 26 | } else { 27 | await disable(); 28 | } 29 | }, [isAutoStart]); 30 | 31 | return isAutoStart !== null ? ( 32 | 33 | } 35 | label={t('autostart-label')} 36 | sx={{ m: 1, minWidth: 105 }} 37 | /> 38 | 39 | ) : ( 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonitorConfig/MonitorConfigButton.tsx: -------------------------------------------------------------------------------- 1 | import SettingsIcon from '@mui/icons-material/Settings'; 2 | import { useState } from 'react'; 3 | 4 | import { useTranslation } from '@/components/hooks/useTranslation'; 5 | import { ButtonWithToolTip } from '@/components/molecules/ButtonWithToolTip'; 6 | 7 | import { MonitorConfigDialog } from './MonitorConfigDialog'; 8 | 9 | import type React from 'react'; 10 | 11 | export const MonitorConfigButton: React.FC = () => { 12 | const { t } = useTranslation(); 13 | 14 | // relative to dialog 15 | const [isDialogOpen, setDialogOpen] = useState(false); 16 | const handleOpenDialog = () => setDialogOpen(true); 17 | const handleCloseDialog = () => setDialogOpen(false); 18 | 19 | return ( 20 | <> 21 | } 24 | onClick={handleOpenDialog} 25 | tooltipTitle={t('monitor.config-dialog.tooltip')} 26 | /> 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonitorConfig/MonitorConfigDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | 5 | import { ConfigFields } from './ConfigFields'; 6 | 7 | import type React from 'react'; 8 | 9 | interface MonitorConfigDialogProps { 10 | open: boolean; 11 | onClose: () => void; 12 | } 13 | 14 | export const MonitorConfigDialog: React.FC = ({ open, onClose }) => { 15 | const { t } = useTranslation(); 16 | 17 | return ( 18 | 19 | {`${t('monitor.config-dialog.title')}`} 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/MonitorConfig/NumericField.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton, TextField, Tooltip } from '@mui/material'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | import type { I18nKeys } from '@/lib/i18n'; 5 | 6 | import type { ChangeEventHandler } from 'react'; 7 | 8 | type NumericFieldProps = { 9 | label: I18nKeys; 10 | value: number | undefined; 11 | errorCondition: boolean; 12 | errorMessage: string; 13 | onChange: ChangeEventHandler; 14 | tooltipKey: I18nKeys; 15 | minValue: number; 16 | }; 17 | 18 | export const NumericField = ({ 19 | label, 20 | value, 21 | errorCondition, 22 | errorMessage, 23 | onChange, 24 | tooltipKey, 25 | minValue, 26 | }: NumericFieldProps) => { 27 | const { t } = useTranslation(); 28 | 29 | return value !== undefined ? ( 30 | 31 | 51 | 52 | ) : ( 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/NotifyList/MaxSnackField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from '@mui/material'; 2 | 3 | import type { ComponentPropsWithRef } from 'react'; 4 | 5 | type Props = { 6 | value: number; 7 | } & ComponentPropsWithRef; 8 | 9 | export const MaxSnackField = ({ value, ...props }: Props) => { 10 | const slotProps: Props['slotProps'] = { 11 | input: { inputProps: { min: 1 } }, 12 | inputLabel: { shrink: true }, 13 | }; 14 | 15 | return ( 16 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/NotifyList/NotifyList.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentPropsWithRef, useCallback, useState } from 'react'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 5 | import { NOTIFY_CONFIG } from '@/lib/notify/config'; 6 | 7 | import { MaxSnackField } from './MaxSnackField'; 8 | 9 | import type { SnackbarOrigin } from 'notistack'; 10 | 11 | type PosChangeHandler = Exclude['onChange'], undefined>; 12 | type SnackChangeHandler = ComponentPropsWithRef['onChange']; 13 | 14 | export const NotifyList = () => { 15 | const def = NOTIFY_CONFIG.getOrDefault(); 16 | const { t } = useTranslation(); 17 | const [pos, setPos] = useState(def.anchorOrigin); 18 | const [maxSnack, setMaxSnack] = useState(def.maxSnack); 19 | 20 | const handlePosChange = useCallback(({ target }) => { 21 | const newPosition = NOTIFY_CONFIG.anchor.fromStr(target.value); 22 | NOTIFY_CONFIG.anchor.set(newPosition); 23 | setPos(newPosition); 24 | }, []); 25 | 26 | const handleMaxSnackChange: SnackChangeHandler = ({ target }) => { 27 | const newMaxSnack = NOTIFY_CONFIG.limit.fromStr(target.value); 28 | NOTIFY_CONFIG.limit.set(newMaxSnack); 29 | setMaxSnack(newMaxSnack); 30 | }; 31 | 32 | const menuItems = [ 33 | { value: 'top_right', label: t('notice-position-top-right') }, 34 | { value: 'top_center', label: t('notice-position-top-center') }, 35 | { value: 'top_left', label: t('notice-position-top-left') }, 36 | { value: 'bottom_right', label: t('notice-position-bottom-right') }, 37 | { value: 'bottom_center', label: t('notice-position-bottom-center') }, 38 | { value: 'bottom_left', label: t('notice-position-bottom-left') }, 39 | ] as const; 40 | 41 | return ( 42 | <> 43 | 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/NotifyList/index.tsx: -------------------------------------------------------------------------------- 1 | export { NotifyList } from './NotifyList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/PageNavigation/index.tsx: -------------------------------------------------------------------------------- 1 | export { PageNavigation } from './PageNavigation'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/TabPositionList/TabPositionList.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from '@/components/hooks/useTranslation'; 2 | import { SelectWithLabel } from '@/components/molecules/SelectWithLabel'; 3 | import { tabPosSchema, useTabContext } from '@/components/providers/TabProvider'; 4 | 5 | import type { SelectChangeEvent } from '@mui/material/Select/Select'; 6 | 7 | export const TabPositionList = () => { 8 | const { t } = useTranslation(); 9 | const { tabPos, setTabPos } = useTabContext(); 10 | 11 | const handleChange = ({ target }: SelectChangeEvent) => { 12 | setTabPos(tabPosSchema.parse(target.value)); 13 | }; 14 | 15 | const menuItems = [ 16 | { value: 'top', label: t('tab-list-position-top') }, 17 | { value: 'bottom', label: t('tab-list-position-bottom') }, 18 | ] as const; 19 | 20 | return ( 21 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/TabPositionList/index.tsx: -------------------------------------------------------------------------------- 1 | export { TabPositionList } from './TabPositionList'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/Tabs/index.tsx: -------------------------------------------------------------------------------- 1 | export { Tabs } from './Tabs'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/Tabs/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const tabList = ['editor', 'notice', 'lang', 'tab', 'backup'] as const; 4 | export const TabSchema = z.enum(tabList).catch('editor'); 5 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/Updater/NavigationUpdater.tsx: -------------------------------------------------------------------------------- 1 | import UpdateIcon from '@mui/icons-material/Update'; 2 | import { BottomNavigationAction, Box, Tooltip } from '@mui/material'; 3 | import { useState } from 'react'; 4 | 5 | import { CircularProgressWithLabel } from '@/components/atoms/CircularWithLabel'; 6 | import { useTranslation } from '@/components/hooks/useTranslation'; 7 | 8 | import { UpdaterDialog } from './UpdaterDialog'; 9 | import { useUpdater } from './useUpdater'; 10 | 11 | import type React from 'react'; 12 | 13 | export const NavigationWithUpdater: React.FC = () => { 14 | const { t } = useTranslation(); 15 | 16 | const { isDownloading, isUpdatable, progress, handleRelaunch, oldVersion, newVersion } = useUpdater(); 17 | const versionInfo = oldVersion && newVersion ? `${oldVersion} => ${newVersion}` : ''; 18 | 19 | // relative to dialog 20 | const [isDialogOpen, setDialogOpen] = useState(false); 21 | const handleOpenDialog = () => setDialogOpen(true); 22 | const handleCloseDialog = () => setDialogOpen(false); 23 | 24 | return isDownloading || isUpdatable ? ( 25 | isUpdatable ? ( 26 | 27 | <> 28 | } label='Update' onClick={handleOpenDialog} showLabel={true} /> 29 | 30 | 31 | 32 | ) : ( 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | ) : null; 40 | }; 41 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/Updater/UpdaterDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; 2 | 3 | import { useTranslation } from '@/components/hooks/useTranslation'; 4 | 5 | import type React from 'react'; 6 | 7 | interface UpdaterDialogProps { 8 | open: boolean; 9 | onClose: () => void; 10 | onConfirm: () => void; 11 | versionInfo?: string; 12 | } 13 | 14 | export const UpdaterDialog: React.FC = ({ open, onClose, onConfirm, versionInfo }) => { 15 | const { t } = useTranslation(); 16 | const versions = versionInfo ? ` ${versionInfo}` : ''; 17 | 18 | return ( 19 | 20 | {`${t('updater.dialog.title')}${versions}`} 21 | 22 | {t('updater.dialog.message')} 23 | 24 | 25 | 28 | 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /gui/frontend/src/components/organisms/Updater/useUpdater.ts: -------------------------------------------------------------------------------- 1 | import { relaunch } from '@tauri-apps/plugin-process'; 2 | import { type Update, check } from '@tauri-apps/plugin-updater'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { NOTIFY } from '@/lib/notify'; 6 | 7 | // - ref: https://v2.tauri.app/plugin/updater/ 8 | export const useUpdater = () => { 9 | const [isDownloading, setDownloading] = useState(false); 10 | const [isUpdatable, setUpdatable] = useState(false); 11 | const [progress, setProgress] = useState(0); 12 | 13 | const [updater, setUpdater] = useState(null); 14 | const [oldVersion, setOldVersion] = useState(null); 15 | const [newVersion, setNewVersion] = useState(null); 16 | 17 | useEffect(() => { 18 | const checkForUpdates = async () => { 19 | const update = await check(); 20 | setUpdater(update); 21 | if (update?.available) { 22 | setOldVersion(update.currentVersion); 23 | setNewVersion(update.version); 24 | 25 | setDownloading(true); 26 | let downloaded = 0; 27 | let contentLength = 0; 28 | 29 | await update.download((event) => { 30 | switch (event.event) { 31 | case 'Started': { 32 | contentLength = event.data.contentLength ?? 0; 33 | break; 34 | } 35 | case 'Progress': { 36 | downloaded += event.data.chunkLength; 37 | const percentage = Math.round((downloaded / contentLength) * 100); 38 | setProgress(percentage); 39 | break; 40 | } 41 | case 'Finished': { 42 | setUpdatable(true); 43 | setProgress(100); 44 | break; 45 | } 46 | default: 47 | } 48 | }); 49 | } 50 | 51 | setDownloading(false); 52 | }; 53 | 54 | (async () => { 55 | try { 56 | await checkForUpdates(); 57 | } catch (err) { 58 | // biome-ignore lint/suspicious/noConsole: 59 | console.error(`[Failed to check update]: ${err}`); 60 | } 61 | })(); 62 | }, []); 63 | 64 | const handleRelaunch = async () => { 65 | try { 66 | if (updater) { 67 | await updater.install(); 68 | await relaunch(); 69 | } 70 | } catch (err) { 71 | NOTIFY.error(`[Failed to launch app]: ${err}`); 72 | } 73 | }; 74 | 75 | return { isDownloading, isUpdatable, progress, handleRelaunch, oldVersion, newVersion }; 76 | }; 77 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/CssProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { useInjectCss } from '@/components/hooks/useInjectCss'; 4 | 5 | import type React from 'react'; 6 | 7 | type ContextType = ReturnType; 8 | const Context = createContext(undefined); 9 | 10 | /** Wrapper component to allow user-defined css and existing css design presets to be retrieved/modified from anywhere */ 11 | export const CssProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 12 | const context = useInjectCss(); 13 | return {children}; 14 | }; 15 | 16 | /** 17 | * @throws `useCssContext must be used within a CssProvider` 18 | */ 19 | export const useCssContext = () => { 20 | const context = useContext(Context); 21 | if (!context) { 22 | throw new Error('useCssContext must be used within a CssProvider'); 23 | } 24 | return context; 25 | }; 26 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/EditorModeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type ReactNode, type SetStateAction, createContext, useContext } from 'react'; 2 | 3 | import { useStorageState } from '@/components/hooks/useStorageState'; 4 | import { EDITOR_MODE, type EditorMode } from '@/lib/editor-mode'; 5 | import { PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 6 | 7 | type ContextType = { 8 | editorMode: EditorMode; 9 | setEditorMode: Dispatch>; 10 | }; 11 | const Context = createContext(undefined); 12 | 13 | type Props = { children: ReactNode }; 14 | export const EditorModeProvider = ({ children }: Props) => { 15 | const [editorMode, setEditorMode] = useStorageState( 16 | PUB_CACHE_OBJ.editorMode, 17 | EDITOR_MODE.schema.catch(EDITOR_MODE.default), 18 | ); 19 | return {children}; 20 | }; 21 | 22 | /** 23 | * @throws `useEditorModeContext must be used within a EditorModeProvider` 24 | */ 25 | export const useEditorModeContext = () => { 26 | const context = useContext(Context); 27 | if (!context) { 28 | throw new Error('useEditorModeContext must be used within a EditorModeProvider'); 29 | } 30 | return context; 31 | }; 32 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/JsProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type ReactNode, type SetStateAction, createContext, useContext, useState } from 'react'; 2 | 3 | import { useStorageState } from '@/components/hooks/useStorageState'; 4 | import { STORAGE } from '@/lib/storage'; 5 | import { HIDDEN_CACHE_OBJ, PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 6 | import { boolSchema } from '@/lib/zod/schema-utils'; 7 | 8 | type ContextType = { 9 | js: string; 10 | setJs: (value?: string) => void; 11 | runScript: boolean; 12 | setRunScript: Dispatch>; 13 | }; 14 | const Context = createContext(undefined); 15 | 16 | type Props = { children: ReactNode }; 17 | 18 | /** Wrapper component to allow user-defined css and existing css design presets to be retrieved/modified from anywhere */ 19 | export const JsProvider = ({ children }: Props) => { 20 | const [runScript, setRunScript] = useStorageState(HIDDEN_CACHE_OBJ.runScript, boolSchema); 21 | const [js, setJs] = useState(STORAGE.get(PUB_CACHE_OBJ.customJs) ?? ''); 22 | 23 | const setHook = (value?: string) => { 24 | if (value) { 25 | setJs(value); 26 | STORAGE.set(PUB_CACHE_OBJ.customJs, value); 27 | } else { 28 | STORAGE.remove(PUB_CACHE_OBJ.customJs); 29 | } 30 | }; 31 | 32 | return {children}; 33 | }; 34 | 35 | /** 36 | * @throws `useJsContext must be used within a JsProvider` 37 | */ 38 | export const useJsContext = () => { 39 | const context = useContext(Context); 40 | if (!context) { 41 | throw new Error('useJsContext must be used within a JsProvider'); 42 | } 43 | return context; 44 | }; 45 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/LogLevelProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, createContext, useContext, useState } from 'react'; 2 | 3 | import { LOG, type LogLevel } from '@/services/api/log'; 4 | 5 | type ContextType = { 6 | logLevel: LogLevel; 7 | /** 8 | * setState & setLocalStorage 9 | * 10 | * # Note 11 | * It is necessary to call the backend API manually to actually change the log level, just to store the information. 12 | * (since that is not the responsibility of this function). 13 | */ 14 | setLogLevel: (value: LogLevel) => void; 15 | }; 16 | 17 | const Context = createContext(undefined); 18 | 19 | type Props = { children: ReactNode }; 20 | export const LogLevelProvider = ({ children }: Props) => { 21 | const [logLevel, setLogLevel] = useState(LOG.get()); 22 | const setHook = (value: LogLevel) => { 23 | setLogLevel(value); 24 | LOG.set(value); 25 | }; 26 | 27 | return {children}; 28 | }; 29 | 30 | /** 31 | * @throws `useLogLevelContext must be used within a LogLevelProvider` 32 | */ 33 | export const useLogLevelContext = () => { 34 | const context = useContext(Context); 35 | if (!context) { 36 | throw new Error('useEditorModeContext must be used within a EditorModeProvider'); 37 | } 38 | return context; 39 | }; 40 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/NotifyProvider.tsx: -------------------------------------------------------------------------------- 1 | import CloseIcon from '@mui/icons-material/Close'; 2 | import { Alert, AlertTitle, IconButton, type SxProps, type Theme } from '@mui/material'; 3 | import { type CustomContentProps, type SnackbarKey, SnackbarProvider, closeSnackbar } from 'notistack'; 4 | import { forwardRef, memo } from 'react'; 5 | 6 | import { NOTIFY_CONFIG } from '@/lib/notify/config'; 7 | 8 | /** 9 | * ref 10 | * - https://github.com/iamhosseindhv/notistack/issues/477#issuecomment-1885706867 11 | * @export 12 | */ 13 | export default function NotifyProvider() { 14 | const { anchorOrigin, maxSnack } = NOTIFY_CONFIG.getOrDefault(); 15 | 16 | return ( 17 | 30 | ); 31 | } 32 | 33 | /** It exists to realize the deletion of the history of the passage at any timing by Click. */ 34 | const action = (id: SnackbarKey) => ( 35 | closeSnackbar(id)} size='small'> 36 | 37 | 38 | ); 39 | 40 | const ThemeResponsiveSnackbar = memo( 41 | forwardRef(function ThemeResponsiveSnackbarCompRef(props, forwardedRef) { 42 | const { id, message, variant, hideIconVariant, style, className } = props; 43 | 44 | const action = (() => { 45 | const { action } = props; 46 | return typeof action === 'function' ? action(id) : action; 47 | })(); 48 | 49 | const severity = variant === 'default' ? 'info' : variant; 50 | 51 | const sx: SxProps = (theme) => ({ 52 | alignItems: 'center', 53 | backgroundColor: '#1a1919e1', 54 | borderRadius: '8px', 55 | boxShadow: theme.shadows[8], 56 | display: 'flex', 57 | maxWidth: '65vw', 58 | whiteSpace: 'pre', // ref: https://github.com/iamhosseindhv/notistack/issues/32 59 | willChange: 'transform', 60 | }); 61 | 62 | // HACK: Convert whitespace to a special Unicode space equal to the numeric width to alleviate whitespace misalignment. 63 | // - ref: https://www.fileformat.info/info/unicode/char/2007/index.htm 64 | const errMsg = message?.toString().replaceAll(' ', '\u2007'); 65 | 66 | return ( 67 | 77 | {severity.toUpperCase()} 78 | {errMsg} 79 | 80 | ); 81 | }), 82 | ); 83 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/TabProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type FC, type ReactNode, type SetStateAction, createContext, useContext } from 'react'; 2 | import { z } from 'zod'; 3 | 4 | import { useStorageState } from '@/components/hooks/useStorageState'; 5 | import { PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 6 | 7 | type TabPosition = 'top' | 'bottom'; 8 | type ContextType = { 9 | tabPos: TabPosition; 10 | setTabPos: Dispatch>; 11 | }; 12 | 13 | const Context = createContext(undefined); 14 | export const tabPosSchema = z.enum(['bottom', 'top']).catch('top'); 15 | 16 | type Props = { children: ReactNode }; 17 | export const TabProvider: FC = ({ children }) => { 18 | const [tabPos, setTabPos] = useStorageState(PUB_CACHE_OBJ.settingsTabPosition, tabPosSchema); 19 | return {children}; 20 | }; 21 | 22 | /** 23 | * @throws `useTabContext must be used within a TabProvider` 24 | */ 25 | export const useTabContext = () => { 26 | const context = useContext(Context); 27 | if (!context) { 28 | throw new Error('useJsContext must be used within a TabProvider'); 29 | } 30 | return context; 31 | }; 32 | -------------------------------------------------------------------------------- /gui/frontend/src/components/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; 2 | import NextLink from 'next/link'; 3 | 4 | import { CssProvider } from '@/components/providers/CssProvider'; 5 | import { EditorModeProvider } from '@/components/providers/EditorModeProvider'; 6 | import { JsProvider } from '@/components/providers/JsProvider'; 7 | import { LogLevelProvider } from '@/components/providers/LogLevelProvider'; 8 | import NotifyProvider from '@/components/providers/NotifyProvider'; 9 | import { TabProvider } from '@/components/providers/TabProvider'; 10 | 11 | import type { ComponentProps, ReactNode } from 'react'; 12 | 13 | const darkTheme = createTheme({ 14 | palette: { 15 | mode: 'dark', 16 | }, 17 | components: { 18 | // biome-ignore lint/style/useNamingConvention: 19 | MuiLink: { 20 | defaultProps: { 21 | component: (props: ComponentProps) => , 22 | }, 23 | }, 24 | // biome-ignore lint/style/useNamingConvention: 25 | MuiButtonBase: { 26 | defaultProps: { 27 | // biome-ignore lint/style/useNamingConvention: 28 | LinkComponent: (props: ComponentProps) => , 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | type Props = Readonly<{ children: ReactNode }>; 35 | 36 | export const GlobalProvider = ({ children }: Props) => { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {children} 47 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /gui/frontend/src/components/templates/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from '@mui/material'; 2 | 3 | /** This is executed on the server side, so the client's API cannot be used 4 | * (because it is an alternative page for preparing the Client). 5 | */ 6 | export default function Loading() { 7 | return ( 8 | 16 |

Loading...

17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /gui/frontend/src/components/templates/Settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; // If this directive is not present on each page, a build error will occur. 2 | import { Box, type SxProps, type Theme } from '@mui/material'; 3 | import Grid from '@mui/material/Grid2'; 4 | 5 | import { Help } from '@/components/atoms/Help'; 6 | import { useInjectJs } from '@/components/hooks/useInjectJs'; 7 | import { CodeEditorTab } from '@/components/organisms/CodeEditorTab'; 8 | import { Tabs } from '@/components/organisms/Tabs'; 9 | import { useTabContext } from '@/components/providers/TabProvider'; 10 | import { start } from '@/services/api/shell'; 11 | 12 | import packageJson from '@/../../package.json'; 13 | 14 | import type { MouseEventHandler } from 'react'; 15 | 16 | const sx: SxProps = { 17 | alignItems: 'center', 18 | display: 'flex', 19 | flexDirection: 'column', 20 | justifyContent: 'center', 21 | minHeight: 'calc(100vh - 56px)', 22 | width: '100%', 23 | }; 24 | 25 | export const Settings = () => { 26 | useInjectJs(); 27 | const { tabPos } = useTabContext(); 28 | 29 | return ( 30 | 31 | {tabPos === 'top' ? ( 32 | <> 33 | 34 | 35 | 36 | ) : ( 37 | <> 38 | 39 | 40 | 41 | )} 42 | 43 | ); 44 | }; 45 | 46 | const TabsMenu = () => { 47 | const handleHelpClick: MouseEventHandler = (_event) => { 48 | start(packageJson.homepage); // jump by backend api 49 | }; 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /gui/frontend/src/components/templates/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | export { Settings } from './Settings'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/components/templates/Top/Top.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; // If this directive is not present on each page, a build error will occur. 2 | import { Box, Skeleton, type SxProps, type Theme } from '@mui/material'; 3 | import { Suspense } from 'react'; 4 | 5 | import { useInjectJs } from '@/components/hooks/useInjectJs'; 6 | import { DeviceCards } from '@/components/organisms/BluetoothGrid'; 7 | import { DevicesProvider } from '@/components/organisms/BluetoothGrid/DevicesProvider'; 8 | import { FixedNav } from '@/components/organisms/FixedNav'; 9 | 10 | const sx: SxProps = { 11 | display: 'flex', 12 | flexDirection: 'column', 13 | alignItems: 'center', 14 | minHeight: 'calc(100vh - 56px)', 15 | width: '100%', 16 | }; 17 | 18 | export const Top = () => { 19 | useInjectJs(); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | const TopInner = () => { 31 | return ( 32 | }> 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /gui/frontend/src/components/templates/Top/index.tsx: -------------------------------------------------------------------------------- 1 | export { Top } from './Top'; 2 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/editor-mode/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { NOTIFY } from '@/lib/notify'; 4 | 5 | const DEFAULT: EditorMode = 'default'; 6 | 7 | /** Zod schema for validating the editor mode value. */ 8 | export const editorModeSchema = z.enum(['default', 'vim']); 9 | /** Automatically inferred `EditorMode` type from the schema. */ 10 | export type EditorMode = z.infer; 11 | 12 | /** 13 | * Normalizes the editor mode value. 14 | * If the provided mode is invalid, it falls back to the default mode. 15 | * 16 | * @param mode - The editor mode value 17 | * @returns A valid `EditorMode`. Defaults to `'default'` if invalid. 18 | */ 19 | const normalize = (mode: string): EditorMode => { 20 | const result = editorModeSchema.safeParse(mode); 21 | if (result.success) { 22 | return result.data; 23 | } 24 | 25 | const errMsg = result.error.errors.map((error) => error.message).join(', '); 26 | NOTIFY.error(`Invalid editor mode: ${errMsg}`); 27 | return DEFAULT; 28 | }; 29 | 30 | export const EDITOR_MODE = { 31 | /** The default editor mode. */ 32 | default: DEFAULT, 33 | schema: editorModeSchema, 34 | 35 | /** 36 | * Fallback to `'default'` if the value is `null` or `undefined`. 37 | * 38 | * @param mode - The editor mode value to normalize. 39 | * @returns A valid `EditorMode`. 40 | */ 41 | normalize, 42 | }; 43 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/notify/index.ts: -------------------------------------------------------------------------------- 1 | import { enqueueSnackbar } from 'notistack'; 2 | 3 | import type { OptionsObject, SnackbarMessage } from 'notistack'; 4 | 5 | /** 6 | * Wrapper to simplify refactoring of libraries such as snackbar and toast 7 | */ 8 | export const NOTIFY = { 9 | /** Show as `info` message. */ 10 | info(message: SnackbarMessage, options?: OptionsObject<'info'>) { 11 | return enqueueSnackbar(message, { variant: 'info', ...options }); 12 | }, 13 | /** Show as `success` message. */ 14 | success(message: SnackbarMessage, options?: OptionsObject<'success'>) { 15 | return enqueueSnackbar(message, { variant: 'success', ...options }); 16 | }, 17 | /** Show as `warning` message. */ 18 | warn(message: SnackbarMessage, options?: OptionsObject<'warning'>) { 19 | return enqueueSnackbar(message, { variant: 'warning', ...options }); 20 | }, 21 | /** Show as `error` message. */ 22 | error(message: SnackbarMessage, options?: OptionsObject<'error'>) { 23 | return enqueueSnackbar(message, { variant: 'error', ...options }); 24 | }, 25 | 26 | /** Try to execute function, and then catch & notify if error. */ 27 | try ReturnType>(tryFn: Fn): ReturnType | undefined { 28 | try { 29 | return tryFn(); 30 | } catch (error) { 31 | NOTIFY.error(`${error}`); 32 | } 33 | }, 34 | 35 | /** Try to execute async function, and then catch & notify if error. */ 36 | async asyncTry ReturnType>(tryFn: Fn): Promise | undefined> { 37 | try { 38 | return await tryFn(); 39 | } catch (error) { 40 | NOTIFY.error(`${error}`); 41 | } 42 | }, 43 | } as const; 44 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/notify/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const snackbarOriginSchema = z 4 | .object({ 5 | vertical: z.enum(['top', 'bottom']).catch('top'), 6 | horizontal: z.enum(['left', 'center', 'right']).catch('left'), 7 | }) 8 | .catch({ 9 | horizontal: 'left', 10 | vertical: 'top', 11 | }); 12 | 13 | // NOTE: Cannot convert string to number without `coerce: true`. 14 | // https://github.com/colinhacks/zod/discussions/330#discussioncomment-8895651 15 | export const snackbarLimitSchema = z.number({ coerce: true }).int().positive().catch(3); 16 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/object-utils/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | * 4 | * - ref: https://vitest.dev/config/#environment 5 | */ 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | import { OBJECT } from './'; 9 | 10 | describe('OBJECT.keys', () => { 11 | it('should return the correct keys for a given object', () => { 12 | const obj = { a: 1, b: 'test', c: true }; 13 | const keys = OBJECT.keys(obj); 14 | 15 | expect(keys).toEqual(['a', 'b', 'c']); 16 | expect(keys).toContain('a'); 17 | expect(keys).toContain('b'); 18 | expect(keys).toContain('c'); 19 | }); 20 | 21 | it('should correctly infer key types', () => { 22 | const obj = { x: 42, y: 'example' }; 23 | const keys = OBJECT.keys(obj); 24 | 25 | // Ensure that the inferred type is correct 26 | expect(typeof keys[0]).toBe('string'); 27 | }); 28 | }); 29 | 30 | describe('OBJECT.entries', () => { 31 | it('should return correct key-value pairs for a given object', () => { 32 | const obj = { a: 1, b: 'test', c: true }; 33 | const entries = OBJECT.entries(obj); 34 | 35 | expect(entries).toEqual([ 36 | ['a', 1], 37 | ['b', 'test'], 38 | ['c', true], 39 | ]); 40 | }); 41 | 42 | it('should correctly infer value types', () => { 43 | const obj = { x: 42, y: 'example', z: false }; 44 | const entries = OBJECT.entries(obj); 45 | 46 | for (const [key, value] of entries) { 47 | if (key === 'x') { 48 | expect(typeof value).toBe('number'); 49 | } 50 | if (key === 'y') { 51 | expect(typeof value).toBe('string'); 52 | } 53 | if (key === 'z') { 54 | expect(typeof value).toBe('boolean'); 55 | } 56 | } 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/object-utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility object that provides type-safe methods for working with object properties.(**without casting by `as`**) 3 | * 4 | * Forked by: https://zenn.dev/ossamoon/articles/694a601ee62526 5 | * 6 | * @remarks 7 | * The `OBJECT` contains two methods: `keys` and `entries`. 8 | * Both methods are generic and work with any object type, ensuring type safety by returning 9 | * appropriate key and value types based on the input object. 10 | * 11 | * @example 12 | * ```typescript 13 | * const exampleObject = { a: 42, b: "Hello", c: true }; 14 | * 15 | * // Using keys method 16 | * const keys = OBJECT.keys(exampleObject); 17 | * // keys is inferred as ("a" | "b" | "c")[] 18 | * console.log(keys); // Output: ["a", "b", "c"] 19 | * 20 | * // Using entries method 21 | * const entries = OBJECT.entries(exampleObject); 22 | * // entries is inferred as ["a" | "b" | "c", number | string | boolean][] 23 | * console.log(entries); 24 | * // Output: [["a", 42], ["b", "Hello"], ["c", true]] 25 | * ``` 26 | * 27 | * @template T - The type of the input object. 28 | */ 29 | export const OBJECT = { 30 | /** 31 | * Returns an array of keys from the given object, inferred as the keys' types. 32 | * 33 | * @param obj - The object from which to extract the keys. 34 | * @returns An array of keys of type (keyof T). 35 | * 36 | * @example 37 | * ```typescript 38 | * const obj = { x: 1, y: "text", z: false }; 39 | * const keys = OBJECT.keys(obj); 40 | * console.log(keys); // Output: ["x", "y", "z"] 41 | * ``` 42 | */ 43 | keys: (obj: T): (keyof T)[] => { 44 | return Object.keys(obj); 45 | }, 46 | 47 | values: (obj: T): T[keyof T][] => { 48 | return Object.values(obj); 49 | }, 50 | 51 | /** 52 | * Returns an array of key-value pairs from the given object, where each pair is a tuple 53 | * containing a key and its corresponding value. 54 | * 55 | * @param obj - The object from which to extract the key-value pairs. 56 | * @returns An array of tuples of type [keyof T, T[keyof T]]. 57 | * 58 | * @example 59 | * ```typescript 60 | * const obj = { a: 1, b: "string", c: true }; 61 | * const entries = OBJECT.entries(obj); 62 | * console.log(entries); 63 | * // Output: [["a", 1], ["b", "string"], ["c", true]] 64 | * ``` 65 | */ 66 | entries: (obj: T): [keyof T, T[keyof T]][] => { 67 | return Object.entries(obj); 68 | }, 69 | } as const; 70 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/storage/cacheKeys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * # Why use key value by object? 3 | * Extract strings by property identifier to automate key refactoring on the language server. 4 | * This facilitates modification. 5 | */ 6 | import { OBJECT } from '@/lib/object-utils'; 7 | 8 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 9 | 10 | const MONITOR_PRIVATE_CACHE_KEYS_OBJ = { 11 | devices: 'bluetooth-devices', 12 | }; 13 | 14 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 15 | 16 | const SETTINGS_PUB_CACHE_KEYS_OBJ = { 17 | customCss: 'custom-css', 18 | customJs: 'custom-js', 19 | customTranslationDict: 'custom-translation-dict', 20 | editorMode: 'editor-mode', 21 | editorTabSelect: 'editor-tab-select', 22 | lastPath: 'last-path', // last visited url(in App) 23 | locale: 'locale', 24 | logLevel: 'log-level', 25 | presetNumber: 'css-preset-number', 26 | selectedPage: 'selected-page', 27 | settingsTabSelect: 'settings-tab-select', 28 | settingsTabPosition: 'settings-tab-position', 29 | snackbarLimit: 'snackbar-limit', 30 | snackbarPosition: 'snackbar-position', 31 | } as const; 32 | 33 | const SETTINGS_PRIVATE_CACHE_KEYS_OBJ = { 34 | exportSettingsPath: 'export-settings-path', 35 | importSettingsPath: 'import-backup-path', 36 | langFilePath: 'lang-file-path', 37 | } as const; 38 | 39 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 40 | 41 | export const HIDDEN_CACHE_OBJ = { 42 | runScript: 'run-script', 43 | } as const; 44 | 45 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 46 | 47 | export const PUB_CACHE_OBJ = { 48 | ...SETTINGS_PUB_CACHE_KEYS_OBJ, 49 | } as const; 50 | 51 | export const PRIVATE_CACHE_OBJ = { 52 | ...MONITOR_PRIVATE_CACHE_KEYS_OBJ, 53 | ...SETTINGS_PRIVATE_CACHE_KEYS_OBJ, 54 | } as const; 55 | 56 | /** Public cache keys that are available and exposed for standard use in the application. */ 57 | export const PUB_CACHE_KEYS = [...OBJECT.values(PUB_CACHE_OBJ)] as const; 58 | 59 | /** Private cache keys that are internal to the application and may involve sensitive data or paths. */ 60 | const PRIVATE_CACHE_KEYS = [...OBJECT.values(PRIVATE_CACHE_OBJ)] as const; 61 | 62 | /** Hidden cache keys, typically used for restricted data like permissions for running scripts. */ 63 | export const HIDDEN_CACHE_KEYS = [...OBJECT.values(HIDDEN_CACHE_OBJ)] as const; 64 | 65 | /** Aggregated list of both public and private cache keys. */ 66 | export const CACHE_KEYS = [...PUB_CACHE_KEYS, ...PRIVATE_CACHE_KEYS] as const; 67 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_KEYS, HIDDEN_CACHE_KEYS, PUB_CACHE_KEYS } from './cacheKeys'; 2 | import { createStorage } from './storage'; 3 | 4 | import type { LocalCache } from './types'; 5 | 6 | type Mutable = { -readonly [P in keyof T]: T[P] }; 7 | 8 | export { PUB_CACHE_KEYS, CACHE_KEYS }; 9 | /** key/value pairs related to this project. */ 10 | export type Cache = LocalCache; 11 | export type CacheKey = keyof Cache; 12 | export type CacheKeyWithHide = CacheKey | Mutable[number]; 13 | 14 | export const STORAGE = createStorage({ 15 | cacheKeys: CACHE_KEYS, 16 | _hiddenKeys: HIDDEN_CACHE_KEYS, 17 | }); 18 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/storage/schemaStorage.ts: -------------------------------------------------------------------------------- 1 | import { type CacheKey, STORAGE } from '@/lib/storage'; 2 | import { stringToJsonSchema } from '@/lib/zod/json-validation'; 3 | 4 | import type { z } from 'zod'; 5 | 6 | /** 7 | * Provides methods for interacting with a storage system with schema validation. 8 | * 9 | * NOTE: Use `useStorageState` if you rely on `React.useState`. 10 | */ 11 | export const schemaStorage = { 12 | /** 13 | * Retrieves and validates data from storage. 14 | * 15 | * @template T - The type of the data. 16 | * @param {CacheKey} key - The key to retrieve the data from storage. 17 | * @param {z.ZodType} schema - The Zod schema used for validation. 18 | * @returns {T | null} - The parsed data if valid, otherwise `null`. 19 | */ 20 | get(key: CacheKey, schema: z.ZodType): T | null { 21 | const data = STORAGE.get(key); 22 | const result = stringToJsonSchema.catch(null).pipe(schema).safeParse(data); 23 | 24 | if (result.success) { 25 | return result.data; 26 | } 27 | return null; 28 | }, 29 | 30 | /** 31 | * Stores data in storage as a JSON string. 32 | * 33 | * @template T - The type of the value to be stored. 34 | * @param {CacheKey} key - The key to store the data under. 35 | * @param {T} value - The value to store. 36 | * @returns {void} 37 | */ 38 | set(key: CacheKey, value: T): void { 39 | STORAGE.set(key, JSON.stringify(value)); 40 | }, 41 | 42 | /** 43 | * Retrieves and validates data from storage, and returns it along with a function to update the value. 44 | * 45 | * @template T - The type of the data. 46 | * @param {CacheKey} key - The key to retrieve the data from storage. 47 | * @param {z.ZodType} schema - The Zod schema used for validation. 48 | * @returns {[T | null, (value: T) => void]} - A tuple containing the parsed data and a function to set the value. 49 | */ 50 | use(key: CacheKey, schema: z.ZodType): [T | null, (value: T) => void] { 51 | const value = this.get(key, schema); 52 | const setValue = (newValue: T) => { 53 | this.set(key, newValue); 54 | }; 55 | 56 | return [value, setValue]; 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/storage/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest'; 2 | 3 | import { createStorage } from './storage'; 4 | 5 | describe('Storage Utility', () => { 6 | beforeEach(() => { 7 | localStorage.clear(); 8 | }); 9 | 10 | const testHiddenKeys = ['hidden-key'] as const; 11 | const testKeys = ['key1', 'key2', ...testHiddenKeys] as const; 12 | const storage = createStorage({ cacheKeys: testKeys, _hiddenKeys: testHiddenKeys }); 13 | 14 | it('should set and get a cache item', () => { 15 | storage.set('key1', 'value1'); 16 | expect(storage.get('key1')).toBe('value1'); 17 | }); 18 | 19 | it('should return null for non-existing value', () => { 20 | expect(storage.get('key1')).toBe(null); 21 | }); 22 | 23 | it('should retrieve values for multiple cache keys', () => { 24 | storage.set('key1', 'value1'); 25 | storage.set('key2', 'value2'); 26 | 27 | const result = storage.getByKeys(['key1', 'key2']); 28 | expect(result).toEqual({ key1: 'value1', key2: 'value2' }); 29 | }); 30 | 31 | it('should return an empty object for no matching keys', () => { 32 | const result = storage.getByKeys([]); 33 | expect(result).toEqual({}); 34 | }); 35 | 36 | it('should retrieve all cache values for defined keys', () => { 37 | storage.set('key1', 'value1'); 38 | storage.set('key2', 'value2'); 39 | 40 | const result = storage.getAll(); 41 | expect(result).toEqual({ key1: 'value1', key2: 'value2' }); 42 | }); 43 | 44 | it('should remove a cache item', () => { 45 | storage.set('key1', 'value1'); 46 | storage.remove('key1'); 47 | expect(storage.get('key1')).toBe(null); 48 | }); 49 | 50 | it('should retrieve a value for a hidden cache key', () => { 51 | storage.set('hidden-key', 'hiddenValue'); 52 | expect(storage.getHidden('hidden-key')).toBe('hiddenValue'); 53 | }); 54 | 55 | it('should return null for non-existing hidden value', () => { 56 | expect(storage.getHidden('hidden-key')).toBe(null); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/storage/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type representing the available cache keys for storage. 3 | * The keys are defined in the cacheKeys.ts file and depend on the environment. 4 | */ 5 | export type CacheKey = K[number]; 6 | 7 | /** 8 | * Type representing hidden cache keys, typically used for sensitive or internal data. 9 | */ 10 | export type HiddenCacheKey = H[number]; 11 | 12 | /** 13 | * Type representing a partial key-value map for cached data. 14 | * Each key maps to a string value in the local storage. 15 | */ 16 | export type LocalCache = Partial, string>>; 17 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/zod/json-validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); 4 | type Literal = z.infer; 5 | type Json = Literal | { [key: string]: Json } | Json[]; 6 | const jsonSchema: z.ZodType = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])); 7 | 8 | /** 9 | * A utility function that returns a Zod schema which: 10 | * - Parses a JSON string. 11 | * - Validates the parsed object using the provided schema `T`. 12 | * 13 | * @param schema - The Zod schema to validate the parsed object. 14 | * @returns A Zod schema that parses JSON and validates the result. 15 | * 16 | * @see [Parsing a JSON string with zod](https://github.com/colinhacks/zod/discussions/2215#discussion-4977685) 17 | * 18 | * @example 19 | * 20 | * ```ts 21 | * const EditorModeSchema = z.enum(['default', 'vim']); 22 | * const result = stringToJsonSchema.pipe(EditorModeSchema).safeParse("default"); 23 | * if (result.success) { 24 | * return result.data; 25 | * } 26 | * ``` 27 | */ 28 | export const stringToJsonSchema = z.string().transform((str, ctx): z.infer => { 29 | try { 30 | return JSON.parse(str); 31 | } catch (e) { 32 | ctx.addIssue({ code: 'custom', message: `${e}` }); 33 | return z.NEVER; 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /gui/frontend/src/lib/zod/schema-utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const boolSchema = z.boolean().catch(false); 4 | export const stringArraySchema = z.array(z.string()).catch([]); 5 | export const stringSchema = z.string().catch(''); 6 | export const numberSchema = z.number().catch(0); 7 | 8 | /** 9 | * @see 10 | * [Enum From Object Literal Keys](https://github.com/colinhacks/zod/discussions/839) 11 | */ 12 | export const enumFromKeys = < 13 | // biome-ignore lint/suspicious/noExplicitAny: 14 | Rec extends Record, 15 | // biome-ignore lint/suspicious/noExplicitAny: 16 | K extends string = Rec extends Record ? R : never, 17 | >( 18 | input: Rec, 19 | ): z.ZodEnum<[K, ...K[]]> => { 20 | const [firstKey, ...otherKeys] = Object.keys(input) as [K, ...K[]]; 21 | return z.enum([firstKey, ...otherKeys]); 22 | }; 23 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/backup.ts: -------------------------------------------------------------------------------- 1 | import { save } from '@tauri-apps/plugin-dialog'; 2 | 3 | import { CACHE_KEYS, type Cache, STORAGE } from '@/lib/storage'; 4 | import { PRIVATE_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 5 | import { stringToJsonSchema } from '@/lib/zod/json-validation'; 6 | 7 | import { readFile, writeFile } from './fs'; 8 | 9 | const SETTINGS_FILE_NAME = 'settings'; 10 | 11 | export const BACKUP = { 12 | /** @throws Error | JsonParseError */ 13 | async import(): Promise { 14 | const settings = await readFile(PRIVATE_CACHE_OBJ.importSettingsPath, SETTINGS_FILE_NAME); 15 | if (settings) { 16 | const json = stringToJsonSchema.parse(settings); 17 | 18 | // Validate 19 | if (typeof json === 'object' && !Array.isArray(json) && json !== null) { 20 | for (const key of Object.keys(json)) { 21 | // NOTE: The import path selected immediately before should remain selectable the next time, so do not overwrite it. 22 | if (key === PRIVATE_CACHE_OBJ.importSettingsPath) { 23 | continue; 24 | } 25 | 26 | const isInvalidKey = !CACHE_KEYS.some((cacheKey) => cacheKey === key); 27 | if (isInvalidKey) { 28 | delete json[key]; 29 | } 30 | } 31 | 32 | return json as Partial>; 33 | } 34 | } 35 | }, 36 | 37 | /** @throws SaveError */ 38 | async export(settings: Cache) { 39 | const cachedPath = STORAGE.get(PRIVATE_CACHE_OBJ.exportSettingsPath); 40 | const path = await save({ 41 | defaultPath: cachedPath ?? 'settings.json', 42 | filters: [{ name: SETTINGS_FILE_NAME, extensions: ['json'] }], 43 | }); 44 | 45 | if (typeof path === 'string') { 46 | await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`); 47 | return path; 48 | } 49 | return null; 50 | }, 51 | } as const; 52 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/bluetooth_config.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | 3 | import type { IconType } from '@/services/api/sys_tray'; 4 | 5 | export type Config = { 6 | /** e.g. `0` */ 7 | address: number; 8 | 9 | /** e.g. `60`(minutes) == 1hour */ 10 | // biome-ignore lint/style/useNamingConvention: 11 | battery_query_duration_minutes: number; 12 | 13 | /** e.g. `20`(%) */ 14 | // biome-ignore lint/style/useNamingConvention: 15 | notify_battery_level: number; 16 | 17 | // biome-ignore lint/style/useNamingConvention: 18 | icon_type: IconType 19 | }; 20 | 21 | export const CONFIG = { 22 | default: { 23 | address: 0, 24 | // biome-ignore lint/style/useNamingConvention: 25 | battery_query_duration_minutes: 60, 26 | // biome-ignore lint/style/useNamingConvention: 27 | notify_battery_level: 20, 28 | // biome-ignore lint/style/useNamingConvention: 29 | icon_type: 'circle', 30 | } as const satisfies Config, 31 | 32 | /** 33 | * Read bluetooth finder configuration 34 | */ 35 | async read() { 36 | return await invoke('read_config'); 37 | }, 38 | 39 | /** 40 | * Read bluetooth finder configuration 41 | */ 42 | async write(config: Config) { 43 | await invoke('write_config', { config }); 44 | }, 45 | } as const; 46 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/bluetooth_finder.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import { z } from 'zod'; 3 | 4 | export const BluetoothDeviceInfoSchema = z 5 | .object({ 6 | /** e.g. `E500Pro Hands-Free AG` */ 7 | // biome-ignore lint/style/useNamingConvention: 8 | friendly_name: z.string(), 9 | 10 | /** e.g. `BTHENUM\\{0000111E-0000-1000-8000-00805F9B34FB}_LOCALMFG&005D...` */ 11 | // biome-ignore lint/style/useNamingConvention: 12 | instance_id: z.string(), 13 | 14 | address: z.number(), 15 | 16 | /** e.g. 80(%) */ 17 | // biome-ignore lint/style/useNamingConvention: 18 | battery_level: z.number(), 19 | 20 | category: z.string(), 21 | 22 | // biome-ignore lint/style/useNamingConvention: 23 | is_connected: z.boolean(), 24 | 25 | /** 26 | * Native time. e.g. `2024/4/19 22:42:16` 27 | */ 28 | // biome-ignore lint/style/useNamingConvention: 29 | last_used: z.string(), 30 | 31 | /** 32 | * Native time. e.g. `2024/4/19 22:42:16` 33 | */ 34 | // biome-ignore lint/style/useNamingConvention: 35 | last_updated: z.string(), 36 | }) 37 | .catch({ 38 | // biome-ignore lint/style/useNamingConvention: 39 | friendly_name: 'Unknown', 40 | // biome-ignore lint/style/useNamingConvention: 41 | instance_id: 'Unknown', 42 | address: 0, 43 | // biome-ignore lint/style/useNamingConvention: 44 | battery_level: 0, 45 | category: 'Unknown', 46 | // biome-ignore lint/style/useNamingConvention: 47 | is_connected: false, 48 | // biome-ignore lint/style/useNamingConvention: 49 | last_used: 'Unknown', 50 | // biome-ignore lint/style/useNamingConvention: 51 | last_updated: 'Unknown', 52 | }); 53 | 54 | // Optional: Create a TypeScript type from the schema for type safety. 55 | export type BluetoothDeviceInfo = z.infer; 56 | 57 | export type Devices = Record; 58 | 59 | /** 60 | * Restart device watcher to get bluetooth devices information. 61 | * @throws `Error` 62 | */ 63 | export async function restartDeviceWatcher() { 64 | await invoke('restart_device_watcher'); 65 | } 66 | 67 | /** 68 | * Get devices information 69 | * @throws `Error` 70 | */ 71 | export async function getDevices() { 72 | return await invoke('get_devices'); 73 | } 74 | 75 | /** 76 | * Restart interval to get bluetooth device information. 77 | * @throws `Error` 78 | */ 79 | export async function restartInterval() { 80 | await invoke('restart_interval'); 81 | } 82 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/device_listener.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '@tauri-apps/api/event'; 2 | 3 | import { NOTIFY } from '@/lib/notify'; 4 | import type { BluetoothDeviceInfo, Devices } from '@/services/api/bluetooth_finder'; 5 | 6 | import type { EventCallback } from '@tauri-apps/api/event'; 7 | import type { JSX } from 'react/jsx-runtime'; 8 | 9 | type ListenerProps = { 10 | setDev: (devices: BluetoothDeviceInfo) => void; 11 | /** @default Error */ 12 | error?: string | JSX.Element; 13 | }; 14 | 15 | export async function deviceListener({ setDev, error }: ListenerProps) { 16 | let unlisten: (() => void) | null = null; 17 | const eventHandler: EventCallback = (event) => { 18 | if (event.payload) { 19 | setDev(event.payload); 20 | } 21 | }; 22 | 23 | try { 24 | // Setup before run Promise(For event hook) 25 | unlisten = await listen('bt_monitor://update_devices', eventHandler); 26 | return unlisten; 27 | } catch (err) { 28 | NOTIFY.error(error ?? `${err}`); 29 | if (unlisten) { 30 | unlisten(); 31 | } 32 | } 33 | } 34 | 35 | export async function devicesListener({ 36 | setDev, 37 | error, 38 | }: { 39 | setDev: (devices: Devices) => void; 40 | /** @default Error */ 41 | error?: string | JSX.Element; 42 | }) { 43 | let unlisten: (() => void) | null = null; 44 | const eventHandler: EventCallback = (event) => { 45 | if (event.payload) { 46 | setDev(event.payload); 47 | } 48 | }; 49 | 50 | try { 51 | // Setup before run Promise(For event hook) 52 | unlisten = await listen('bt_monitor://restart_devices', eventHandler); 53 | return unlisten; 54 | } catch (err) { 55 | NOTIFY.error(error ?? `${err}`); 56 | if (unlisten) { 57 | unlisten(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/dialog.ts: -------------------------------------------------------------------------------- 1 | import { type OpenDialogOptions, open } from '@tauri-apps/plugin-dialog'; 2 | 3 | type OpenOptions = { 4 | /** 5 | * path setter. 6 | * - If we don't get the result within this function, somehow the previous value comes in.(React component) 7 | * @param path 8 | * @returns 9 | */ 10 | setPath?: (path: string) => void; 11 | } & OpenDialogOptions; 12 | 13 | /** 14 | * Open a file or Dir 15 | * @returns selected path or cancelled null 16 | * @throws 17 | */ 18 | export async function openPath(path: string, options: OpenOptions = {}): Promise { 19 | const res = await open({ 20 | defaultPath: path, 21 | ...options, 22 | }); 23 | 24 | if (typeof res === 'string' && options.setPath) { 25 | options.setPath(res); 26 | } 27 | return res; 28 | } 29 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/fs.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import { readTextFile } from '@tauri-apps/plugin-fs'; 3 | import { z } from 'zod'; 4 | 5 | import type { CacheKey } from '@/lib/storage'; 6 | import { schemaStorage } from '@/lib/storage/schemaStorage'; 7 | 8 | import { openPath } from './dialog'; 9 | 10 | /** 11 | * Reads the entire contents of a file into a string. 12 | * 13 | * @param pathCacheKey - Target path cache key. 14 | * @param filterName - Name of the filter to be displayed in the file dialog. 15 | * @param extensions - Array of file extensions to be filtered in the file dialog. Default is `['json']`. 16 | * 17 | * @returns A promise that resolves to the contents of the file if successful, or `null` if the user cancels the file dialog. 18 | * 19 | * @throws Throws an `Error` if there is an issue reading the file. 20 | */ 21 | export async function readFile(pathCacheKey: CacheKey, filterName: string, extensions = ['json']) { 22 | const [path, setPath] = schemaStorage.use(pathCacheKey, z.string()); 23 | const selectedPath = await openPath(path ?? '', { 24 | setPath, 25 | filters: [{ name: filterName, extensions }], 26 | multiple: false, 27 | }); 28 | 29 | if (typeof selectedPath === 'string') { 30 | return await readTextFile(selectedPath); 31 | } 32 | return null; 33 | } 34 | 35 | /** 36 | * Alternative file writing API to avoid tauri API bug. 37 | * 38 | * # NOTE 39 | * We couldn't use `writeTextFile`! 40 | * - The `writeTextFile` of tauri's api has a bug that the data order of some contents is unintentionally swapped. 41 | * @param path - path to write 42 | * @param content - string content 43 | * @throws Error 44 | */ 45 | export async function writeFile(path: string, content: string) { 46 | await invoke('write_file', { path, content }); 47 | } 48 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/lang.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from './fs'; 2 | 3 | /** 4 | * Read the entire contents of a file into a string. 5 | * @param {string} path - target path 6 | * @return [isCancelled, contents] 7 | * @throws 8 | */ 9 | export async function importLang() { 10 | return await readFile('lang-file-path', 'Custom Language'); 11 | } 12 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/log.ts: -------------------------------------------------------------------------------- 1 | import { app } from '@tauri-apps/api'; 2 | import { invoke } from '@tauri-apps/api/core'; 3 | import { appLogDir } from '@tauri-apps/api/path'; 4 | import { open } from '@tauri-apps/plugin-shell'; 5 | import { z } from 'zod'; 6 | 7 | import { STORAGE } from '@/lib/storage'; 8 | import { PUB_CACHE_OBJ } from '@/lib/storage/cacheKeys'; 9 | import { stringToJsonSchema } from '@/lib/zod/json-validation'; 10 | 11 | const logList = ['trace', 'debug', 'info', 'warn', 'error'] as const; 12 | const DEFAULT = 'error'; 13 | const logLevelSchema = z.enum(logList).catch(DEFAULT); 14 | export type LogLevel = z.infer; 15 | 16 | /** @default `error` */ 17 | const normalize = (logLevel?: string | null): LogLevel => { 18 | return logLevelSchema.parse(logLevel); 19 | }; 20 | 21 | export const LOG = { 22 | default: DEFAULT, 23 | 24 | /** 25 | * Opens the log file. 26 | * @throws - if not found path 27 | */ 28 | async openFile() { 29 | const logFile = `${await appLogDir()}/${await app.getName()}.log`; 30 | await open(logFile); 31 | }, 32 | 33 | /** 34 | * Opens the log directory. 35 | * @throws - if not found path 36 | */ 37 | async openDir() { 38 | await open(await appLogDir()); 39 | }, 40 | 41 | /** 42 | * Invokes the `change_log_level` command with the specified log level. 43 | * @param logLevel - The log level to set. If not provided, the default log level will be used. 44 | * @returns A promise that resolves when the log level is changed. 45 | */ 46 | async changeLevel(logLevel?: LogLevel) { 47 | await invoke('change_log_level', { logLevel }); 48 | }, 49 | 50 | normalize, 51 | 52 | /** get current log level from `LocalStorage`. */ 53 | get() { 54 | return stringToJsonSchema.catch('error').pipe(logLevelSchema).parse(STORAGE.get(PUB_CACHE_OBJ.logLevel)); 55 | }, 56 | 57 | /** set log level to `LocalStorage`. */ 58 | set(level: LogLevel) { 59 | STORAGE.set(PUB_CACHE_OBJ.logLevel, JSON.stringify(level)); 60 | }, 61 | } as const; 62 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/patch.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | 3 | export type ModInfo = { 4 | id: string; 5 | name: string; 6 | author: string; 7 | site: string; 8 | auto: string; 9 | }; 10 | 11 | export type ModIds = readonly string[]; 12 | 13 | /** 14 | * Load mods `info.ini` 15 | * @throws Error 16 | */ 17 | export async function loadModsInfo(searchGlob: string) { 18 | return await invoke('load_mods_info', { glob: searchGlob }); 19 | } 20 | 21 | /** 22 | * Load activate mods id 23 | * @example ['aaa', 'bbb'] 24 | * @throws Error 25 | */ 26 | export async function patch(output: string, ids: ModIds) { 27 | await invoke('patch', { output, ids }); 28 | } 29 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/shell.ts: -------------------------------------------------------------------------------- 1 | import { open } from '@tauri-apps/plugin-shell'; 2 | 3 | import { NOTIFY } from '@/lib/notify'; 4 | 5 | /** 6 | * Wrapper tauri's `open` with `notify.error` 7 | * 8 | * # Why need this? 9 | * Use the backend api to jump to the link so that it can be opened in the default browser without opening it in the webview. 10 | * 11 | * @export 12 | * @param {string} path 13 | * @param {string} [openWith] 14 | */ 15 | export async function start(path: string, openWith?: string) { 16 | await NOTIFY.asyncTry(async () => await open(path, openWith)); 17 | } 18 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/sys_tray.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | 3 | export type IconType = 'circle' | 'number_box'; 4 | 5 | export const normalizeIconType = (iconType: string): IconType => { 6 | return iconType.toLowerCase() === 'circle' ? 'circle' : 'number_box'; 7 | }; 8 | 9 | /** set device info icon */ 10 | export async function updateTrayIcon( 11 | deviceName: string, 12 | batteryLevel: number, 13 | isConnected: boolean, 14 | iconType: IconType, 15 | ): Promise { 16 | await invoke('update_tray', { deviceName, batteryLevel, isConnected, iconType }); 17 | } 18 | 19 | /** set Loading tray icon */ 20 | export async function defaultTrayIcon() { 21 | await invoke('default_tray'); 22 | } 23 | -------------------------------------------------------------------------------- /gui/frontend/src/services/api/window.ts: -------------------------------------------------------------------------------- 1 | import { isTauri } from '@tauri-apps/api/core'; 2 | import { getCurrentWindow } from '@tauri-apps/api/window'; 3 | 4 | /** HACK: Avoid blank white screen on load. 5 | * - https://github.com/tauri-apps/tauri/issues/5170#issuecomment-2176923461 6 | * - https://github.com/tauri-apps/tauri/issues/7488 7 | */ 8 | export function showWindow() { 9 | if (typeof window !== 'undefined' && isTauri()) { 10 | getCurrentWindow().show(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /gui/frontend/src/services/readme.md: -------------------------------------------------------------------------------- 1 | # Backend api 2 | -------------------------------------------------------------------------------- /gui/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "removeComments": false, 15 | "preserveConstEnums": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "strictNullChecks": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ], 26 | "paths": { 27 | "@/*": ["./src/*"] 28 | }, 29 | "forceConsistentCasingInFileNames": true 30 | }, 31 | "include": [ 32 | "**/*.ts", 33 | "**/*.tsx", 34 | "../../vitest.config.ts", 35 | "./out/types/**/*.ts", 36 | ".next/types/**/*.ts", 37 | "next-env.d.ts" 38 | ], 39 | "exclude": ["node_modules"] 40 | } 41 | -------------------------------------------------------------------------------- /gui/frontend/vitest.setup.mts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | import { loadEnvConfig } from '@next/env'; 3 | import { configure } from '@testing-library/react'; 4 | 5 | loadEnvConfig(process.cwd()); 6 | 7 | configure({ 8 | testIdAttribute: 'data-test', 9 | }); 10 | -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | ./en-US.json -------------------------------------------------------------------------------- /locales/readme.md: -------------------------------------------------------------------------------- 1 | # Localization 2 | 3 | This files in are language tags according to the BCP-47 standard. 4 | 5 | - See: 6 | [Content Partner Hub | BCP-47 Language Tags](https://partnerhub.warnermediagroup.com/metadata/languages) 7 | 8 | ## Note 9 | 10 | This file information is currently only used on the front end of the GUI. 11 | It is placed in the root directory for clarity and for future reference. 12 | 13 | The file `en.json` is not used in the `en-US.json` symbolic link. 14 | It exists to prevent erroneous error reporting of i18n's VS Code extension. 15 | 16 | ## Implementation 17 | 18 | Just adding json to locales is not yet available. 19 | 20 | Adding languages to the following components will allow for GUI selection and automatic inference. 21 | 22 | It should be added like any other language selection. 23 | 24 | - [menuItems](../gui/frontend/src/components/organisms/I18nList/I18nList.tsx) 25 | - [RESOURCES & normalize](../gui/frontend/src/lib/i18n/index.ts) 26 | 27 | Example: [here](https://github.com/SARDONYX-sard/bluetooth-battery-monitor/pull/21/files) 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bluetooth-battery-monitor", 3 | "description": "A simple app to monitor the battery of your bluetooth devices", 4 | "version": "0.5.2", 5 | "license": "MIT", 6 | "homepage": "https://github.com/SARDONYX-sard/bluetooth-battery-monitor", 7 | "private": true, 8 | "type": "module", 9 | "scripts": { 10 | "dev": "tauri dev", 11 | "dev:front": "next ./gui/frontend", 12 | "build": "rimraf ./gui/frontend/out && tauri build --no-bundle -- --profile release-no-lto", 13 | "build:front": "next build ./gui/frontend", 14 | "build:icons": "cargo tauri icon ./gui/backend/icons/icon.png", 15 | "test": "vitest run", 16 | "test:back": " cargo test --workspace", 17 | "test:all": "npm test && npm run test:back", 18 | "fmt": "biome format --write ./ && prettier --cache --write \"**/*.+(yml|yaml)\" --ignore-path ./.gitignore && cargo fmt --all", 19 | "lint": "next lint ./gui/frontend && biome lint ./ && cargo clippy --workspace", 20 | "lint:fix": "npm run fmt && next lint ./gui/frontend --fix && biome check --apply ./ && cargo clippy --workspace --fix --allow-staged --allow-dirty", 21 | "tauri": "tauri" 22 | }, 23 | "dependencies": { 24 | "@monaco-editor/react": "^4.6.0", 25 | "@mui/icons-material": "6.4.0", 26 | "@mui/lab": "6.0.0-beta.23", 27 | "@mui/material": "6.4.0", 28 | "@tauri-apps/api": "2.2.0", 29 | "@tauri-apps/plugin-autostart": "2.2.0", 30 | "@tauri-apps/plugin-dialog": "^2.2.0", 31 | "@tauri-apps/plugin-fs": "2.2.0", 32 | "@tauri-apps/plugin-notification": "2.2.1", 33 | "@tauri-apps/plugin-process": "^2.2.0", 34 | "@tauri-apps/plugin-shell": "^2.2.0", 35 | "@tauri-apps/plugin-updater": "^2.3.1", 36 | "babel-plugin-react-compiler": "^19.0.0-beta-b2e8e9c-20241220", 37 | "i18next": "24.2.1", 38 | "next": "15.1.4", 39 | "notistack": "^3.0.1", 40 | "react": "18.3.1", 41 | "react-dom": "18.3.1", 42 | "react-i18next": "15.4.0", 43 | "react-use": "^17.6.0", 44 | "zod": "^3.24.1" 45 | }, 46 | "devDependencies": { 47 | "@biomejs/biome": "1.9.3", 48 | "@tauri-apps/cli": "2.2.5", 49 | "@testing-library/jest-dom": "^6.6.3", 50 | "@testing-library/react": "^16.1.0", 51 | "@types/node": "22.10.6", 52 | "@types/react": "19.0.7", 53 | "@types/react-dom": "19.0.3", 54 | "@vitejs/plugin-react-swc": "^3.7.2", 55 | "eslint": "^8.57.0", 56 | "eslint-config-next": "15.1.4", 57 | "jsdom": "^25.0.1", 58 | "prettier": "^3.4.2", 59 | "rimraf": "^6.0.1", 60 | "typescript": "5.7.3", 61 | "vite-tsconfig-paths": "^5.1.4", 62 | "vitest": "2.1.8" 63 | }, 64 | "overrides": { 65 | "monaco-editor": "^0.49.0" 66 | }, 67 | "packageManager": "npm@10.8.1" 68 | } 69 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Bluetooth Battery Monitor 2 | 3 | 17 | 18 |

19 | bluetooth battery monitor icon 20 |

21 | 22 | ## Getting Started for User 23 | 24 | - [Release Page(& Click `Assets`)](https://github.com/SARDONYX-sard/bluetooth-battery-monitor/releases) 25 | 26 | ## Viewing and clicking icons on the taskbar 27 | 28 | - Left-click once on the icon: Toggle the Bluetooth device information list. 29 | - Right click on the icon: Opens a small menu 30 | 31 | - Hover the mouse over the icon to see ` %`. 32 | 33 | The circle on that side shows the percentage, and if you choose to show unconnected devices in the tray, the area around them will turn gray. 34 | 35 | Example: 80% battery + no connection 36 | 37 | ![{FB9E0583-6873-48AD-8304-58FDBF7CCE87}](https://github.com/user-attachments/assets/d195bcb5-cddd-4e69-a58c-31846bc3d3c1) 38 | 39 | ## Features 40 | 41 | - [x] Search Bluetooth(Classic) battery info 42 | 43 | - [x] Autostart at PC startup 44 | - [x] Localization(could be customized) 45 | - [x] Custom edit JavaScript & CSS 46 | 47 | ## UnSupported Features(I don't have a supported device.) 48 | 49 | - Bluetooth LE device search 50 | - Battery information acquisition for Bluetooth LE 51 | 52 | ## Device List menu image(v4) 53 | 54 | It takes a little time to retrieve the list; wait 6~10 seconds. 55 | 56 | - (You need to choose settings -> editor/preset -> css preset 4 for this design.) 57 | 58 | ![Device List](https://github.com/user-attachments/assets/d6bf2e5c-2ed5-4dec-9260-3bbc99a44552) 59 | 60 | ## Licenses 61 | 62 | Licensed under either of 63 | 64 | - Apache License, Version 2.0 65 | ([LICENSE-APACHE](LICENSE-APACHE) or ) 66 | - MIT license 67 | ([LICENSE-MIT](LICENSE-MIT) or ) 68 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // This file is a test configuration file for gui/frontend. 2 | // By placing the configuration file in the root directory, it eliminates wasted time in directory searches 3 | // and prevents time delays in testing. 4 | 5 | import react from '@vitejs/plugin-react-swc'; 6 | import tsconfigPaths from 'vite-tsconfig-paths'; 7 | import { defineConfig } from 'vitest/config'; 8 | 9 | export default defineConfig({ 10 | plugins: [react(), tsconfigPaths()], 11 | test: { 12 | alias: [{ find: '@/', replacement: `${__dirname}/gui/frontend/src/` }], 13 | globals: true, 14 | root: './gui/frontend/src/', 15 | environment: 'jsdom', 16 | setupFiles: ['./vitest.setup.mts'], 17 | testTransformMode: { 18 | ssr: ['**/*'], 19 | }, 20 | reporters: ['default', 'hanging-process'], 21 | }, 22 | }); 23 | --------------------------------------------------------------------------------